UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

286 lines (285 loc) 11.3 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.patchJson = void 0; const path_1 = __importDefault(require("path")); const fs_extra_1 = require("fs-extra"); const json_1 = require("./peek/json"); /** * Files that are CLI-maintained / remote snapshots / live config — AI must * never patch these directly. Enforced at patch-json's write step so even if * a skill doc goes stale and tells AI to target the wrong file, the CLI * refuses and guides AI to use `lpm ai init-draft` first. */ const PROTECTED_BASENAMES = new Set([ 'remote.json', 'point.config.local.json', 'plugin.config.json', ]); /** * Surgically patch a JSON file without loading the full content into AI context. * * Why this shape: * - `全量提交约束`(shared/SKILL.md)要求 draft 必须包含远端所有点位,但 AI 不需要 * 把所有点位塞进 context——copy remote→draft 后用 patch-json 按 path 精准改动 * - 和 peek 共享 `segment[field=value]` 路径语法,AI 一套心智覆盖读和写 * - 每步出错 CLI 返回 exit 1,draft 保留上一步正确状态,便于重试 */ function patchJson(file, opts) { if (!(0, fs_extra_1.existsSync)(file)) { process.stderr.write(`File not found: ${file}\n`); process.exit(1); } const base = path_1.default.basename(file); if (PROTECTED_BASENAMES.has(base)) { process.stderr.write(`Refusing to patch "${file}" — this file is CLI-maintained / a remote snapshot.\n` + 'Use `lpm ai init-draft` to create a draft from remote.json first, then patch the draft.\n'); process.exit(1); } const patches = resolvePatches(opts); if (patches.length === 0) { process.stderr.write('No patch operation provided. Use --set / --add / --delete / --merge, or --patches <file>.\n'); process.exit(1); } const raw = (0, fs_extra_1.readFileSync)(file, 'utf8'); let root; try { root = JSON.parse(raw); } catch (e) { process.stderr.write(`Failed to parse JSON: ${e.message}\n`); process.exit(1); } const applied = []; for (const patch of patches) { try { applyPatch(root, patch); applied.push(patch); } catch (e) { process.stderr.write(`Patch failed at op=${patch.op} path=${patch.path}: ${e.message}\n`); process.stderr.write(`(${applied.length} earlier patch(es) had already been applied in-memory but NOT written to disk; original file is unchanged.)\n`); process.exit(1); } } (0, fs_extra_1.writeFileSync)(file, JSON.stringify(root, null, 2) + '\n'); if (opts.format === 'json') { process.stdout.write(JSON.stringify({ file, applied: applied.length, patches: applied }) + '\n'); } else { process.stdout.write(`ok (${applied.length} patch(es) applied to ${file})\n`); for (const p of applied) { process.stdout.write(` ${p.op} ${p.path}\n`); } } // Post-edit hint: AI 频繁停在"改完文件就交差"——明确指向下一步把 draft 应用到 local-config。 // Stderr 不污染 stdout JSON。仅当 draft-like 文件名时输出(避免泛滥)。 // 匹配 `lpm ai init-draft` 实际产物:`draft.json` 或 `draft-<suffix>.json`(不命中 // `drafts.json` 等同前缀复数 / `mydraft.json` 等用户自定义名)。 if (/^draft(-.*)?\.json$/i.test(base)) { process.stderr.write('next: run `lpm local-config set` to apply this draft to point.config.local.json with schema validation.\n'); } } exports.patchJson = patchJson; function resolvePatches(opts) { const patches = []; if (opts.patches) { const content = (0, fs_extra_1.readFileSync)(opts.patches, 'utf8'); let parsed; try { parsed = JSON.parse(content); } catch (e) { process.stderr.write(`Failed to parse --patches file: ${e.message}\n`); process.exit(1); } if (!Array.isArray(parsed)) { process.stderr.write('--patches file must contain a JSON array of {op, path, value?} objects.\n'); process.exit(1); } for (const p of parsed) { if (!isValidOp(p.op) || typeof p.path !== 'string') { process.stderr.write(`Invalid patch entry: ${JSON.stringify(p)}\n`); process.exit(1); } patches.push(p); } } if (opts.setPath !== undefined) { if (opts.setValue === undefined) failPair('--set-path', '--set-value'); patches.push({ op: 'set', path: opts.setPath, value: parseValueArg(opts.setValue) }); } else if (opts.setValue !== undefined) { failPair('--set-value', '--set-path'); } if (opts.addPath !== undefined) { if (opts.addValue === undefined) failPair('--add-path', '--add-value'); patches.push({ op: 'add', path: opts.addPath, value: parseValueArg(opts.addValue) }); } else if (opts.addValue !== undefined) { failPair('--add-value', '--add-path'); } if (opts.mergePath !== undefined) { if (opts.mergeValue === undefined) failPair('--merge-path', '--merge-value'); patches.push({ op: 'merge', path: opts.mergePath, value: parseValueArg(opts.mergeValue) }); } else if (opts.mergeValue !== undefined) { failPair('--merge-value', '--merge-path'); } if (opts.delete) patches.push({ op: 'delete', path: opts.delete }); return patches; } function failPair(provided, missing) { process.stderr.write(`${provided} requires a matching ${missing}.\n`); process.exit(1); } /** @file and @- (stdin) loading for large JSON payloads. */ function parseValueArg(arg) { let content = arg; if (arg.startsWith('@')) { const src = arg.slice(1); if (src === '-') { content = (0, fs_extra_1.readFileSync)(0, 'utf8'); } else { content = (0, fs_extra_1.readFileSync)(src, 'utf8'); } } try { return JSON.parse(content); } catch (e) { process.stderr.write(`Value is not valid JSON: ${e.message}\n`); process.stderr.write('Tip: wrap strings in quotes (e.g. "text"), use @file for large payloads, or @- to read stdin.\n'); process.exit(1); } } function isValidOp(op) { return op === 'set' || op === 'add' || op === 'delete' || op === 'merge'; } function applyPatch(root, patch) { const segs = (0, json_1.parseSegments)(patch.path); if (segs.length === 0) throw new Error('path cannot be empty'); const lastSeg = segs[segs.length - 1]; const parentSegs = segs.slice(0, -1); const parent = parentSegs.length === 0 ? root : navigate(root, parentSegs); if (parent === undefined) throw new Error('parent path not found'); switch (patch.op) { case 'set': doSet(parent, lastSeg, patch.value); return; case 'add': doAdd(parent, lastSeg, patch.value); return; case 'delete': doDelete(parent, lastSeg); return; case 'merge': doMerge(parent, lastSeg, patch.value); return; } } /** Like peek's followSegments but throws on miss so patch errors are specific. */ function navigate(root, segs) { let cur = root; for (const seg of segs) { if (!cur || typeof cur !== 'object' || Array.isArray(cur)) { throw new Error(`cannot navigate "${seg.name}" through non-object`); } const obj = cur; if (!(seg.name in obj)) throw new Error(`segment not found: ${seg.name}`); cur = obj[seg.name]; if (seg.filter) { if (!Array.isArray(cur)) throw new Error(`filter requires array at ${seg.name}`); const hit = cur.find(x => x && typeof x === 'object' && (0, json_1.matchFilterValue)(x[seg.filter.field], seg.filter)); if (hit === undefined) throw new Error(`no array element matched ${seg.name}[${seg.filter.field}=${seg.filter.value}]`); cur = hit; } } return cur; } function doSet(parent, seg, value) { if (!parent || typeof parent !== 'object' || Array.isArray(parent)) { throw new Error('set target parent is not an object'); } const obj = parent; if (seg.filter) { const arr = obj[seg.name]; if (!Array.isArray(arr)) throw new Error(`${seg.name} is not an array`); const idx = arr.findIndex(x => x && typeof x === 'object' && (0, json_1.matchFilterValue)(x[seg.filter.field], seg.filter)); if (idx < 0) throw new Error(`no match for ${seg.name}[${seg.filter.field}=${seg.filter.value}]`); arr[idx] = value; } else { obj[seg.name] = value; } } function doAdd(parent, seg, value) { if (seg.filter) { throw new Error('--add path must point to an array (no trailing [field=value] filter)'); } if (!parent || typeof parent !== 'object' || Array.isArray(parent)) { throw new Error('add target parent is not an object'); } const obj = parent; const target = obj[seg.name]; if (!Array.isArray(target)) throw new Error(`--add target must be an array, got ${typeof target}`); target.push(value); } function doDelete(parent, seg) { if (!parent || typeof parent !== 'object' || Array.isArray(parent)) { throw new Error('delete target parent is not an object'); } const obj = parent; if (seg.filter) { const arr = obj[seg.name]; if (!Array.isArray(arr)) throw new Error(`${seg.name} is not an array`); const idx = arr.findIndex(x => x && typeof x === 'object' && (0, json_1.matchFilterValue)(x[seg.filter.field], seg.filter)); if (idx < 0) throw new Error(`no match for ${seg.name}[${seg.filter.field}=${seg.filter.value}]`); arr.splice(idx, 1); } else { delete obj[seg.name]; } } function doMerge(parent, seg, value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('--merge value must be an object'); } let target; if (!parent || typeof parent !== 'object' || Array.isArray(parent)) { throw new Error('merge target parent is not an object'); } const obj = parent; if (seg.filter) { const arr = obj[seg.name]; if (!Array.isArray(arr)) throw new Error(`${seg.name} is not an array`); target = arr.find(x => x && typeof x === 'object' && (0, json_1.matchFilterValue)(x[seg.filter.field], seg.filter)); if (!target) throw new Error(`no match for ${seg.name}[${seg.filter.field}=${seg.filter.value}]`); } else { target = obj[seg.name]; } if (!target || typeof target !== 'object' || Array.isArray(target)) { throw new Error('--merge target must be an object'); } Object.assign(target, value); }