UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

261 lines (260 loc) 17.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PROJECT_STANDING_ALLOW_OFF_HINT = exports.PROJECT_STANDING_ALLOW_WARNING = exports.shouldOfferProjectStandingAllow = exports.addAllowDeleteCommand = void 0; // ⚠️ 改命令名 / flag / alias 时,同步 grep `lpm` skills/ .claude/skills/ 修文档引用 const commander_1 = require("commander"); const inquirer_1 = __importDefault(require("inquirer")); const env_1 = require("../../utils/env"); const logger_1 = require("../../utils/logger"); const deletion_gate_1 = require("../../v2/services/deletion-gate"); const workspace_1 = require("../../v2/utils/workspace"); const local_config_1 = require("../../v2/services/local-config"); /** * `lpm allow-delete` —— **由用户在自己终端运行**的删除授权命令。 * * 当 `lpm update`/`set` 因删除点位 / 属性 exit 2 时,把它打印的 `lpm allow-delete <nonce>` 转呈用户、 * 请用户在自己的终端执行:本命令打印**权威删除清单**(CLI 自己的 pending 记录,非 AI 转述)并要求 * 用户**显式确认**,确认后写一份**一次性**授权;下次 `update` 命中即消费。 * * 默认(每次删除都要授权)/ `--project`(本项目 standing allow,高风险弃权、**强烈不推荐**,仅测试插件; * 取消:`--project --off`)。第一次授权后会**顺带单选询问**是否开启本项目 standing allow——默认高亮「保持 * 每次确认」,只有主动选「开启」才进一步展示风险并要求输 ENABLE。**AI 不应运行本命令,也不应设置 standing allow。** * * 全局 standing allow 的能力保留(`--global` 仍可硬拼参数进入),但**不在 help / 文档露出、不教任何人怎么设**—— * 全局放开删除风险最高,刻意不暴露入口。 */ function addAllowDeleteCommand(program) { program .command('allow-delete [nonce]') .description('USER-run authorization for an irreversible point/property deletion that `lpm update`/`set` blocked (exit 2). ' + 'Run it in your OWN terminal: it prints the authoritative deletion list, asks you to confirm, then grants a ONE-TIME pass for that exact deletion. ' + 'AI agents must NOT run this — surface the printed `lpm allow-delete <nonce>` to the user instead. ' + '--project sets a project-level standing allow (HIGH RISK, NOT recommended); --status shows the current posture.') .option('--project', 'Set a PROJECT-level standing allow (HIGH RISK, NOT recommended): every deletion in THIS project auto-proceeds') // --global 隐藏不进 help:能力保留(可硬拼参数),但全局放开删除风险最高,刻意不暴露、不教人怎么设。 .addOption(new commander_1.Option('--global').hideHelp()) .option('--off', 'With --project: turn the standing allow back off') .option('--status', 'Show the current standing-allow posture') .action(async (nonce, options) => { if (options.status) { printStatus(); return; } if (options.project || options.global) { await setStanding(options); return; } await grantByNonce(nonce); }); } exports.addAllowDeleteCommand = addAllowDeleteCommand; function printStatus() { const { project, global } = (0, deletion_gate_1.getStandingAllowStatus)(); process.stdout.write('删除 standing allow 当前姿态:\n'); process.stdout.write(` 项目级(.lpm/config.json):${project ? '已开启 ⚠️(本项目删除直接放行;如需关闭:lpm allow-delete --project --off)' : '关闭(默认,每次删除需授权)'}\n`); // 全局档刻意不在 help / 文档露出;但若它**已开启**仍如实显示——绝不静默藏住一个正在生效的最高风险姿态。 if (global) { process.stdout.write(' 全局:已开启 ⚠️⚠️(任意项目删除直接放行;如需关闭:lpm allow-delete --global --off)\n'); } } /** 第一次 allow 后是否提示开启项目级 standing allow:仅交互终端、且本项目尚未开启时。绝不提供 global。 */ function shouldOfferProjectStandingAllow(isTTY, projectAlreadyOn) { return isTTY && !projectAlreadyOn; } exports.shouldOfferProjectStandingAllow = shouldOfferProjectStandingAllow; /** 项目级 standing allow 的风险警示——强烈不推荐,仅测试插件,删除不可逆、数据无法恢复。 */ exports.PROJECT_STANDING_ALLOW_WARNING = '\n⚠️ 不推荐:对【本项目】开启 standing allow 后,本项目的点位 / 属性删除将' + '【不再逐次确认、直接放行】(含 AI 发起的删除)。\n' + ' 仅建议在【可随时重建的测试插件】上开启;正式插件删除不可逆、数据无法恢复,强烈建议保持逐次确认。\n'; /** 项目级 standing allow 的取消命令——开启后必须告知用户怎么关回去(入口早已存在,只是没显眼露出)。 */ exports.PROJECT_STANDING_ALLOW_OFF_HINT = '如需关闭本项目 standing allow,随时在工程根目录执行:lpm allow-delete --project --off'; /** * 第一次 allow 后顺带询问是否对本项目开 standing allow(仅项目级、绝不 global)。两步、默认不开: * ① 单选——默认高亮「保持每次确认」(更安全、推荐);不推荐的开启项明确标注。本次删除此前已授权, * 这里只决定「之后是否还逐次确认」,选默认即什么都不变。 * ② 仅当主动选了「开启」才展示风险警示,并要求输 ENABLE 作为「我已理解风险」的显式确认; * 输其它 / 留空则放弃。开启成功后告知取消命令。 */ async function offerProjectStandingAllow() { const { choice } = await inquirer_1.default.prompt([ { name: 'choice', type: 'list', message: '本次删除已授权。之后本项目的删除要怎么处理?', default: 'keep', choices: [ { name: '保持每次删除都确认(默认 · 推荐 · 更安全)', value: 'keep' }, { name: '⚠️ 开启本项目 standing allow(不推荐:之后删除不再逐次确认,含 AI 发起的删除)', value: 'enable' }, ], }, ]); if (choice !== 'enable') { logger_1.logger.info('已保持默认:本项目仍每次删除都需授权(更安全)。'); return; } // 选了开启才展示风险,并要求显式输 ENABLE——把「我已理解风险」做成一次实打实的动作,而非默认值。 process.stderr.write(exports.PROJECT_STANDING_ALLOW_WARNING); const { confirm } = await inquirer_1.default.prompt([ { name: 'confirm', type: 'input', message: '我已理解上述风险,输入 ENABLE 确认对【本项目】开启(输其它 / 留空则放弃、保持每次确认):' }, ]); if (String(confirm).trim() !== 'ENABLE') { logger_1.logger.info('已放弃开启,本项目仍每次删除都需授权(默认、更安全)。'); return; } const file = (0, deletion_gate_1.setStandingAllow)('project', true); logger_1.logger.success(`已对【本项目】开启 standing allow:${file}`); logger_1.logger.info('建议把该文件纳入 git 跟踪——让团队 review 时看见本项目放开了删除这一高风险姿态。'); logger_1.logger.info(exports.PROJECT_STANDING_ALLOW_OFF_HINT); } async function setStanding(options) { const scope = options.global ? 'global' : 'project'; const allow = !options.off; // 项目级 standing allow 落 <cwd>/.lpm/config.json——setStandingAllow 内部会 assertPluginRoot(cwd)。 // 把这道校验提前到任何风险提示 / ENABLE 确认之前(与 grantByNonce 的守卫对齐):否则用户输完 ENABLE // 才在写盘那步抛错、白费一次确认,且默认报错文案指向的目录与实际写入位置不一致。 if (scope === 'project') { try { (0, workspace_1.assertPluginRoot)(process.cwd()); } catch (_a) { logger_1.logger.error(`当前目录不是插件工程根(找不到 plugin.config.json):${process.cwd()}\n` + '项目级 standing allow 落在插件工程根的 `.lpm/config.json`,请在工程根目录跑,' + '或带 `--cwd <插件工程根>`,例如 `lpm --cwd /abs/path/to/plugin allow-delete --project`。'); process.exit(1); } } if (allow) { const where = scope === 'global' ? '【全局 · 任意项目】' : '【本项目】'; const reach = scope === 'global' ? '任意项目' : '本项目'; process.stderr.write(`\n⚠️ 高风险:开启 ${where} standing allow 后,${reach}的点位 / 属性删除将` + '【不再逐次确认、直接放行】。AI 在此环境删除将不被拦下。\n'); if (!env_1.env.isTTY) { logger_1.logger.error('设置 standing allow 需在交互终端确认(这是高风险弃权,不能在非交互环境静默开启)。'); process.exit(1); } const { confirm } = await inquirer_1.default.prompt([ { name: 'confirm', type: 'input', message: `我已理解风险,输入 ENABLE 开启 ${scope} standing allow(输其它取消):` }, ]); if (String(confirm).trim() !== 'ENABLE') { logger_1.logger.warn('已取消,未改动 standing allow。'); process.exit(0); } } const file = (0, deletion_gate_1.setStandingAllow)(scope, allow); logger_1.logger.success(`${scope} standing allow ${allow ? '已开启' : '已关闭'}${file}`); if (allow && scope === 'project') { logger_1.logger.info('建议把该文件纳入 git 跟踪——让团队 review 时能看见本项目放开了删除这一高风险姿态。'); logger_1.logger.info(exports.PROJECT_STANDING_ALLOW_OFF_HINT); } } async function grantByNonce(arg) { if (!arg) { logger_1.logger.error('用法:lpm allow-delete <nonce | token>(被拦的 `lpm update`/`set` 会打印 nonce 与 token)。'); logger_1.logger.info('或:lpm allow-delete --status 查当前姿态;--project 设本项目 standing allow(不推荐)。'); process.exit(1); } // 两种输入:① 自包含 token(兜底,解码即得清单,无需读文件);② nonce(读项目 .lpm/ 下的 pending 文件)。 const decoded = (0, deletion_gate_1.decodeDeletionToken)(arg); let nonce; let tokens; let diffs; let pluginId; if (decoded) { ({ nonce, tokens, diffs, pluginId } = decoded); } else { // pending 落在「项目目录」<cwd>/.lpm/——所以必须在插件项目根目录下跑(或带 --cwd <项目根>)。 // 先卡住「目录不对」(典型:cwd 掉进 .lpm/delete-* 子目录、或根本不在插件工程里)—— // 给精准指引而非含糊的「nonce not found」。token 分支自包含、不读文件,不需要这道守卫。 try { (0, workspace_1.assertPluginRoot)(process.cwd()); } catch (_a) { logger_1.logger.error(`当前目录不是插件工程根(找不到 plugin.config.json):${process.cwd()}\n` + '删除授权记录落在插件工程根的 `.lpm/` 下,请在工程根目录跑,或带 `--cwd <插件工程根>`,' + '例如 `lpm --cwd /abs/path/to/plugin allow-delete <nonce>`。\n' + '若项目目录在你的终端与拦截命令之间都不共享,则改用拦截时打印的 **token**(`lpm allow-delete <token>`)授权。'); process.exit(1); } const pending = (0, deletion_gate_1.readPendingDeletion)(arg); if (!pending) { logger_1.logger.error(`没有找到 "${arg}" 对应的待确认删除。最常见原因是**没在插件项目根目录下跑**——` + '授权记录落在项目目录 `.lpm/` 下,请加 `--cwd <插件项目根>`(或 cd 进项目根目录)后重试,' + '例如 `lpm --cwd /abs/path/to/plugin allow-delete <nonce>`。' + '若项目目录在你的终端与拦截命令之间都不共享,则改用它打印的 **token**(`lpm allow-delete <token>`)授权。'); process.exit(1); } ({ nonce, tokens, diffs } = pending); pluginId = pending.pluginId; } const { points, properties } = (0, deletion_gate_1.countDeletions)(diffs); const total = points + properties; // 权威清单:来自 CLI 的 pending 记录或自校验过的 token,直达用户、不经 AI 转述。 process.stderr.write('\n' + (0, deletion_gate_1.deletionBanner)(diffs) + '\n'); process.stderr.write('以下内容将被删除(不可逆):\n'); for (const line of (0, deletion_gate_1.formatDeletionLines)(diffs)) process.stderr.write(line + '\n'); // 附带展示「本次还将一并修改」——授权删除时顺带让用户看到同批的修改改动(含深层字段), // 但**不计入**需确认的删除数(确认数仍只 = 删除项)。token 兜底路径的 diffs 已被 slim 掉 changes, // 这里自然为空、整段省略;只有走 pending 文件(nonce)路径才有 changes 可展示。 const modifiedEntries = diffs.filter(d => d.status === 'modified' && d.changes && d.changes.length > 0); if (modifiedEntries.length > 0) { process.stderr.write('\n本次还将一并修改(仅告知,无需确认):\n'); for (const d of modifiedEntries) { const nameSuffix = d.name ? `「${d.name}」` : ''; process.stderr.write(` ${d.type}[${d.key}]${nameSuffix}\n`); for (const c of d.changes) { for (const line of (0, local_config_1.formatStructuralChange)(c, ' ')) process.stderr.write(line + '\n'); } } } if (!env_1.env.isTTY) { logger_1.logger.error('\nlpm allow-delete 必须由【人】在交互终端执行(需显式确认)。它不是给 AI 用的——请把上面的命令交给用户运行。'); process.exit(1); } const { confirm } = await inquirer_1.default.prompt([ { name: 'confirm', type: 'input', message: `确认删除以上 ${total} 项,请输入数字 ${total}(输其它 / 留空则取消):` }, ]); if (String(confirm).trim() !== String(total)) { logger_1.logger.warn('数字不符,已取消,未授权任何删除。'); process.exit(0); } // 同机:写一次性 grant 文件到项目 .lpm/,供同一项目目录下的 `update` 消费。 // 包 try:token 兜底流可能在非插件目录跑——本地 grant 写不了/被守卫拒不致命,那条流靠下方回传的 // grant token 放行,本地 grant 文件对它无用。grantFile 有值即「确实落在插件工程根」。 let grantFile; try { grantFile = (0, deletion_gate_1.writeDeleteGrant)(nonce, tokens); } catch (_b) { /* 非插件目录或写盘失败:忽略本地 grant,靠下方 grant token 放行 */ } (0, deletion_gate_1.removePendingDeletion)(nonce); // 提示故意保持中性「重跑刚才被拦下的命令」,不写死 `lpm update`:删除闸口同时挂在 // `update`(publish 流,本地配置已含改动)和 `local-config set`(config-apply 流,set 在写盘前就 // exit 2、draft 还没落地)两条流上。在 set 流里若照「重跑 update」走,会推还没改的旧本地配置并报 // success,用户真正的删除 + 增改静默没生效——故与 writeDeletionBlockNotice 的「重跑即放行」对齐。 logger_1.logger.success('已授权本次删除(一次性)。现在重跑刚才被拦下的那条命令即可放行。'); if (grantFile) logger_1.logger.info(`(grant: ${grantFile})`); // 跨沙箱:若 `update` 跑在另一个文件系统(读不到上面的 grant 文件),打印 grant token 让用户回传放行。 if (pluginId) { const g = (0, deletion_gate_1.encodeDeletionToken)(pluginId, diffs); process.stderr.write('\n→ 若你的 `update` 跑在另一个文件系统(如隔离沙箱、读不到上面的本地 grant 文件):\n' + ' 把下面这段 grant token 作为 nonce 复制贴给 agent,以便 agent 用 `lpm update --source-type=local --delete-grant <token>` 放行(不依赖本地文件):\n' + ` ${g}\n`); } // 第一次授权后:若本项目尚未开 standing allow,顺带问一句要不要开(仅本项目、强烈不推荐)。 // 仅当 grantFile 写成了(即确实落在插件工程根)才问——非插件目录(token 兜底流)下项目级 standing allow // 无处可落,setStandingAllow 会被守卫拒,跳过提问。本次删除已授权、不受此问影响(这只是个 opt-out)。 if (grantFile) { const { project: projectStandingOn } = (0, deletion_gate_1.getStandingAllowStatus)(); if (shouldOfferProjectStandingAllow(env_1.env.isTTY, projectStandingOn)) { await offerProjectStandingAllow(); } } }