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