@lark-project/cli
Version:
飞书项目插件开发工具
267 lines (266 loc) • 11.8 kB
JavaScript
;
/**
* Runtime URL placeholder validator.
*
* Runtime URLs (those that the product actually requests at runtime) must be
* real. Placeholder/example values will cause silent failures in production
* (blank columns, broken buttons, missed events).
*
* This validator is deliberately static and conservative: it matches known
* placeholder patterns but does not verify reachability. It catches the
* common AI fabrication modes (example.com, your-server, localhost,
* placeholder, reserved TLDs, etc.) without network side effects.
*
* Two severities (see `kind`):
* - 'invalid' — missing / empty / not http(s):// — a structural defect.
* Callers hard-block (exit 1): the config cannot be used.
* - 'placeholder' — looks fabricated (example.com / your-server / localhost /
* <PLACEHOLDER:…> …). Callers DO NOT block — they only warn
* and push as-is. This matches the documented two-step flow
* (fill a placeholder at Stage Config, swap the real public
* URL in later / in the developer console). The notice
* discharges the duty to inform; replacing is the user's job.
*
* Full field taxonomy and rationale see:
* jy-skill/skills/meego-shared/references/url-policy.md
* jy-skill/skills/meego-point-config/references/apply.md (A0.5)
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.formatPlaceholderNotice = exports.formatViolations = exports.splitViolations = exports.validateRuntimeUrls = void 0;
const PLACEHOLDER_PATTERNS = [
{ re: /example\.(com|org|net)/i, reason: 'example.com/.org/.net is a reserved documentation domain' },
{ re: /\.(test|example|invalid|localhost)(?:[\/:]|$)/i, reason: 'RFC 2606 reserved TLD (never resolvable)' },
{ re: /\byour-(server|domain|api|host|webhook)\b/i, reason: 'looks like a scaffolding placeholder' },
{ re: /placeholder/i, reason: 'contains literal "placeholder"' },
{ re: /<PLACEHOLDER[\s:]/, reason: 'contains <PLACEHOLDER: ...> literal — replace with the real URL' },
{ re: /\/\/localhost(?:[:\/]|$)/i, reason: 'localhost is not reachable from production' },
{ re: /\/\/127\.0\.0\.1(?:[:\/]|$)/, reason: '127.0.0.1 is not reachable from production' },
{ re: /\/\/0\.0\.0\.0(?:[:\/]|$)/, reason: '0.0.0.0 is not reachable from production' },
{ re: /\b(mock|fake|dummy|sample|foo\.bar)\b/i, reason: 'contains obvious placeholder token (mock/fake/dummy/sample/foo.bar)' },
];
function checkUrlValue(value, path, violations, required) {
if (typeof value !== 'string') {
if (required) {
violations.push({ path, value: String(value !== null && value !== void 0 ? value : ''), reason: 'Runtime URL is required but missing', kind: 'invalid' });
}
return;
}
const trimmed = value.trim();
if (trimmed === '') {
if (required) {
violations.push({ path, value: '', reason: 'Runtime URL is required but empty', kind: 'invalid' });
}
return;
}
if (!/^https?:\/\//i.test(trimmed)) {
violations.push({ path, value: trimmed, reason: 'must start with http:// or https://', kind: 'invalid' });
return;
}
for (const { re, reason } of PLACEHOLDER_PATTERNS) {
if (re.test(trimmed)) {
violations.push({ path, value: trimmed, reason, kind: 'placeholder' });
return;
}
}
}
// 提取 DSL 声明的变量名清单,用于增强违规消息("该点位 DSL 声明了变量 X,Y")
function getDataVariableNames(dslContainer) {
var _a;
if (!dslContainer || typeof dslContainer !== 'object')
return [];
if (!('definitions' in dslContainer))
return [];
const data = (_a = dslContainer.definitions) === null || _a === void 0 ? void 0 : _a.data;
if (!data)
return [];
if (Array.isArray(data)) {
return data
.map((item) => (item === null || item === void 0 ? void 0 : item.name) || (item === null || item === void 0 ? void 0 : item.key) || (item === null || item === void 0 ? void 0 : item.varName))
.filter((n) => typeof n === 'string' && n.length > 0);
}
if (typeof data === 'object')
return Object.keys(data);
return [];
}
// DSL 可能是完整对象 { definitions, template }、裸 template(顶层 type)、
// 或 JSON 字符串。统一解出 template 根节点。
function extractTemplateRoot(dsl) {
if (!dsl)
return null;
if (typeof dsl === 'string') {
try {
return extractTemplateRoot(JSON.parse(dsl));
}
catch (_a) {
return null;
}
}
if (typeof dsl !== 'object')
return null;
if ('template' in dsl && dsl.template)
return dsl.template;
if ('type' in dsl)
return dsl; // 裸 template 形态
return null;
}
// 递归扫 DSL 节点里的 onClick/onDoubleClick URL
function scanDslNode(node, basePath, violations) {
var _a, _b;
if (!node || typeof node !== 'object')
return;
for (const ev of ['onClick', 'onDoubleClick']) {
const handler = (_a = node.props) === null || _a === void 0 ? void 0 : _a[ev];
if (handler && typeof handler === 'object') {
const action = handler.action;
const url = (_b = handler.params) === null || _b === void 0 ? void 0 : _b.url;
if (action === 'httpRequest' || (action === 'openLink' && typeof url === 'string' && !url.includes('{{'))) {
// openLink 用 {{varName}} 时由数据接口填,不在此 lint
checkUrlValue(url, `${basePath}.props.${ev}.params.url`, violations, true);
}
}
}
if (Array.isArray(node.children)) {
node.children.forEach((child, i) => {
scanDslNode(child, `${basePath}.children[${i}]`, violations);
});
}
}
const POINT_DSL_BINDINGS = {
control: [
{
dslField: 'table_cell',
getUrl: (web) => { var _a; return (_a = web === null || web === void 0 ? void 0 : web.table_url) === null || _a === void 0 ? void 0 : _a.url; },
urlPath: 'platform.web.table_url.url',
},
],
customField: [
{
dslField: 'table_layout',
getUrl: (web) => web === null || web === void 0 ? void 0 : web.table_data_url,
urlPath: 'platform.web.table_data_url',
},
],
};
function scanDslPoint(pointType, point, key, violations) {
var _a;
// control 顶层还有 control[].url(独立于 DSL 的 Webhook 字段),先处理
if (pointType === 'control' && point.url !== undefined) {
checkUrlValue(point.url, `control[${key}].url`, violations, true);
}
const web = (_a = point.platform) === null || _a === void 0 ? void 0 : _a.web;
if (!web)
return;
const bindings = POINT_DSL_BINDINGS[pointType] || [];
for (const { dslField, getUrl, urlPath } of bindings) {
const dslContainer = web[dslField];
// 数据接口 URL:仅当 DSL 声明了变量时必填,带变量上下文进 violation
const varNames = getDataVariableNames(dslContainer);
if (varNames.length > 0) {
const url = getUrl(web);
const before = violations.length;
checkUrlValue(url, `${pointType}[${key}].${urlPath}`, violations, true);
// 若刚追加了违规,在 reason 末尾补充变量上下文
for (let i = before; i < violations.length; i++) {
violations[i].reason += ` (DSL declares variables: ${varNames.join(', ')} — data interface URL is required to populate them)`;
}
}
// 递归扫 DSL 树里 onClick/onDoubleClick URL(无论是否有 data 变量)
const root = extractTemplateRoot(dslContainer);
if (root)
scanDslNode(root, `${pointType}[${key}].platform.web.${dslField}`, violations);
}
}
function validateRuntimeUrls(config) {
const violations = [];
if (!config || typeof config !== 'object')
return { violations };
// intercept[].url (always required)
const intercept = config.intercept;
if (intercept && typeof intercept === 'object') {
for (const [key, point] of Object.entries(intercept)) {
if (point && typeof point === 'object') {
checkUrlValue(point.url, `intercept[${key}].url`, violations, true);
}
}
}
// listen_event[].url (always required)
const listenEvent = config.listen_event;
if (listenEvent && typeof listenEvent === 'object') {
for (const [key, point] of Object.entries(listenEvent)) {
if (point && typeof point === 'object') {
checkUrlValue(point.url, `listen_event[${key}].url`, violations, true);
}
}
}
// 已注册的 DSL-sharing 点位类型(控件 / 拓展字段 / 未来新增)
for (const pointType of Object.keys(POINT_DSL_BINDINGS)) {
const bucket = config[pointType];
if (!bucket || typeof bucket !== 'object')
continue;
for (const [key, point] of Object.entries(bucket)) {
if (point && typeof point === 'object') {
scanDslPoint(pointType, point, key, violations);
}
}
}
return { violations };
}
exports.validateRuntimeUrls = validateRuntimeUrls;
/**
* Split violations by severity so callers can block on structural defects
* while only warning on fabricated-looking placeholders.
*/
function splitViolations(violations) {
return {
invalid: violations.filter(v => v.kind === 'invalid'),
placeholders: violations.filter(v => v.kind === 'placeholder'),
};
}
exports.splitViolations = splitViolations;
function formatViolationLines(violations) {
const lines = [];
for (const v of violations) {
lines.push(` - ${v.path}`);
lines.push(` value: ${v.value || '(empty)'}`);
lines.push(` reason: ${v.reason}`);
}
return lines;
}
/**
* Hard-error formatting for 'invalid' violations (missing / empty / not http(s)).
* Callers print this and exit 1 — the config is structurally unusable.
*/
function formatViolations(violations) {
if (violations.length === 0)
return '';
const lines = [
'Runtime URL validation failed — the following runtime URLs are missing or not valid http(s) URLs:',
'',
...formatViolationLines(violations),
'',
'Fix: replace each value with a real, reachable http(s):// URL, then re-run.',
];
return lines.join('\n');
}
exports.formatViolations = formatViolations;
/**
* Non-blocking notice for 'placeholder' violations (fabricated-looking URLs).
* Callers print this and CONTINUE — the placeholder is pushed as-is. The notice
* exists to discharge the duty to inform: the user must swap in the real URL,
* not the CLI to refuse. See the two-step flow in the module header.
*/
function formatPlaceholderNotice(violations) {
if (violations.length === 0)
return '';
const lines = [
'⚠️ NOTICE — the following runtime URLs look like placeholders / fabricated values (NOT blocked, pushed as-is):',
'',
...formatViolationLines(violations),
'',
'These will silently fail at runtime (blank columns / broken buttons / missed events) until replaced with the real',
'backend URL — edit the config and re-push, or change the URL directly in the Meego plugin developer console (后台).',
'Tell the user they must do this before the plugin goes live.',
];
return lines.join('\n');
}
exports.formatPlaceholderNotice = formatPlaceholderNotice;