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