@lark-project/cli
Version:
飞书项目插件开发工具
469 lines (468 loc) • 25.2 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.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;