UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

785 lines (784 loc) 46.1 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.localLossBanner = exports.computeLocalOnlyLoss = exports.computeRemovedProperties = exports.computeLocalVsRemoteDiff = exports.computeConfigVsRemoteDiff = exports.fetchRemoteConfig = exports.formatStructuralChange = exports.collectStructuralChanges = 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 fill_ai_option_values_1 = require("../../../utils/fill-ai-option-values"); 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 normalize_option_labels_1 = require("../../../utils/transform/normalize-option-labels"); const fill_webhook_tokens_1 = require("../../../utils/transform/fill-webhook-tokens"); 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 deletion_gate_1 = require("../deletion-gate"); const JSON_STRINGIFY_INDENT = 2; const updateLocalConfig = async ({ fromPath, overwriteLocalEdits }) => { 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; // 拉一次远端基线,供删除闸口判定使用。失败不阻断(advisory)——闸口降级为警告。 let remoteBaseline; let remoteBaselineError; try { remoteBaseline = await fetchRemoteConfig(); } catch (e) { remoteBaselineError = e; } // aiNode / aiField 选项 value(webhook 内部值)补全——必须先于校验 / 后端 dry-run / // diff / 落盘,让全链路吃到同一份。只补空缺:AI 填的非空 value 原样采纳(重复留给 // 校验的 x-unique-fields 弹),缺失 / 空串才生成随机串。补全后随配置落盘保持稳定。 (0, fill_ai_option_values_1.fillAIPropOptionValues)(mergedConfig); // app_type guard must run before schema validation. Hidden point types may not exist // in the public schema, and its unknown-key diagnostic must not expose internal names. 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); } 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); } // 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. // 删除闸口(fail-closed):draft 相比远端基线丢了点位、或某点位内的属性被删 → 默认 exit 2, // 删除不发生。放行靠用户在自己终端跑 `lpm allow-delete <nonce>`(一次性 grant),或用户主动 // 设的项目 / 全局 standing allow。远端不可达 → 无法验基线,降级为告警放行(push 时 update // 会再跑一次同样的闸口)。 if (remoteBaseline === undefined) { const e = remoteBaselineError; 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'); } else { const diffs = diffConfigs(mergedConfig, remoteBaseline); // set 是可逆的(只写本地),不消费 grant——把一次性消费留给 update 的不可逆推送, // 这样用户授权一次,set(写本地)和 update(推远端)都过。 // pluginId 进 nonce → 授权按工程隔离,杜绝跨工程同名删除集串用同一份 grant。 const gate = (0, deletion_gate_1.tryPassDeletionGate)(diffs, localPluginConfig.pluginId, process.cwd(), { consume: false, grantToken: process.env.LPM_DELETE_GRANT, // 沙箱场景:用环境变量回传 grant token,set 也一并放行 }); if (!gate.pass) { (0, deletion_gate_1.writeDeletionBlockNotice)(diffs, gate.nonce, localPluginConfig.pluginId); // notice 自带顶部横幅 + token process.exit(2); } if (gate.hasDeletion) process.stderr.write((0, deletion_gate_1.deletionBanner)(diffs) + '\n'); // 放行也打横幅(保留感知) } // 本地未推改动保护(轻量、可逆 · 与 deletion gate 对称的本地侧闸口): // point.config.local.json 自上次 set 后被手改过(dirty) + draft 丢了「本地有、远端没有」的内容 // → exit 3、不覆盖;除非显式 --overwrite-local-edits(覆盖前自动备份旧文件)。 // clean / 无本地 / 无 sentinel(isLocalConfigDirty 非 true)一律跳过。 // 见 docs/superpowers/specs/2026-06-21-set-local-loss-perception-design.md。 if ((0, check_local_config_drift_1.isLocalConfigDirty)() === true) { let localOnDisk; try { localOnDisk = (await (0, write_local_point_config_1.getLocalPointConfig)()); } catch (_a) { localOnDisk = undefined; // 读不出本地 → 放弃本检查(fail-open) } if (localOnDisk) { const losses = computeLocalOnlyLoss(mergedConfig, localOnDisk, remoteBaseline); if (losses.length > 0) { if (!overwriteLocalEdits) { process.stderr.write(localLossBanner(losses, { remoteVerified: remoteBaseline !== undefined }) + '\n'); process.exit(3); } const backupRel = (0, check_local_config_drift_1.snapshotLocalConfigBackup)(); if (backupRel) { process.stderr.write(`ℹ️ 已将旧 point.config.local.json 备份到 ${backupRel}(覆盖前快照,可恢复)\n`); } else { // 备份是 git 之外的双保险;写不出(IO 失败)时绝不静默有损覆盖——告警后仍继续 // (用户已显式 --overwrite-local-edits,且 point.config.local.json 通常受 git 跟踪、可经 git 恢复)。 process.stderr.write('⚠️ 覆盖前备份失败(IO 错误),仍将按 --overwrite-local-edits 覆盖 point.config.local.json;' + '若需回退,请用 `git checkout point.config.local.json`(该文件通常受 git 跟踪)。\n'); } } } } const normalizedConfig = (0, dsl_1.normalizeLocalConfigDsl)(mergedConfig); // 选项 label 归一到 translation.zh(schema 要求二者一致;value/translation 不动)—— // 把误塞进 label 的 value 归正,消除往返恒 MODIFIED 伪差。改动了就透明告知(local 进 git)。 const labelFixed = (0, normalize_option_labels_1.normalizeOptionLabels)(normalizedConfig); if (labelFixed > 0) { process.stderr.write(`ℹ️ 已将 ${labelFixed} 个选项的 label 归一为 translation.zh(schema 要求 label 与 translation.zh 一致),value 与多语言文案不变。\n`); } 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 复刻同一语义:两侧都空 → 都是哨兵 → 相等;一侧非空 → 串不同 → 不等。 * 由此保证 collectStructuralChanges 列出的变化集合非空 ⟺ 该点位被判 modified。 */ // 裸词哨兵,保证与任何真实 canonicalJson 输出不相等:canonicalJson 对字符串值 // 恒带引号(`"x"`),对数字/布尔也无引号但形态固定,绝不会产出这个未加引号的标识符。 const EMPTY_FIELD_SENTINEL = '__EMPTY_OR_ABSENT__'; function fieldCanon(v) { return isEmptyForDiff(v) ? EMPTY_FIELD_SENTINEL : canonicalJson(v); } /** * 字段值预览:空≡缺失统一显示 `(空/缺失)`;非空走 canonicalJson **全量**展示。 * 不截断——`diff` / `check diff` 是发布前的**用户强确认门**,截断恰恰会把要被确认的内容 * (尤其 AI 应用的 properties)藏进 `…`,让确认空转。完整呈现比"截断后让人去翻原文件"更诚实。 */ function previewFieldValue(v) { if (isEmptyForDiff(v)) return '(空/缺失)'; return canonicalJson(v); } /** 多行 pretty JSON 预览(仅兜底块用:无身份数组 / 触深度上限的对象)。空≡缺失统一显示 `(空/缺失)`。 */ function prettyFieldValue(v) { if (isEmptyForDiff(v)) return '(空/缺失)'; return JSON.stringify(v, null, 2); } function isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); } /** 纯标量数组:每个元素都不是对象 / 数组(string / number / bool / null),可走「成员增删」差分。 */ function isScalarArray(v) { return Array.isArray(v) && v.every(e => e === null || typeof e !== 'object'); } /** * 解析一个「对象数组」用哪个字段当身份(用于按 id 配对、识别增/删/改)。 * 优先级对齐 schema 的 x-unique-fields:key / prop_key / field_key(点位 & 属性身份)、 * value / label(选项身份)、id(兜底)。**name 故意不入选**——name 可被编辑,拿它当身份会把 * 「改名」误判成「删一个 + 增一个」。要求:两侧所有元素都是对象且该字段是字符串、且各侧内唯一。 * 没有这样的字段(纯标量数组 / 无 id 的 DSL 渲染树)→ 返回 undefined,调用方退化成成员增删或整块兜底。 */ const ARRAY_ID_CANDIDATES = ['key', 'prop_key', 'field_key', 'value', 'label', 'id']; function resolveArrayId(local, remote) { const all = [...local, ...remote]; if (all.length === 0) return undefined; for (const c of ARRAY_ID_CANDIDATES) { if (!all.every(e => isPlainObject(e) && typeof e[c] === 'string')) continue; const uniq = (arr) => { const ids = arr.map(e => e[c]); return new Set(ids).size === ids.length; }; if (uniq(local) && uniq(remote)) return c; } return undefined; } const STRUCTURAL_MAX_DEPTH = 8; /** * 递归结构化差分:产出「点位内部到任意深度」的改动清单(纯展示,删除闸口不读)。 * * 规则(与用户拍板的原则一致): * - 对象 → key 即身份,逐 key 递归;key 单边出现 = 该叶 add/remove,同 key 值变 = 递归 modify。 * - 带身份数组(resolveArrayId 命中)→ 按 id 配对:id 单边 = 元素 add/remove(改名 = 删旧+增新), * 同 id 内部差异 = 递归进元素。 * - 纯标量数组(枚举)→ 成员增删(member);标量无"内部",故无 modify。 * - 无身份对象数组(DSL 渲染树)/ 触深度上限 → 整块 pretty JSON 兜底(block modify),不再深钻。 * 用 fieldCanon 折叠「空≡缺失」,与 modified 判定同源:返回非空 ⟺ 两端 canonical 不等。 */ function collectStructuralChanges(local, remote, path = '', depth = 0) { if (fieldCanon(local) === fieldCanon(remote)) return []; // 两侧都是数组:按身份配对 / 成员增删 / 整块兜底 if (Array.isArray(local) && Array.isArray(remote) && depth < STRUCTURAL_MAX_DEPTH) { const idField = resolveArrayId(local, remote); if (idField) { const byId = (arr) => new Map(arr.map(e => [e[idField], e])); const localById = byId(local); const remoteById = byId(remote); // 顺序:local 顺序优先(含改/增),再补 remote 独有(删)——读起来跟着本地配置走。 const ids = [...localById.keys(), ...[...remoteById.keys()].filter(id => !localById.has(id))]; const out = []; for (const id of ids) { const l = localById.get(id); const r = remoteById.get(id); const childPath = `${path}[${id}]`; if (l !== undefined && r === undefined) out.push({ path: childPath, kind: 'add' }); else if (l === undefined && r !== undefined) out.push({ path: childPath, kind: 'remove' }); else out.push(...collectStructuralChanges(l, r, childPath, depth + 1)); } return out; } if (isScalarArray(local) && isScalarArray(remote)) { const lCanon = new Set(local.map(canonicalJson)); const rCanon = new Set(remote.map(canonicalJson)); const added = local.filter(v => !rCanon.has(canonicalJson(v))).map(previewFieldValue); const removed = remote.filter(v => !lCanon.has(canonicalJson(v))).map(previewFieldValue); return [{ path, kind: 'member', added, removed }]; } // 无身份的对象数组(DSL 渲染树等):整块兜底,不按下标硬配(避免顺序噪声 + 钻太深)。 return [{ path, kind: 'modify', local: prettyFieldValue(local), remote: prettyFieldValue(remote), block: true }]; } // 两侧都是对象:逐 key 递归(key 即身份) if (isPlainObject(local) && isPlainObject(remote) && depth < STRUCTURAL_MAX_DEPTH) { const keys = [...new Set([...Object.keys(local), ...Object.keys(remote)])].sort((a, b) => a < b ? -1 : a > b ? 1 : 0); const out = []; for (const k of keys) { const childPath = path ? `${path}.${k}` : k; out.push(...collectStructuralChanges(local[k], remote[k], childPath, depth + 1)); } return out; } // 叶子 / 类型不一致 / 一侧缺失 / 触深度上限:单行(标量)或整块(对象/数组兜底)。 const isComplex = (v) => Array.isArray(v) || isPlainObject(v); if (isComplex(local) || isComplex(remote)) { return [{ path, kind: 'modify', local: prettyFieldValue(local), remote: prettyFieldValue(remote), block: true }]; } return [{ path, kind: 'modify', local: previewFieldValue(local), remote: previewFieldValue(remote) }]; } exports.collectStructuralChanges = collectStructuralChanges; /** * 把一条 StructuralChange 渲染成展示行(可能多行:block 兜底)。`pad` 是行首缩进。 * 标记:`~` 修改 / `+` 新增 / `-` 删除(删除仅"显示",授权见段末「删除授权」块)。 */ function formatStructuralChange(c, pad) { var _a, _b, _c, _d; if (c.kind === 'add') return [`${pad}+ ${c.path}(新增)`]; if (c.kind === 'remove') return [`${pad}- ${c.path}(删除)`]; if (c.kind === 'member') { const segs = [ ...((_a = c.removed) !== null && _a !== void 0 ? _a : []).map(r => `- ${r}`), ...((_b = c.added) !== null && _b !== void 0 ? _b : []).map(a => `+ ${a}`), ]; return [`${pad}~ ${c.path}: ${segs.length ? segs.join(',') : '顺序变化'}`]; } if (c.block) { const indentBlock = (s) => (s !== null && s !== void 0 ? s : '').split('\n').map(l => `${pad} ${l}`); return [ `${pad}~ ${c.path}:`, `${pad} 本地:`, ...indentBlock((_c = c.local) !== null && _c !== void 0 ? _c : ''), `${pad} 远端:`, ...indentBlock((_d = c.remote) !== null && _d !== void 0 ? _d : ''), ]; } return [`${pad}~ ${c.path}: ${c.local}${c.remote}`]; } exports.formatStructuralChange = formatStructuralChange; /** 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); } exports.fetchRemoteConfig = fetchRemoteConfig; /** * 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(); // 折叠「push 不会真改、却会显示成待推送修改」的两类伪差(深拷贝本地、临时归一、不落盘): // ① token:push 时本地空 token 复用远端那份(不覆盖/不 churn),故「本地空 + 远端有」推上去不变 token; // 按同语义用远端 token 补上本地空 token(不生成)。本地若有不同 token(主动轮换)仍如实显示。 // ② option label:push 时 label 会被转成 translation.zh(schema 要求二者一致),故 label 推上去不变; // 两侧都归一 label=translation.zh 再比,仅当 translation/value 真改了才报。 const localForDiff = JSON.parse(JSON.stringify(local !== null && local !== void 0 ? local : {})); (0, fill_webhook_tokens_1.fillWebhookTokens)(localForDiff, remote); (0, normalize_option_labels_1.normalizeOptionLabels)(localForDiff); (0, normalize_option_labels_1.normalizeOptionLabels)(remote); return diffConfigs(localForDiff, 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; /** * 「带属性的点位」精确表:bucket → 它承载属性的数组 + 数组元素的标识字段。 * 其余点位类型不在表里 → computeRemovedProperties 恒返回 [](永不误报属性删除)。 * - aiNode / aiField:`properties[]`,元素按 `key` 标识(往返已归一化为单对象→[obj])。 * - liteAppComponent:`properties[]` + `outputs[]`,元素按 `prop_key` 标识。 * - customField:`subfield[]`,元素按 `field_key` 标识(删子字段=不可逆配置丢失,须进闸口)。 * * `nested` 声明属性元素内部再带的身份数组(仅 `options`)+ 其标识字段:删一个保留属性里的 * 某个 option 同样是不可逆配置丢失。aiNode/aiField/customField 的 option 按 `value` 标识, * liteApp 的 option 无 `value`、按 `label` 标识。`outputs` 无 options,drill 时自然跳过。 */ const PROPERTY_BEARING_TYPES = { aiNode: { arrays: ['properties'], idField: 'key', nested: { options: 'value' } }, aiField: { arrays: ['properties'], idField: 'key', nested: { options: 'value' } }, liteAppComponent: { arrays: ['properties', 'outputs'], idField: 'prop_key', nested: { options: 'label' } }, customField: { arrays: ['subfield'], idField: 'field_key', nested: { options: 'value' } }, }; /** * 一个 `modified` 点位内部「remote 有、local 无」的属性 / 选项标识列表。 * 格式:属性删除 `<array>.<id>`;保留属性里的选项删除 `<array>.<id>.<nestedArray>.<optId>`。 * 用于删除闸口——点位本身保留、但某个输入属性 / 选项被删掉 = 不可逆配置丢失。 * 改名(旧 key 删 + 新 key 增)会算作旧 key 被删:这是期望行为(rename 对旧配置是破坏性的)。 */ function computeRemovedProperties(type, localItem, remoteItem) { const spec = PROPERTY_BEARING_TYPES[type]; if (!spec) return []; const removed = []; const idsOf = (arr, field) => new Set(arr.map((p) => p === null || p === void 0 ? void 0 : p[field]).filter((v) => typeof v === 'string')); for (const arr of spec.arrays) { const localArr = Array.isArray(localItem === null || localItem === void 0 ? void 0 : localItem[arr]) ? localItem[arr] : []; const remoteArr = Array.isArray(remoteItem === null || remoteItem === void 0 ? void 0 : remoteItem[arr]) ? remoteItem[arr] : []; const localById = new Map(localArr.filter((p) => typeof (p === null || p === void 0 ? void 0 : p[spec.idField]) === 'string').map((p) => [p[spec.idField], p])); for (const p of remoteArr) { const id = p === null || p === void 0 ? void 0 : p[spec.idField]; if (typeof id !== 'string') continue; if (!localById.has(id)) { removed.push(`${arr}.${id}`); // 整个属性被删 —— 不再下钻(连同其选项一起没了) continue; } // 属性两侧都在 → 下钻它内部的身份数组(options),找被删的选项 if (!spec.nested) continue; const localProp = localById.get(id); for (const [nestedArr, nestedIdField] of Object.entries(spec.nested)) { const localOptIds = idsOf(Array.isArray(localProp === null || localProp === void 0 ? void 0 : localProp[nestedArr]) ? localProp[nestedArr] : [], nestedIdField); for (const o of Array.isArray(p === null || p === void 0 ? void 0 : p[nestedArr]) ? p[nestedArr] : []) { const oid = o === null || o === void 0 ? void 0 : o[nestedIdField]; if (typeof oid === 'string' && !localOptIds.has(oid)) removed.push(`${arr}.${id}.${nestedArr}.${oid}`); } } } } return removed; } exports.computeRemovedProperties = computeRemovedProperties; /** 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 / aiOperation 在 yaml 里是单对象(AI 应用全工程恰好一个),其它点位都是数组。 // 这里把单对象统一归一化为 [obj] 一元数组,下面 by-key 比对的逻辑就能复用。 // 不做这层归一化,diff 会对 AI 单对象报"无变化"——删除 gate / update 推送都会跟着失效。 const SINGLE_OBJECT_TYPES = new Set(['aiNode', 'aiField', 'aiOperation']); 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)) { const removedProperties = computeRemovedProperties(type, item, remoteItem); diffs.push(Object.assign({ type, key: k, name: item === null || item === void 0 ? void 0 : item.name, status: 'modified', changes: collectStructuralChanges(item, remoteItem) }, (removedProperties.length > 0 ? { removedProperties } : {}))); } } 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 (deleted < modified < added) asc by key. // 删除优先级最高——不可逆、最该被先看到("扫一眼漏不掉");其次修改,最后新增。 const statusOrder = { deleted: 0, modified: 1, added: 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; } /** * 算「set 会丢掉的本地独有改动」:draft(D) 相对现存本地文件(L) 丢了什么,再减去远端(R) 已有的部分 * (在 R 的丢失归 deletion gate 管,不重复报)。R 缺省(远端不可达)时不相减,报全部本地丢失项。 * 与 deletion gate 对称:deletion gate 看 D-vs-R 防丢远端;本函数看 D-vs-L 防丢本地未推改动。 * 复用 diffConfigs:`deleted` = 整点位 L 有 D 无;`modified.removedProperties` = 属性 L 有 D 无。 */ function computeLocalOnlyLoss(draft, local, remote) { var _a, _b, _c; const dl = diffConfigs(draft, local); const dr = remote ? diffConfigs(draft, remote) : []; const id = (e) => `${e.type}::${e.key}`; const rDeleted = new Set(dr.filter(e => e.status === 'deleted').map(id)); const rRemovedProps = new Map(); for (const e of dr) { if ((_a = e.removedProperties) === null || _a === void 0 ? void 0 : _a.length) rRemovedProps.set(id(e), new Set(e.removedProperties)); } const out = []; for (const e of dl) { if (e.status === 'deleted') { if (!rDeleted.has(id(e))) out.push({ type: e.type, key: e.key, name: e.name, whole: true }); } else if (e.status === 'modified' && ((_b = e.removedProperties) === null || _b === void 0 ? void 0 : _b.length)) { const already = (_c = rRemovedProps.get(id(e))) !== null && _c !== void 0 ? _c : new Set(); const localOnly = e.removedProperties.filter(p => !already.has(p)); if (localOnly.length) out.push({ type: e.type, key: e.key, name: e.name, removedProperties: localOnly }); } } return out; } exports.computeLocalOnlyLoss = computeLocalOnlyLoss; /** name 可能是字符串或 i18n 对象(如 {zh:'..'});取可读串用于展示,取不到返回空串。 */ function renderLossName(name) { var _a, _b, _c; if (typeof name === 'string') return name; if (name && typeof name === 'object') { const o = name; const v = (_c = (_b = (_a = o.zh) !== null && _a !== void 0 ? _a : o['zh-cn']) !== null && _b !== void 0 ? _b : o.en) !== null && _c !== void 0 ? _c : Object.values(o)[0]; return typeof v === 'string' ? v : ''; } return ''; } /** * 「本地独有改动会被丢」感知块(stderr,纯文本、无 ANSI,便于弱 AI 逐字转呈)。 * remoteVerified=false(远端不可达)时,把「远端也没有/删除闸口看不到」换成「未核实」说明, * 避免在没核实远端的情况下做出「这是本地独有」的断言。 */ function localLossBanner(losses, opts = {}) { var _a; const remoteVerified = opts.remoteVerified !== false; const total = losses.reduce((n, l) => { var _a, _b; return n + (l.whole ? 1 : ((_b = (_a = l.removedProperties) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0)); }, 0); const lines = []; for (const l of losses) { lines.push(`[LOCAL-ONLY] ${l.type} [${l.key}] — "${renderLossName(l.name)}"`); for (const p of (_a = l.removedProperties) !== null && _a !== void 0 ? _a : []) { lines.push(` 属性:${p}`); } } const why = remoteVerified ? '这是「本地独有、从未推送」的内容,删除闸口看不到(远端本就没有)。' : '(远端未核实,以下为本地相对 draft 的全部丢失项,可能含远端已删的旧项。)'; return [ '⛔ 本地有未推送的改动,这次 set 会用 draft 覆盖、导致它们丢失', '', 'point.config.local.json 自上次 `lpm local-config set` 后被改动过(手改 / 未 push),', '而本次 draft(基于远端构造)没有保留下面这些「本地有、远端也没有」的内容。', `直接覆盖 → 这些改动将从本地文件永久消失(共 ${total} 项):`, '', ...lines, '', why, '当前 point.config.local.json 仍原样在盘上、尚未被改动。', '', '下一步由用户决定(AI 不要自行选择、不要自行加 flag):', ' • 想保留:先把上述内容并回 draft(或基于本地重建完整 draft)再 set;', ' • 确认丢弃:重跑并显式加 --overwrite-local-edits(覆盖前会自动备份旧文件)。', ].join('\n'); } exports.localLossBanner = localLossBanner; const TYPE_PAD = 20; 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 附结构化明细(递归到任意深度),让发布前能看清"到底改了什么", // 而不只是"某点位变了"。changes 与 modified 判定同源,必非空。 const block = d.status === 'modified' && d.changes && d.changes.length > 0 ? [head, ...d.changes.flatMap(c => formatStructuralChange(c, ' '))].join('\n') : head; return colorizeByStatus(d.status, block); } /** * `lpm local-config diff` entry. * - stdout: human-readable diff lines * - stderr (on deletion): DELETION_REQUIRES_CONFIRMATION notice + `<nonce>` (counts point AND property deletions) * - exit: 0 if no deletions OR deletions already authorized (grant / standing allow); * 2 if deletions present and NOT authorized; 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 deleted = diffs.filter(d => d.status === 'deleted').length; process.stdout.write(`\nSummary: ${added} added, ${modified} modified, ${deleted} deleted.\n`); // 删除闸口(与 set / update 同一套):整点位删除 + 点位内属性删除都计入。 // diff 是纯预览,consume:false——不消费一次性 grant(把消费留给 update 的真推送), // 但若用户已授权 / 设了 standing allow 则放行(exit 0,仅打横幅),忠实预演 push 时的结果。 // 未授权且有删除 → 打权威清单 + `<nonce>`、exit 2,引导用户跑 `lpm allow-delete <nonce>`。 // pluginId 进 nonce,必须与 set / update 用同一个真值,nonce 才能在三处对齐。 // 走到这里 config 必然存在(上面 computeLocalVsRemoteDiff → fetchRemoteConfig 缺 config 已 exit 1); // 仍显式断言,与 set / update 的「缺 config 即报错」姿态对齐——不靠 `?? ''` 静默兜底, // 那会用空 pluginId 算 nonce、产出一个注定无法被 set / update 采纳的 token 误导用户。 const localPluginConfig = (0, local_plugin_config_1.getLocalPluginConfig)(); if (!localPluginConfig) { logger_1.logger.error('Please init project first'); process.exit(1); } const pluginId = localPluginConfig.pluginId; const gate = (0, deletion_gate_1.tryPassDeletionGate)(diffs, pluginId, process.cwd(), { consume: false, grantToken: process.env.LPM_DELETE_GRANT, // 沙箱场景:grant token 经环境变量回传,diff 预演也忠实放行 }); if (!gate.pass) { (0, deletion_gate_1.writeDeletionBlockNotice)(diffs, gate.nonce, pluginId); // notice 自带顶部横幅 + token + 用户跑 allow-delete 的引导 process.exit(2); } if (gate.hasDeletion) process.stderr.write((0, deletion_gate_1.deletionBanner)(diffs) + '\n'); // 已授权放行也打横幅(保留感知) 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;