UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

469 lines (468 loc) 25.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getLocalConfig = exports.diffLocalConfig = exports.writeDeletionBlockNotice = exports.computeLocalVsRemoteDiff = exports.computeConfigVsRemoteDiff = exports.colorizeByStatus = exports.updateLocalConfig = void 0; const fs_extra_1 = require("fs-extra"); const logger_1 = require("../../../utils/logger"); const validate_point_schema_1 = require("../../../utils/validate-point-schema"); const write_local_point_config_1 = require("../../../utils/write-local-point-config"); const get_plugin_info_1 = require("../../../api/get-plugin-info"); const local_plugin_config_1 = require("../../../local-plugin-config"); const get_plugin_app_type_1 = require("../../../utils/get-plugin-app-type"); const validate_point_type_allowed_1 = require("../../../utils/validate-point-type-allowed"); const compare_1 = require("../../../utils/point-eval/compare"); const validate_runtime_urls_1 = require("../../../utils/validate-runtime-urls"); const backendToLocal_1 = require("../../../utils/transform/backendToLocal"); const validate_local_point_config_1 = require("../../../api/tools/validate-local-point-config"); const dsl_1 = require("../../../utils/transform/dsl"); const check_local_config_drift_1 = require("../../../utils/check-local-config-drift"); const path_1 = __importDefault(require("path")); const chalk_1 = __importDefault(require("chalk")); const workspace_1 = require("../../utils/workspace"); const JSON_STRINGIFY_INDENT = 2; const updateLocalConfig = async ({ fromPath, allowDelete = false }) => { try { if (!fromPath) { logger_1.logger.error('local-config set requires --from <path>'); process.exit(1); } if (!(0, fs_extra_1.existsSync)(fromPath)) { logger_1.logger.error(`Draft file not found: ${fromPath}`); process.exit(1); } let newConfig; try { newConfig = JSON.parse((0, fs_extra_1.readFileSync)(fromPath, 'utf8')); } catch (e) { logger_1.logger.error(`Invalid JSON in ${fromPath}`, e); process.exit(1); } // set is full-replacement: newConfig is the complete desired state. // Omitting a type key means that type should be removed on update. const mergedConfig = newConfig; const validationResult = await (0, validate_point_schema_1.validatePointConfig)(mergedConfig); if (!validationResult.valid) { logger_1.logger.error('Configuration validation failed:'); const { lines } = (0, validate_point_schema_1.formatValidationErrors)(validationResult, 'text'); for (const line of lines) { logger_1.logger.error(line); } process.exit(1); } // app_type 硬卡:schema 通过后再跑,防止"形态 OK 但跨插件类型混用" // (AI 插件塞普通点位 / 普通插件塞 aiNode 等)。 const appType = (0, get_plugin_app_type_1.getPluginAppType)(); const allowed = (0, validate_point_type_allowed_1.validatePointTypeAllowed)(mergedConfig, appType); if (!allowed.ok) { logger_1.logger.error(`Configuration not allowed for app_type=${appType}:`); for (const line of allowed.errors) { logger_1.logger.error(line); } process.exit(1); } // Runtime URL check. Fabricated-looking placeholders are NOT blocked — we // only warn (the user swaps in the real URL later / in the developer console). // Structurally invalid URLs (missing / empty / not http(s)) still hard-block. const { invalid, placeholders } = (0, validate_runtime_urls_1.splitViolations)((0, validate_runtime_urls_1.validateRuntimeUrls)(mergedConfig).violations); if (placeholders.length > 0) { process.stderr.write((0, validate_runtime_urls_1.formatPlaceholderNotice)(placeholders) + '\n'); } if (invalid.length > 0) { process.stderr.write((0, validate_runtime_urls_1.formatViolations)(invalid) + '\n'); process.exit(1); } const localPluginConfig = (0, local_plugin_config_1.getLocalPluginConfig)(); if (!localPluginConfig) { logger_1.logger.error('Please init project first'); process.exit(1); } const backValidateResult = await (0, validate_local_point_config_1.validateLocalPointConfig)({ pluginId: localPluginConfig.pluginId, siteDomain: localPluginConfig.siteDomain, pluginSecret: localPluginConfig.pluginSecret, pointInfoMap: mergedConfig, }); if (backValidateResult.valid !== true) { logger_1.logger.error('Configuration validation failed: invalid point config (backend validation).'); process.exit(1); } // Baseline gate: set is full-replacement. If the draft omits points that // still exist on remote, pushing it would delete them — almost always a sign // the draft was built fresh instead of on top of `get --remote`. Block here // (before writing local) unless the caller explicitly opted into deletion. // Best-effort: if remote is unreachable we can't verify the baseline, so we // warn and proceed — the push (`update`) re-checks and is the irreversible gate. if (!allowDelete) { let deletions = []; let verified = true; try { deletions = (await computeConfigVsRemoteDiff(mergedConfig)).filter(d => d.status === 'deleted'); } catch (e) { verified = false; process.stderr.write(`[warn] Could not verify remote baseline (${e instanceof Error ? e.message : e}); ` + 'the push step will re-check before deleting anything.\n'); } // exit outside the fetch try/catch so the block is never swallowed as a "fetch failure". if (verified && deletions.length > 0) { writeDeletionBlockNotice(deletions); process.exit(2); } } const normalizedConfig = (0, dsl_1.normalizeLocalConfigDsl)(mergedConfig); await (0, write_local_point_config_1.writeLocalPointConfig)(normalizedConfig, true); logger_1.logger.success('Draft validated and written to local point.config.local.json — not yet pushed to remote.'); // 落 drift baseline:让后续 lpm start / build / diff 能检测出 // "set 之后用户又改了 point.config.local.json"(Stage Config plan→apply 边界守护)。 // 内部从磁盘读一次最终落盘内容算 hash——保证 baseline 与 checkLocalConfigDrift 字节对齐。 (0, check_local_config_drift_1.writeLocalConfigLastSet)(); // 把 CLI 自己维护的中间产物移出活跃目录、归档到 history(保留可追溯,不再硬删): // remote.json 基线(已被新配置覆盖)+ 规范 AI 流程生成的 draft-*.json。 // 仍然「移出」活跃路径——remote.json 留在原地会变陈旧基线、坑后续 diff/删除闸口。 // 不动用户自带的任意 --from 文件,避免数据丢失。 const paths = (0, workspace_1.workspacePaths)(); (0, workspace_1.archiveFile)(paths.configRemote, paths.historyDir); const fromBasename = path_1.default.basename(fromPath); const fromDir = path_1.default.resolve(path_1.default.dirname(fromPath)); if (fromDir === path_1.default.resolve(paths.configDir) && fromBasename.startsWith('draft-')) { (0, workspace_1.archiveFile)(fromPath, paths.historyDir); } // 防呆:set 只落本地,必须再跑 update 才推平台。明确点出下一步, // 避免 agent / 开发者把 set 成功当成"已发布"而停步(见 ISSUE-set-not-pushed-to-remote)。 logger_1.logger.info('Next step: run `lpm update --source-type=local` to push this config to the platform — until then it lives only on disk.'); } catch (error) { logger_1.logger.error('Failed to update local configuration:', error); process.exit(1); } }; exports.updateLocalConfig = updateLocalConfig; /** * 点位 diff 状态配色:删除红、修改黄、新增默认(不上色)。 * chalk@4 在非 TTY(被 skill 子进程捕获 / jest)下 level=0、原样返回不加 ANSI, * 故确认块被 AI 转呈时仍是干净文本、测试断言也不受影响——颜色只在终端交互时出现。 */ function colorizeByStatus(status, text) { if (status === 'deleted') return chalk_1.default.red(text); if (status === 'modified') return chalk_1.default.yellow(text); return text; // added:默认色 } exports.colorizeByStatus = colorizeByStatus; /** * 「空 ≡ 缺失」判定,用于 canonicalJson 在比对前剔除空值键。 * * 往返不闭合的根源:反向转换(reverseTransform*)对一堆可选字段做了空兜底 * (`url || ''`、`event_config || []`、`platform || {}`、i18n `|| {}` 等),而本地草稿 * 里这些字段常常是缺失/undefined。canonicalJson 此前只抹平了 undefined,抹不掉 * 「`''`/`[]`/`{}` vs 缺失」,导致"内容没真改"也被判 MODIFIED(往返伪差)。 * * 这里把「空值」与「缺失」统一视同——两侧都剔。只影响**比对分类**(canonicalJson 的 * 输出只用于 `!==` 比相等,永不写回、不喂 update),故对实际推送的数据零影响: * 唯一行为变化是"一侧空、另一侧缺失"判为相等,而这正是语义相同、本就该判等的; * 任何非空值的差异(a→b、空串→真值)仍照常报 MODIFIED。 * * **刻意不剔 `0` / `false`**:它们常是有意义的真值(如 layout `mode: 0`、开关 `false`), * 剔掉会把"0/false vs 缺失"误判为相等、藏掉真差异。代价:`mode: 0` 这类数值兜底的 * 伪差不在本次消除范围内(残留,但比字符串/数组/对象类伪差罕见得多)。 */ function isEmptyForDiff(v) { if (v === undefined || v === null || v === '') return true; if (Array.isArray(v)) return v.length === 0; if (typeof v === 'object') { return Object.values(v).every(isEmptyForDiff); } return false; // 数字(含 0)、布尔(含 false)、非空字符串:均视为有值 } function canonicalJson(value) { if (value === null) return 'null'; // 与 JSON.stringify 对数组元素的处理对齐:undefined → null if (value === undefined) return 'null'; if (typeof value !== 'object') return JSON.stringify(value); if (Array.isArray(value)) { return '[' + value.map(canonicalJson).join(',') + ']'; } // 剔除「空值」键(含 undefined):远端反向转换补的 ''/[]/{}/ 嵌套空对象 与本地的 // 缺失字段统一视同,消除往返伪差(详见 isEmptyForDiff)。key 再按字母排序保证稳定。 const entries = Object.entries(value) .filter(([, v]) => !isEmptyForDiff(v)) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); return ('{' + entries .map(([k, v]) => JSON.stringify(k) + ':' + canonicalJson(v)) .join(',') + '}'); } /** * 字段级规范化:把"空值(含缺失)"折叠成单一哨兵,非空值走 canonicalJson。 * * 这样**逐字段比较**与**对象级 canonicalJson 比较**严格等价——后者在序列化对象时 * 直接剔掉空值键(isEmptyForDiff),故"key 在一侧非空、另一侧空/缺"才算不等。 * fieldCanon 复刻同一语义:两侧都空 → 都是哨兵 → 相等;一侧非空 → 串不同 → 不等。 * 由此保证 computeFieldChanges 列出的变化集合非空 ⟺ 该点位被判 modified。 */ // 裸词哨兵,保证与任何真实 canonicalJson 输出不相等:canonicalJson 对字符串值 // 恒带引号(`"x"`),对数字/布尔也无引号但形态固定,绝不会产出这个未加引号的标识符。 const EMPTY_FIELD_SENTINEL = '__EMPTY_OR_ABSENT__'; function fieldCanon(v) { return isEmptyForDiff(v) ? EMPTY_FIELD_SENTINEL : canonicalJson(v); } const FIELD_PREVIEW_MAX = 80; /** 字段值预览:空≡缺失统一显示 `(空/缺失)`;非空走 canonicalJson 并截断到 80 字符。 */ function previewFieldValue(v) { if (isEmptyForDiff(v)) return '(空/缺失)'; const s = canonicalJson(v); return s.length > FIELD_PREVIEW_MAX ? s.slice(0, FIELD_PREVIEW_MAX - 1) + '…' : s; } /** * 比出两个点位对象里 canonical 不等的字段(含一侧缺失的字段),按字段名排序。 * 用 fieldCanon 折叠空值,与 modified 判定同源——绝不列出方案 A 视为相等的空兜底字段。 */ function computeFieldChanges(local, remote) { const fields = new Set([ ...Object.keys(local || {}), ...Object.keys(remote || {}), ]); const changes = []; for (const f of [...fields].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))) { if (fieldCanon(local === null || local === void 0 ? void 0 : local[f]) !== fieldCanon(remote === null || remote === void 0 ? void 0 : remote[f])) { changes.push({ field: f, local: previewFieldValue(local === null || local === void 0 ? void 0 : local[f]), remote: previewFieldValue(remote === null || remote === void 0 ? void 0 : remote[f]), }); } } return changes; } /** Fetch + reverse-transform the remote plugin config into local-config shape. */ async function fetchRemoteConfig() { const pluginCfg = (0, local_plugin_config_1.getLocalPluginConfig)(); if (!pluginCfg) { throw new Error('Please init project first'); } const { pluginId, pluginSecret, siteDomain } = pluginCfg; const rawRemote = await (0, get_plugin_info_1.getPluginPointInfo)(pluginId, pluginSecret, siteDomain); return (0, backendToLocal_1.reverseTransformQueryLocalConfig)(rawRemote); } /** * Diff an arbitrary full config object against the current remote. * `set` passes the draft (to gate "didn't build on remote → would drop points"), * `update` / `diff` pass the on-disk `point.config.local.json`. */ async function computeConfigVsRemoteDiff(local) { const remote = await fetchRemoteConfig(); return diffConfigs(local, remote); } exports.computeConfigVsRemoteDiff = computeConfigVsRemoteDiff; /** * Compare local `point.config.local.json` with remote plugin config. * Groups by point type + key; classifies each key as added / modified / deleted. * Used by both `local-config diff` (AI-facing preview) and `update --source-type=local` * (pre-push deletion gate). */ async function computeLocalVsRemoteDiff() { return computeConfigVsRemoteDiff((await (0, write_local_point_config_1.getLocalPointConfig)())); } exports.computeLocalVsRemoteDiff = computeLocalVsRemoteDiff; /** Pure: classify every point key across local vs remote as added / modified / deleted. */ function diffConfigs(local, remote) { const diffs = []; const allTypes = new Set([ ...Object.keys(local || {}), ...Object.keys(remote || {}), ]); // aiNode / aiField 在 yaml 里是单对象(AI 应用全工程恰好一个),其它点位都是数组。 // 这里把单对象统一归一化为 [obj] 一元数组,下面 by-key 比对的逻辑就能复用。 // 不做这层归一化,diff 会对 AI 单对象报"无变化"——删除 gate / update 推送都会跟着失效。 const SINGLE_OBJECT_TYPES = new Set(['aiNode', 'aiField']); const normalizeBucket = (raw) => { if (Array.isArray(raw)) return raw; if (raw && typeof raw === 'object') return [raw]; return []; }; for (const type of allTypes) { const isSingleObject = SINGLE_OBJECT_TYPES.has(type); const localItems = isSingleObject ? normalizeBucket(local === null || local === void 0 ? void 0 : local[type]) : (Array.isArray(local === null || local === void 0 ? void 0 : local[type]) ? local[type] : []); const remoteItems = isSingleObject ? normalizeBucket(remote === null || remote === void 0 ? void 0 : remote[type]) : (Array.isArray(remote === null || remote === void 0 ? void 0 : remote[type]) ? remote[type] : []); const localByKey = new Map(localItems.filter(it => it && typeof it.key === 'string').map(it => [it.key, it])); const remoteByKey = new Map(remoteItems.filter(it => it && typeof it.key === 'string').map(it => [it.key, it])); for (const [k, item] of localByKey) { if (!remoteByKey.has(k)) { diffs.push({ type, key: k, name: item === null || item === void 0 ? void 0 : item.name, status: 'added' }); continue; } const remoteItem = remoteByKey.get(k); if (canonicalJson(item) !== canonicalJson(remoteItem)) { diffs.push({ type, key: k, name: item === null || item === void 0 ? void 0 : item.name, status: 'modified', changes: computeFieldChanges(item, remoteItem), }); } } for (const [k, item] of remoteByKey) { if (!localByKey.has(k)) { diffs.push({ type, key: k, name: item === null || item === void 0 ? void 0 : item.name, status: 'deleted' }); } } } // Stable ordering: type asc, then status (added < modified < deleted) asc by key. const statusOrder = { added: 0, modified: 1, deleted: 2 }; diffs.sort((a, b) => { if (a.type !== b.type) return a.type < b.type ? -1 : 1; if (a.status !== b.status) return statusOrder[a.status] - statusOrder[b.status]; return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; }); return diffs; } const TYPE_PAD = 20; /** * Shared deletion gate for `set` and `update --source-type=local`. * A config that drops points still present on remote would delete them on push — * almost always the symptom of a config NOT built on the current remote baseline * ("treated as new" instead of "get --remote → edit on top"). Writes a verbatim * deletion list + recovery guidance to stderr. Callers exit(2) after this unless * the user explicitly opted in with --allow-delete. */ function writeDeletionBlockNotice(deletions) { process.stderr.write('\n⚠️ DELETION_REQUIRES_CONFIRMATION\n'); process.stderr.write(`This config drops ${deletions.length} point(s) that still exist on remote:\n`); for (const d of deletions) { process.stderr.write(` - ${d.type}[${d.key}]${d.name ? ` "${d.name}"` : ''}\n`); } process.stderr.write('\nThis usually means the config was not built on the current remote baseline (treated as new).\n' + '→ To keep them: run `lpm local-config get --remote`, add/modify on top of the full config, then retry.\n' + '→ To really delete them: show this exact list to the user, get explicit approval, then re-run with --allow-delete.\n' + 'Deleting remote points is not reversible — do NOT pass --allow-delete without user approval.\n'); } exports.writeDeletionBlockNotice = writeDeletionBlockNotice; function formatDiffLine(d) { const tag = `[${d.status.toUpperCase()}]`.padEnd(11); const type = d.type.padEnd(TYPE_PAD); const nameSuffix = d.name ? ` — "${d.name}"` : ''; const head = `${tag} ${type} [${d.key}]${nameSuffix}`; // modified 附字段级 local→remote 明细,让发布前能看清"到底改了什么", // 而不只是"某点位变了"。changes 与 modified 判定同源,必非空。 const block = d.status === 'modified' && d.changes && d.changes.length > 0 ? [head, ...d.changes.map(c => ` ${c.field}: ${c.local}${c.remote}`)].join('\n') : head; return colorizeByStatus(d.status, block); } /** * `lpm local-config diff` entry. * - stdout: human-readable diff lines * - stderr (only on deletion): DELETION_REQUIRES_CONFIRMATION notice * - exit: 0 if no deletions, 2 if any deletion present, 1 on error */ const diffLocalConfig = async () => { (0, check_local_config_drift_1.checkLocalConfigDrift)(); let diffs; try { diffs = await computeLocalVsRemoteDiff(); } catch (error) { logger_1.logger.error('Failed to compute diff:', error instanceof Error ? error.message : error); process.exit(1); } if (diffs.length === 0) { process.stdout.write('No changes — local and remote are in sync.\n'); process.exit(0); } for (const d of diffs) { process.stdout.write(formatDiffLine(d) + '\n'); } const added = diffs.filter(d => d.status === 'added').length; const modified = diffs.filter(d => d.status === 'modified').length; const deletions = diffs.filter(d => d.status === 'deleted'); process.stdout.write(`\nSummary: ${added} added, ${modified} modified, ${deletions.length} deleted.\n`); if (deletions.length > 0) { process.stderr.write('\n⚠️ DELETION_REQUIRES_CONFIRMATION\n'); process.stderr.write(`This operation will delete ${deletions.length} point(s) from the remote plugin:\n`); for (const d of deletions) { process.stderr.write(` - ${d.type}[${d.key}]${d.name ? ` "${d.name}"` : ''}\n`); } process.stderr.write('\nAI MUST:\n'); process.stderr.write(' 1. Show the deletion list above to the user verbatim (not a paraphrase).\n'); process.stderr.write(' 2. Pause and wait for explicit user approval — user must confirm they want to delete these exact points.\n'); process.stderr.write(' 3. Only after user approves, proceed to run: lpm update --source-type=local\n'); process.stderr.write('\nDo NOT silently auto-proceed — deleting remote points is not reversible.\n'); process.exit(2); } process.exit(0); }; exports.diffLocalConfig = diffLocalConfig; /** * Always writes remote config snapshot to `.lpm-cache/config/remote.json` and * prints that path on stdout. AI/scripts consume the file, not the JSON blob, * so conversation context stays clean. */ const getLocalConfig = async ({ remote = false, compareSnapshot = false, caseId, } = {}) => { try { let rawRemotePointInfoMap; let config; try { if (remote || compareSnapshot) { throw new Error('Force fetch remote config'); } config = await (0, write_local_point_config_1.getLocalPointConfig)(); } catch (_a) { // 走 stderr:与 stdout 的机器可读路径分离,保持 `$(lpm local-config get --remote)` 可直接消费。 process.stderr.write('Fetching from remote...\n'); const localPluginConfig = (0, local_plugin_config_1.getLocalPluginConfig)(); if (!localPluginConfig) { logger_1.logger.error('Please init project first'); process.exit(1); } const { pluginId, pluginSecret, siteDomain } = localPluginConfig; rawRemotePointInfoMap = await (0, get_plugin_info_1.getPluginPointInfo)(pluginId, pluginSecret, siteDomain); config = (0, backendToLocal_1.reverseTransformQueryLocalConfig)(rawRemotePointInfoMap); if (!remote) { await (0, write_local_point_config_1.writeLocalPointConfig)(config); } } (0, workspace_1.ensureWorkspace)(); const paths = (0, workspace_1.workspacePaths)(); (0, fs_extra_1.writeFileSync)(paths.configRemote, JSON.stringify(config, null, JSON_STRINGIFY_INDENT)); const relativePath = paths.relative(paths.configRemote); // stdout 只输出机器可读路径,脚本可直接 `$(lpm local-config get)` 消费; // 成功提示走 stderr,不污染 stdout。 console.log(relativePath); process.stderr.write(`Config written to ${relativePath}\n`); if (compareSnapshot && caseId) { if (!rawRemotePointInfoMap) { logger_1.logger.error('--compare-snapshot requires --remote to fetch the latest remote data.'); process.exit(1); } const result = (0, compare_1.runCompare)(caseId, rawRemotePointInfoMap); (0, compare_1.printCompareResult)(result); } else if (compareSnapshot && !caseId) { logger_1.logger.error('--compare-snapshot requires --case-id to be specified.'); process.exit(1); } return config; } catch (error) { logger_1.logger.error('Failed to retrieve configuration:', error); process.exit(1); } }; exports.getLocalConfig = getLocalConfig;