@lark-project/cli
Version:
飞书项目插件开发工具
785 lines (784 loc) • 46.1 kB
JavaScript
;
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;