UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

267 lines (266 loc) 11.8 kB
"use strict"; /** * 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;