mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat(runtime): add unified confirmation gate for high-risk commands
This commit is contained in:
@@ -79,6 +79,13 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
|
||||
3. **--dry-run**:实现在联网/上传/写盘**之前**返回;断言 stdout JSON/文本
|
||||
4. **真实集成**:放在 skip 块**末尾**
|
||||
|
||||
高风险命令额外要求:
|
||||
|
||||
- `--help` 展示 runtime 注入的 `--yes`
|
||||
- 无 `--yes` 返回 exit code 7 和 JSON `type: "requires_confirmation"`
|
||||
- `--dry-run` 无需 `--yes`,且必须证明在任何远端请求或本地写入之前返回
|
||||
- runtime 的离线 high-risk fixture 必须覆盖带 `--yes` 确实进入 `run()`,并断言 `yes` 不进入 command 自有 flags
|
||||
|
||||
## Journey 层(用户旅程全链路)
|
||||
|
||||
- **定位**:命令 E2E 验单命令契约;journey 验“用户带着目标跨命令走通回路”,结构性断言不在 journey 重复
|
||||
|
||||
@@ -71,7 +71,9 @@ packages/commands/src/index.ts
|
||||
- `usageArgs`(不含 bin/path 前缀)
|
||||
- `exampleArgs`(不含 bin/path 前缀)
|
||||
- `validate`(跨 flag 校验)
|
||||
- 高风险命令必须声明 `risk: { level: "high", message: <双语文案> }`;`--yes` 由 runtime 注入,命令不得自行声明
|
||||
- 普通业务命令的 `run(ctx)` 只读 `ctx.flags` / `ctx.settings` / `ctx.client`
|
||||
- 声明 `risk` 的 `run(ctx)` 必须在任何远端请求或本地写入之前处理 `ctx.settings.dryRun` 并返回预览;runtime 只负责确认闸门,不替命令实现 dry-run
|
||||
- `commands/auth/**` 可用 `ctx.authStore`,`commands/config/**` 可用 `ctx.configStore`;不要把这些持久化能力扩散到普通业务命令
|
||||
- `commands/plugin/**` 可用 `ctx.commandPacks`;产品 policy 由 runtime 绑定,命令不要自行 import 产品入口
|
||||
- [ ] 用户可见 Help 文案在命令文件中就近提供 `en-US` / `zh-CN`:命令 `description`、flag `description`、`notes` 和包含自然语言的 `exampleArgs`;纯命令语法示例可保留为字符串,服务端错误不翻译
|
||||
|
||||
@@ -17,11 +17,12 @@
|
||||
│ ├─ ~/.bailian/telemetry.jsonl
|
||||
│ └─ AEM(pid=bailian-cli-node, event name=命令路径)
|
||||
│
|
||||
└─ authStage
|
||||
├─ apiKey → DashScope / 模型域
|
||||
├─ console → Bailian Console Gateway
|
||||
├─ openapi → 阿里云 OpenAPI
|
||||
└─ none → 无凭证域;本地命令也仍有 AEM 命令事件
|
||||
└─ confirmationStage
|
||||
└─ versionCheckStage → authStage
|
||||
├─ apiKey → DashScope / 模型域
|
||||
├─ console → Bailian Console Gateway
|
||||
├─ openapi → 阿里云 OpenAPI
|
||||
└─ none → 无凭证域;本地命令也仍有 AEM 命令事件
|
||||
```
|
||||
|
||||
### 1. 三套鉴权与埋点标识
|
||||
@@ -77,7 +78,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网
|
||||
|
||||
### 3. 全命令 AEM 客户端埋点
|
||||
|
||||
`packages/runtime/src/middleware.ts` 的 `telemetryStage` 包裹 `authStage` 与命令执行,因此成功、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`。
|
||||
`packages/runtime/src/middleware.ts` 的 `telemetryStage` 包裹确认闸门、`authStage` 与命令执行,因此成功、确认未通过、业务失败、网络失败和鉴权失败都会形成一次命令事件。事件名是空格连接的命令路径,例如 `text chat`。确认闸门仍位于版本检查、鉴权和业务执行之前,不会因为埋点而放行高风险操作。
|
||||
|
||||
以下情况不会形成命令事件,因为没有进入 middleware 的 `run`:
|
||||
|
||||
@@ -92,7 +93,7 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网
|
||||
- `command`、`timestamp`、`durationMs`、`success`
|
||||
- `cliVersion`、`nodeVersion`、`os`
|
||||
- `authMethod`
|
||||
- 失败时的 `errorMessage`、`httpStatus`、`requestId`
|
||||
- 失败时的 `errorMessage`、`exitCode`、`httpStatus`、`requestId`
|
||||
- 安全 allowlist 过滤后的 `params`
|
||||
|
||||
参数默认不上传,只有 `packages/core/src/telemetry/tracker.ts` 的 `PARAM_ALLOWLIST` 中字段会进入事件。不得加入 prompt、凭证、文件路径、URL、账号/租户/工作空间 ID 或其他用户内容。
|
||||
@@ -108,16 +109,16 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网
|
||||
|
||||
AEM 映射:
|
||||
|
||||
| AEM 字段 | 内容 |
|
||||
| ---------- | ----------------------------------------- |
|
||||
| event name | 命令路径 |
|
||||
| `et` | `EXP` |
|
||||
| `ext` | 除 `command`、`params` 外的结构化事件字段 |
|
||||
| `c1` | allowlist 参数 |
|
||||
| `c2` | `success` / `failure` |
|
||||
| `c3` | HTTP status |
|
||||
| `c4` | 错误文案,最多 500 字符 |
|
||||
| `c5` | request ID |
|
||||
| AEM 字段 | 内容 |
|
||||
| ---------- | ------------------------------------------------------------------ |
|
||||
| event name | 命令路径 |
|
||||
| `et` | `EXP` |
|
||||
| `ext` | 除 `command`、`params` 外的结构化事件字段,包含失败时的 `exitCode` |
|
||||
| `c1` | allowlist 参数 |
|
||||
| `c2` | `success` / `failure` |
|
||||
| `c3` | HTTP status |
|
||||
| `c4` | 错误文案,最多 500 字符 |
|
||||
| `c5` | request ID |
|
||||
|
||||
远端发送是 best-effort,不得阻塞命令或改变退出码。正常退出最多等待 1 秒,SIGINT 最多等待 500 ms。
|
||||
|
||||
@@ -144,6 +145,7 @@ AEM 映射:
|
||||
- [ ] 更新 `TrackingEvent`、`createTrackingEvent()` 与 `buildRemoteAemOptions()` 的字段映射
|
||||
- [ ] 本地 JSONL 与远端 AEM 必须基于同一结构化事件,不能维护两套字段口径
|
||||
- [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码
|
||||
- [ ] runtime 本地语义错误应记录 `exitCode`;新增字段默认随 AEM `ext` 上报,无需占用新的 `c1`—`c5`
|
||||
- [ ] 检查 `DO_NOT_TRACK=1` 与 `telemetry: false` 两个关闭入口
|
||||
- [ ] 错误字段不得额外拼接 token、请求体、prompt 或本地路径
|
||||
|
||||
|
||||
@@ -190,11 +190,11 @@ bl knowledge doc delete --index-id <id> --doc-id <id> [flags]
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------- | ------ | ---- | ----------------- |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--doc-id <id>` | array | 是 | 文档 ID(可重复) |
|
||||
| `--yes` | switch | 否 | 跳过确认提示 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------- | ------ | ---- | ------------------ |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--doc-id <id>` | array | 是 | 文档 ID(可重复) |
|
||||
| `--yes` | switch | 否 | 显式确认高风险操作 |
|
||||
|
||||
**输出**
|
||||
|
||||
@@ -223,7 +223,7 @@ json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID
|
||||
# 删除单个文档
|
||||
bl knowledge doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx
|
||||
|
||||
# 批量删除,跳过确认
|
||||
# 用户明确确认后批量删除
|
||||
bl knowledge doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes
|
||||
```
|
||||
|
||||
|
||||
+6
-6
@@ -190,11 +190,11 @@ kscli doc delete --index-id <id> --doc-id <id> [flags]
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------- | ------ | ---- | ----------------- |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--doc-id <id>` | array | 是 | 文档 ID(可重复) |
|
||||
| `--yes` | switch | 否 | 跳过确认提示 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------------- | ------ | ---- | ------------------ |
|
||||
| `--index-id <id>` | string | 是 | 知识库 ID |
|
||||
| `--doc-id <id>` | array | 是 | 文档 ID(可重复) |
|
||||
| `--yes` | switch | 否 | 显式确认高风险操作 |
|
||||
|
||||
**输出**
|
||||
|
||||
@@ -223,7 +223,7 @@ json 模式:返回 API 原始响应,`data.deleted[]` 为实际删除的 ID
|
||||
# 删除单个文档
|
||||
kscli doc delete --index-id idx-xxx --doc-id doc-xxx --workspace-id ws-xxx
|
||||
|
||||
# 批量删除,跳过确认
|
||||
# 用户明确确认后批量删除
|
||||
kscli doc delete --index-id idx-xxx --doc-id doc-a --doc-id doc-b --yes
|
||||
```
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ describe("e2e: Command Pack", () => {
|
||||
expect(linkedJson.linked.commands).toEqual([
|
||||
"agent credential",
|
||||
"agent credential-denied",
|
||||
"agent dangerous",
|
||||
"agent fail",
|
||||
"agent output",
|
||||
"agent ping",
|
||||
@@ -96,6 +97,34 @@ describe("e2e: Command Pack", () => {
|
||||
expect(failed.stderr).toContain("Use agent fail only in tests.");
|
||||
});
|
||||
|
||||
test("high-risk 命令由 runtime 统一确认并支持安全 dry-run", async () => {
|
||||
const dangerousHelp = await runCli(["agent", "dangerous", "--help"], env());
|
||||
expect(dangerousHelp.exitCode, dangerousHelp.stderr).toBe(0);
|
||||
expect(dangerousHelp.stderr).toContain("--yes");
|
||||
|
||||
const unconfirmed = await runCli(["agent", "dangerous", "--output", "json"], env());
|
||||
expect(unconfirmed.exitCode).toBe(7);
|
||||
expect(JSON.parse(unconfirmed.stderr)).toMatchObject({
|
||||
error: { code: 7, type: "requires_confirmation" },
|
||||
});
|
||||
|
||||
const confirmed = await runCli(["agent", "dangerous", "--yes", "--output", "json"], env());
|
||||
expect(confirmed.exitCode, confirmed.stderr).toBe(0);
|
||||
expect(parseStdoutJson(confirmed.stdout)).toEqual({
|
||||
executed: true,
|
||||
dry_run: false,
|
||||
command_flags: [],
|
||||
});
|
||||
|
||||
const preview = await runCli(["agent", "dangerous", "--dry-run", "--output", "json"], env());
|
||||
expect(preview.exitCode, preview.stderr).toBe(0);
|
||||
expect(parseStdoutJson(preview.stdout)).toEqual({
|
||||
executed: false,
|
||||
dry_run: true,
|
||||
command_flags: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin list 输出加载状态", async () => {
|
||||
const result = await runCli(["plugin", "list", "--output", "json"], env());
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
@@ -109,6 +138,7 @@ describe("e2e: Command Pack", () => {
|
||||
commands: [
|
||||
"agent credential",
|
||||
"agent credential-denied",
|
||||
"agent dangerous",
|
||||
"agent fail",
|
||||
"agent output",
|
||||
"agent ping",
|
||||
|
||||
@@ -19,6 +19,26 @@ const ping = {
|
||||
},
|
||||
};
|
||||
|
||||
const dangerous = {
|
||||
description: "Exercise runtime confirmation for a high-risk Command Pack command",
|
||||
auth: "none",
|
||||
risk: {
|
||||
level: "high",
|
||||
message: {
|
||||
"en-US": "This fixture represents a high-risk operation.",
|
||||
"zh-CN": "该测试命令代表高风险操作。",
|
||||
},
|
||||
},
|
||||
async run(ctx) {
|
||||
const dryRun = ctx.settings.dryRun;
|
||||
ctx.output.result({
|
||||
executed: !dryRun,
|
||||
dry_run: dryRun,
|
||||
command_flags: Object.keys(ctx.flags),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const credential = {
|
||||
description: "Read an API key through the Command Pack host adapter",
|
||||
auth: "apiKey",
|
||||
@@ -55,6 +75,7 @@ const fail = {
|
||||
export default {
|
||||
"agent credential": credential,
|
||||
"agent credential-denied": credentialDenied,
|
||||
"agent dangerous": dangerous,
|
||||
"agent fail": fail,
|
||||
"agent output": output,
|
||||
"agent ping": ping,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type FlagsDef,
|
||||
type RagDeleteFileResponse,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, confirmDangerousAction } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { resolveWorkspaceId, WORKSPACE_FLAG } from "./shared.ts";
|
||||
|
||||
const DOC_DELETE_FLAGS = {
|
||||
@@ -25,28 +25,22 @@ const DOC_DELETE_FLAGS = {
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
yes: {
|
||||
type: "switch",
|
||||
description: { "en-US": "Skip the confirmation prompt", "zh-CN": "跳过确认提示" },
|
||||
},
|
||||
...WORKSPACE_FLAG,
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** Confirmation summary: list all doc_ids up to 5, otherwise show the first 5 + total count */
|
||||
function buildDeleteSummary(indexId: string, docIds: string[]): string {
|
||||
const listed =
|
||||
docIds.length <= 5
|
||||
? docIds.join("\n ")
|
||||
: `${docIds.slice(0, 5).join("\n ")}\n ... (${docIds.length} documents total)`;
|
||||
return `Delete ${docIds.length} document(s) from knowledge base ${indexId}:\n ${listed}\nDocuments and all their chunks are permanently removed from the index. This cannot be undone.`;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: {
|
||||
"en-US": "Delete documents and their chunks from a knowledge base",
|
||||
"zh-CN": "从知识库中删除文档及其 Chunk",
|
||||
},
|
||||
auth: "apiKey",
|
||||
risk: {
|
||||
level: "high",
|
||||
message: {
|
||||
"en-US": "This permanently deletes the selected documents and all of their chunks.",
|
||||
"zh-CN": "该操作会永久删除所选文档及其全部 Chunk,且无法撤销。",
|
||||
},
|
||||
},
|
||||
usageArgs: "--index-id <id> --doc-id <id> [flags]",
|
||||
flags: DOC_DELETE_FLAGS,
|
||||
notes: [
|
||||
@@ -72,7 +66,7 @@ export default defineCommand({
|
||||
},
|
||||
],
|
||||
exampleArgs: [
|
||||
"--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx",
|
||||
"--index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx --dry-run",
|
||||
"--index-id idx-xxx --doc-id file-a --doc-id file-b --yes",
|
||||
],
|
||||
async run(ctx) {
|
||||
@@ -89,11 +83,6 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
await confirmDangerousAction(
|
||||
buildDeleteSummary(flags.indexId, flags.docId),
|
||||
flags.yes ?? false,
|
||||
);
|
||||
|
||||
const response = await ctx.client.requestJson<RagDeleteFileResponse>({
|
||||
path: endpoint,
|
||||
method: "POST",
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("e2e: knowledge doc delete", () => {
|
||||
expect(data.request?.doc_ids).toEqual(["file_a", "file_b"]);
|
||||
});
|
||||
|
||||
test("非 TTY 无 --yes 报 USAGE (2)", async () => {
|
||||
test("无 --yes 返回确认请求 (7)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(KNOWLEDGE_DOC_DELETE_ROUTES, [
|
||||
"knowledge",
|
||||
"doc",
|
||||
@@ -79,9 +79,18 @@ describe("e2e: knowledge doc delete", () => {
|
||||
"sk-fake",
|
||||
"--workspace-id",
|
||||
"ws_test",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--yes/);
|
||||
expect(exitCode).toBe(7);
|
||||
expect(JSON.parse(stderr)).toMatchObject({
|
||||
error: {
|
||||
code: 7,
|
||||
type: "requires_confirmation",
|
||||
hint: expect.stringContaining("--yes"),
|
||||
},
|
||||
});
|
||||
expect(stderr).not.toContain("sk-fake");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ExitCode = {
|
||||
QUOTA: 4,
|
||||
TIMEOUT: 5,
|
||||
NETWORK: 6,
|
||||
CONFIRMATION_REQUIRED: 7,
|
||||
CONTENT_FILTER: 10,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface TrackingEvent {
|
||||
durationMs: number;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
exitCode?: number;
|
||||
httpStatus?: number;
|
||||
requestId?: string;
|
||||
cliVersion: string;
|
||||
@@ -19,7 +20,7 @@ export function createTrackingEvent(opts: {
|
||||
command: string;
|
||||
durationMs: number;
|
||||
success: boolean;
|
||||
error?: { message?: string; httpStatus?: number; requestId?: string };
|
||||
error?: { message?: string; exitCode?: number; httpStatus?: number; requestId?: string };
|
||||
cliVersion: string;
|
||||
authMethod?: AuthRequirement;
|
||||
params?: Record<string, unknown>;
|
||||
@@ -40,6 +41,7 @@ export function createTrackingEvent(opts: {
|
||||
|
||||
if (!opts.success && opts.error) {
|
||||
if (opts.error.message) event.errorMessage = opts.error.message;
|
||||
if (opts.error.exitCode !== undefined) event.exitCode = opts.error.exitCode;
|
||||
if (opts.error.httpStatus !== undefined) event.httpStatus = opts.error.httpStatus;
|
||||
if (opts.error.requestId) event.requestId = opts.error.requestId;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ export async function trackCommandExecution(
|
||||
const start = performance.now();
|
||||
let success = true;
|
||||
let errorMessage: string | undefined;
|
||||
let exitCode: number | undefined;
|
||||
let httpStatus: number | undefined;
|
||||
let requestId: string | undefined;
|
||||
|
||||
@@ -112,6 +113,7 @@ export async function trackCommandExecution(
|
||||
success = false;
|
||||
if (err instanceof BailianError) {
|
||||
errorMessage = err.message;
|
||||
exitCode = err.exitCode;
|
||||
httpStatus = err.api?.httpStatus;
|
||||
requestId = err.api?.requestId;
|
||||
} else if (err instanceof Error) {
|
||||
@@ -125,7 +127,7 @@ export async function trackCommandExecution(
|
||||
command: commandPath.join(" "),
|
||||
durationMs,
|
||||
success,
|
||||
error: success ? undefined : { message: errorMessage, httpStatus, requestId },
|
||||
error: success ? undefined : { message: errorMessage, exitCode, httpStatus, requestId },
|
||||
cliVersion: deps.identity.version,
|
||||
authMethod: deps.authMethod,
|
||||
params: extractParams(flags),
|
||||
|
||||
@@ -258,10 +258,24 @@ export interface CommandContext<F extends FlagsDef = FlagsDef> {
|
||||
* typed flags (`ParsedFlags<F>` = 命令自有 flag). Stored heterogeneously as
|
||||
* {@link AnyCommand}; the precise typing lives at the `defineCommand` call site.
|
||||
*/
|
||||
export type CommandRiskLevel = "high";
|
||||
|
||||
export interface CommandRisk {
|
||||
level: CommandRiskLevel;
|
||||
message: LocalizedText;
|
||||
}
|
||||
|
||||
export interface Command<F extends FlagsDef = FlagsDef> {
|
||||
description: LocalizedText;
|
||||
/** Credential this command requires. See {@link AuthRequirement}. */
|
||||
auth: AuthRequirement;
|
||||
/**
|
||||
* Runtime-classified operation risk and its user-facing consequence message.
|
||||
* Omit for normal commands.
|
||||
* High-risk commands must return from `run` on `settings.dryRun` before any
|
||||
* remote request or local write; runtime only owns the confirmation gate.
|
||||
*/
|
||||
risk?: CommandRisk;
|
||||
/** Usage line arg portion, e.g. "--prompt <text> [flags]". Manually written. */
|
||||
usageArgs?: string;
|
||||
/** Example args (without the `<bin> <path>` prefix). */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export type {
|
||||
Command,
|
||||
CommandRisk,
|
||||
CommandRiskLevel,
|
||||
AnyCommand,
|
||||
CommandContext,
|
||||
LocalizedText,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { defineCommand, type CommandPack, type CommandRiskLevel } from "../src/index.ts";
|
||||
|
||||
const noopRun = async () => {};
|
||||
|
||||
test("high-risk commands keep level and message in one typed object", () => {
|
||||
const command = defineCommand({
|
||||
description: "danger",
|
||||
auth: "none",
|
||||
risk: { level: "high", message: "dangerous operation" },
|
||||
run: noopRun,
|
||||
});
|
||||
const pack = {
|
||||
"agent dangerous": {
|
||||
description: "danger",
|
||||
auth: "none",
|
||||
risk: { level: "high", message: "dangerous operation" },
|
||||
run: noopRun,
|
||||
},
|
||||
} satisfies CommandPack;
|
||||
|
||||
expect(command.risk).toEqual({ level: "high", message: "dangerous operation" });
|
||||
expect(pack["agent dangerous"].risk.level).toBe("high");
|
||||
});
|
||||
|
||||
test("risk types reject flat or incomplete declarations", () => {
|
||||
const high = "high" satisfies CommandRiskLevel;
|
||||
// @ts-expect-error unsupported levels must be added to CommandRiskLevel first.
|
||||
const low = "low" satisfies CommandRiskLevel;
|
||||
|
||||
defineCommand({
|
||||
description: "danger",
|
||||
auth: "none",
|
||||
// @ts-expect-error high-risk metadata requires a message.
|
||||
risk: { level: "high" },
|
||||
run: noopRun,
|
||||
});
|
||||
defineCommand({
|
||||
description: "danger",
|
||||
auth: "none",
|
||||
// @ts-expect-error risk metadata is a single object, not a flat level.
|
||||
risk: "high",
|
||||
run: noopRun,
|
||||
});
|
||||
|
||||
expect(high).toBe("high");
|
||||
expect(low).toBe("low");
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, test, vi } from "vite-plus/test";
|
||||
import type { Identity, Settings } from "../src/config/schema.ts";
|
||||
import { BailianError } from "../src/errors/base.ts";
|
||||
import { ExitCode } from "../src/errors/codes.ts";
|
||||
import { buildRemoteAemOptions, type TrackingEvent } from "../src/telemetry/event.ts";
|
||||
|
||||
const sinkMocks = vi.hoisted(() => ({
|
||||
localSink: vi.fn<(event: TrackingEvent) => Promise<void>>(async () => {}),
|
||||
remoteSink: vi.fn<(event: TrackingEvent) => Promise<void>>(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../src/telemetry/sink.ts", () => sinkMocks);
|
||||
|
||||
import { trackCommandExecution } from "../src/telemetry/tracker.ts";
|
||||
|
||||
const identity: Identity = {
|
||||
binName: "bl",
|
||||
version: "0.0.0-test",
|
||||
clientName: "bailian-cli-test",
|
||||
npmPackage: "bailian-cli",
|
||||
};
|
||||
|
||||
test("records BailianError exitCode in local events and AEM ext", async () => {
|
||||
sinkMocks.localSink.mockClear();
|
||||
sinkMocks.remoteSink.mockClear();
|
||||
const error = new BailianError("该操作会永久删除文档。", ExitCode.CONFIRMATION_REQUIRED);
|
||||
|
||||
await expect(
|
||||
trackCommandExecution(
|
||||
{
|
||||
identity,
|
||||
settings: { telemetry: true } as Settings,
|
||||
authMethod: "apiKey",
|
||||
},
|
||||
["knowledge", "doc", "delete"],
|
||||
{},
|
||||
async () => {
|
||||
throw error;
|
||||
},
|
||||
),
|
||||
).rejects.toBe(error);
|
||||
|
||||
expect(sinkMocks.localSink).toHaveBeenCalledOnce();
|
||||
const event = sinkMocks.localSink.mock.calls[0]![0];
|
||||
expect(event).toMatchObject({
|
||||
command: "knowledge doc delete",
|
||||
success: false,
|
||||
exitCode: 7,
|
||||
errorMessage: "该操作会永久删除文档。",
|
||||
});
|
||||
expect(buildRemoteAemOptions(event)).toMatchObject({
|
||||
ext: expect.objectContaining({ exitCode: 7 }),
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,9 @@ function assertCommand(path: string, value: unknown): asserts value is CommandPa
|
||||
if (!command.auth || !AUTH_REQUIREMENTS.has(command.auth)) {
|
||||
throw new Error(`Command "${path}" has an invalid auth requirement.`);
|
||||
}
|
||||
if (command.risk !== undefined && !isLocalizedText(command.risk.message)) {
|
||||
throw new Error(`Command "${path}" has an invalid risk message.`);
|
||||
}
|
||||
if (typeof command.run !== "function") {
|
||||
throw new Error(`Command "${path}" is missing run(ctx).`);
|
||||
}
|
||||
@@ -106,6 +109,7 @@ function adaptCommandPack(
|
||||
{
|
||||
description: command.description,
|
||||
auth: command.auth,
|
||||
risk: command.risk,
|
||||
usageArgs: command.usageArgs,
|
||||
exampleArgs: command.exampleArgs,
|
||||
notes: command.notes,
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
// Confirmation guard for dangerous operations — used by irreversible or
|
||||
// production-affecting commands (kb/doc/chunk/category/file delete, service
|
||||
// delete/deploy, ...).
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { BailianError, ExitCode } from "bailian-cli-core";
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type CommandRisk,
|
||||
type FlagsDef,
|
||||
type LocalizedText,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
/** Runtime-owned flag: commands declare risk, never their own confirmation flag. */
|
||||
export const CONFIRMATION_FLAGS = {
|
||||
yes: {
|
||||
type: "switch",
|
||||
description: {
|
||||
"en-US": "Confirm this high-risk operation",
|
||||
"zh-CN": "确认执行此高风险操作",
|
||||
},
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export function confirmationFlagDefs(command: { risk?: CommandRisk }): FlagsDef {
|
||||
return command.risk === undefined ? {} : CONFIRMATION_FLAGS;
|
||||
}
|
||||
|
||||
/**
|
||||
* - `yes` (the command's own --yes switch) → pass through
|
||||
* - TTY: print the summary and wait for y/yes (case-insensitive); any other
|
||||
* input cancels with exit SUCCESS (cancellation is not an error)
|
||||
* - non-TTY without --yes: throw USAGE
|
||||
* Transitional compatibility for commands that have not moved to command-level
|
||||
* risk metadata yet.
|
||||
* TODO(next commit): migrate every remaining caller to command-level risk metadata, then
|
||||
* remove this helper and update their confirmation wording/examples together.
|
||||
*
|
||||
* @deprecated Declare command-level risk metadata and let runtime gate confirmation.
|
||||
*/
|
||||
export async function confirmDangerousAction(summary: string, yes: boolean): Promise<void> {
|
||||
if (yes) return;
|
||||
@@ -34,3 +54,35 @@ export async function confirmDangerousAction(summary: string, yes: boolean): Pro
|
||||
readline.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmationHint(): LocalizedText {
|
||||
return {
|
||||
"en-US":
|
||||
"This command performs a high-risk operation. To continue, add --yes to the original command and re-run it.",
|
||||
"zh-CN": "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。",
|
||||
};
|
||||
}
|
||||
|
||||
interface ConfirmationRequiredErrorOptions {
|
||||
message: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** Semantic runtime error consumed by both humans and Agent callers. */
|
||||
export class ConfirmationRequiredError extends BailianError {
|
||||
constructor(options: ConfirmationRequiredErrorOptions) {
|
||||
super(options.message, ExitCode.CONFIRMATION_REQUIRED, options.hint);
|
||||
this.name = "ConfirmationRequiredError";
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
error: {
|
||||
code: this.exitCode,
|
||||
type: "requires_confirmation",
|
||||
message: this.message,
|
||||
hint: this.hint,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,18 @@ import {
|
||||
authStage,
|
||||
telemetryStage,
|
||||
versionCheckStage,
|
||||
confirmationStage,
|
||||
runCommandStage,
|
||||
type RunContext,
|
||||
} from "./middleware.ts";
|
||||
import type { AnyCommand, FlagsDef, Identity, ParsedFlags, SourceFlags } from "bailian-cli-core";
|
||||
import type {
|
||||
AnyCommand,
|
||||
FlagsDef,
|
||||
Identity,
|
||||
LocalizedText,
|
||||
ParsedFlags,
|
||||
SourceFlags,
|
||||
} from "bailian-cli-core";
|
||||
import {
|
||||
CONSOLE_AUTH_FLAGS,
|
||||
DEFAULT_LANGUAGE,
|
||||
@@ -33,6 +41,7 @@ import { loadCommandPacks } from "./command-packs/load.ts";
|
||||
import { createCommandPackManager } from "./command-packs/manager.ts";
|
||||
import type { CommandPackPolicy } from "./command-packs/types.ts";
|
||||
import { createTranslator } from "./i18n.ts";
|
||||
import { confirmationFlagDefs } from "./confirm.ts";
|
||||
|
||||
/** Per-product identity injected by each CLI entrypoint (bl / rag / …). */
|
||||
export interface CliOptions {
|
||||
@@ -113,7 +122,13 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
|
||||
|
||||
installProcessHandlers(binName);
|
||||
|
||||
const runMiddleware = compose([versionCheckStage, telemetryStage, authStage, runCommandStage]);
|
||||
const runMiddleware = compose([
|
||||
telemetryStage,
|
||||
confirmationStage,
|
||||
versionCheckStage,
|
||||
authStage,
|
||||
runCommandStage,
|
||||
]);
|
||||
|
||||
function getLoadedCommandPacks(): ReturnType<typeof loadCommandPacks> {
|
||||
if (!loadedCommandPacksPromise) {
|
||||
@@ -122,11 +137,17 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
|
||||
return loadedCommandPacksPromise;
|
||||
}
|
||||
|
||||
async function getRegistry(argv: string[]): Promise<CommandRegistry> {
|
||||
async function getRegistry(argv: string[]): Promise<{
|
||||
registry: CommandRegistry;
|
||||
localize: (text: LocalizedText) => string;
|
||||
}> {
|
||||
const localeSources = buildSources(pickConfigFlag(argv));
|
||||
const translator = createTranslator(localeSources.file.language ?? DEFAULT_LANGUAGE);
|
||||
const loaded = await getLoadedCommandPacks();
|
||||
return new CommandRegistry(loaded.commands, binName, translator);
|
||||
return {
|
||||
registry: new CommandRegistry(loaded.commands, binName, translator),
|
||||
localize: (text) => translator.localize(text),
|
||||
};
|
||||
}
|
||||
|
||||
/** Render help for `path`; root ([]) doubles as the onboarding / login guide. */
|
||||
@@ -157,7 +178,11 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatch(registry: CommandRegistry, argv: string[]): Promise<void> {
|
||||
async function dispatch(
|
||||
registry: CommandRegistry,
|
||||
argv: string[],
|
||||
localize: (text: LocalizedText) => string,
|
||||
): Promise<void> {
|
||||
const res = resolve(argv, registry);
|
||||
|
||||
switch (res.kind) {
|
||||
@@ -177,9 +202,11 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
|
||||
try {
|
||||
// 全局与凭证域 flag 进 sources,命令自有 flag 进 ctx.flags。
|
||||
const credDefs = credentialFlagDefs(res.command);
|
||||
const confirmationDefs = confirmationFlagDefs(res.command);
|
||||
const parsedFlags = parseFlags(res.rest, {
|
||||
...GLOBAL_FLAGS,
|
||||
...credDefs,
|
||||
...confirmationDefs,
|
||||
...res.command.flags,
|
||||
}) as Record<string, unknown>;
|
||||
const globalFlags = pick(parsedFlags, [
|
||||
@@ -201,6 +228,8 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
|
||||
path: res.path,
|
||||
command: res.command,
|
||||
flags: ownFlags,
|
||||
confirmed: parsedFlags.yes === true,
|
||||
localize,
|
||||
settings,
|
||||
sources,
|
||||
configStore: makeConfigStore(sources.configName),
|
||||
@@ -229,7 +258,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
|
||||
run(argv: string[] = process.argv.slice(2)) {
|
||||
return Promise.resolve()
|
||||
.then(() => getRegistry(argv))
|
||||
.then((registry) => dispatch(registry, argv))
|
||||
.then(({ registry, localize }) => dispatch(registry, argv, localize))
|
||||
.catch(
|
||||
(err) => flushTelemetry(1000).finally(() => handleError(err, binName)) as unknown as void,
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ParsedFlags,
|
||||
ResolutionSources,
|
||||
Settings,
|
||||
LocalizedText,
|
||||
} from "bailian-cli-core";
|
||||
import {
|
||||
Client,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
performAutoUpdate,
|
||||
shouldAutoUpdate,
|
||||
} from "./utils/update-checker.ts";
|
||||
import { ConfirmationRequiredError, confirmationHint } from "./confirm.ts";
|
||||
|
||||
/**
|
||||
* What each middleware stage gets for the invocation in flight: the matched
|
||||
@@ -42,6 +44,10 @@ export interface RunContext {
|
||||
readonly command: AnyCommand;
|
||||
/** 只含本命令声明的 flag(分流后);全局 flag 在 sources/settings。 */
|
||||
flags: ParsedFlags<FlagsDef>;
|
||||
/** Whether the runtime-owned --yes flag was explicitly supplied. */
|
||||
readonly confirmed: boolean;
|
||||
/** Locale selector for runtime-owned command metadata and messages. */
|
||||
readonly localize: (text: LocalizedText) => string;
|
||||
/** 解析后的有效配置面(命令的新读取面;双轨迁移期与 config 并存)。 */
|
||||
settings: Settings;
|
||||
/** 解析源:provider/访问器用;业务命令不可见(窄视图类型不含此字段)。 */
|
||||
@@ -162,5 +168,21 @@ export const versionCheckStage: Middleware = async (ctx, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Safety gate before update/auth/command stages. Telemetry may wrap this stage
|
||||
* so confirmation-required failures remain observable.
|
||||
*/
|
||||
export const confirmationStage: Middleware = async (ctx, next) => {
|
||||
if (ctx.command.risk === undefined || ctx.confirmed || ctx.settings.dryRun) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ConfirmationRequiredError({
|
||||
message: ctx.localize(ctx.command.risk.message),
|
||||
hint: ctx.localize(confirmationHint()),
|
||||
});
|
||||
};
|
||||
|
||||
/** Innermost stage: hand control to the command with its full context. */
|
||||
export const runCommandStage: Middleware = (ctx) => ctx.command.run(ctx);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { camelToKebab } from "./args.ts";
|
||||
import type { Translator } from "./i18n.ts";
|
||||
import { printQuickStart, printWelcomeBanner } from "./output/banner.ts";
|
||||
import { ansi } from "./output/color.ts";
|
||||
import { confirmationFlagDefs } from "./confirm.ts";
|
||||
|
||||
export type { Command, AnyCommand, FlagDef, FlagsDef } from "bailian-cli-core";
|
||||
|
||||
@@ -101,7 +102,11 @@ export class CommandRegistry {
|
||||
|
||||
private register(path: string, command: AnyCommand): void {
|
||||
// 同名守卫:命令自有 flag 不得与全局或其可见凭证域 flag 同名。
|
||||
const reserved = { ...GLOBAL_FLAGS, ...credentialFlagDefs(command) };
|
||||
const reserved = {
|
||||
...GLOBAL_FLAGS,
|
||||
...confirmationFlagDefs(command),
|
||||
...credentialFlagDefs(command),
|
||||
};
|
||||
for (const key of Object.keys(command.flags ?? {})) {
|
||||
if (key in reserved) {
|
||||
throw new Error(`Command "${path}" redeclares reserved flag "${key}".`);
|
||||
@@ -429,6 +434,7 @@ ${authFlagSections ? `${authFlagSections}\n\n` : ""}${b(this.localize(HELP_TEXT.
|
||||
);
|
||||
const flagEntries = [
|
||||
...Object.entries(cmd.flags ?? {}),
|
||||
...Object.entries(confirmationFlagDefs(cmd)),
|
||||
...Object.entries(credentialFlagDefs(cmd)),
|
||||
] as [string, FlagDef][];
|
||||
if (flagEntries.length > 0) {
|
||||
|
||||
@@ -141,6 +141,7 @@ test("loads an API 1 Command Pack and preserves its command contract", async ()
|
||||
expect(Object.keys(commands)).toEqual([
|
||||
"agent credential",
|
||||
"agent credential-denied",
|
||||
"agent dangerous",
|
||||
"agent fail",
|
||||
"agent output",
|
||||
"agent ping",
|
||||
@@ -151,6 +152,14 @@ test("loads an API 1 Command Pack and preserves its command contract", async ()
|
||||
"en-US": "Ping the Command Pack fixture",
|
||||
"zh-CN": "调用 Command Pack 测试命令",
|
||||
});
|
||||
expect(commands["agent ping"]?.risk).toBeUndefined();
|
||||
expect(commands["agent dangerous"]?.risk).toEqual({
|
||||
level: "high",
|
||||
message: {
|
||||
"en-US": "This fixture represents a high-risk operation.",
|
||||
"zh-CN": "该测试命令代表高风险操作。",
|
||||
},
|
||||
});
|
||||
expect(commands["agent ping"]?.flags?.message).toMatchObject({ required: true, type: "string" });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,25 +1,133 @@
|
||||
import { afterEach, describe, expect, test } from "vite-plus/test";
|
||||
import { ExitCode } from "bailian-cli-core";
|
||||
import { confirmDangerousAction } from "../src/confirm.ts";
|
||||
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import { defineCommand, ExitCode, type CommandRisk, type LocalizedText } from "bailian-cli-core";
|
||||
import {
|
||||
ConfirmationRequiredError,
|
||||
confirmDangerousAction,
|
||||
confirmationFlagDefs,
|
||||
} from "../src/confirm.ts";
|
||||
import { confirmationStage, type RunContext } from "../src/middleware.ts";
|
||||
|
||||
const readlineMocks = vi.hoisted(() => {
|
||||
const question = vi.fn(async () => "y");
|
||||
const close = vi.fn();
|
||||
return {
|
||||
question,
|
||||
close,
|
||||
createInterface: vi.fn(() => ({ question, close })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:readline/promises", () => ({ createInterface: readlineMocks.createInterface }));
|
||||
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
|
||||
afterEach(() => {
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("confirmDangerousAction", () => {
|
||||
test("--yes 时直接通过,不触碰 stdin", async () => {
|
||||
await expect(confirmDangerousAction("Delete kb idx-1", true)).resolves.toBeUndefined();
|
||||
const HIGH_RISK_MESSAGE = {
|
||||
"en-US": "This permanently deletes the document and its chunks.",
|
||||
"zh-CN": "该操作会永久删除文档及其 Chunk,且无法撤销。",
|
||||
} satisfies LocalizedText;
|
||||
|
||||
function makeContext(options: {
|
||||
risk?: CommandRisk;
|
||||
confirmed?: boolean;
|
||||
dryRun?: boolean;
|
||||
}): RunContext {
|
||||
const command = defineCommand({
|
||||
description: "Delete a document",
|
||||
auth: "none",
|
||||
risk: options.risk,
|
||||
async run() {},
|
||||
});
|
||||
return {
|
||||
identity: {
|
||||
binName: "bl",
|
||||
version: "0.0.0-test",
|
||||
clientName: "bailian-cli-test",
|
||||
npmPackage: "bailian-cli",
|
||||
},
|
||||
path: ["knowledge", "doc", "delete"],
|
||||
command,
|
||||
flags: {},
|
||||
confirmed: options.confirmed ?? false,
|
||||
localize: (text: LocalizedText) => (typeof text === "string" ? text : text["zh-CN"]),
|
||||
settings: { dryRun: options.dryRun ?? false } as RunContext["settings"],
|
||||
} as unknown as RunContext;
|
||||
}
|
||||
|
||||
describe("confirmation metadata", () => {
|
||||
test("injects --yes only for high-risk commands", () => {
|
||||
expect(
|
||||
confirmationFlagDefs({ risk: { level: "high", message: HIGH_RISK_MESSAGE } }),
|
||||
).toHaveProperty("yes");
|
||||
expect(confirmationFlagDefs({})).toEqual({});
|
||||
});
|
||||
|
||||
test("非 TTY 且无 --yes 时抛 USAGE 并引导 --yes", async () => {
|
||||
test("serializes the stable Agent-readable confirmation contract", () => {
|
||||
const error = new ConfirmationRequiredError({
|
||||
message: HIGH_RISK_MESSAGE["zh-CN"],
|
||||
hint: "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。",
|
||||
});
|
||||
|
||||
expect(error.exitCode).toBe(ExitCode.CONFIRMATION_REQUIRED);
|
||||
expect(error.toJSON()).toEqual({
|
||||
error: {
|
||||
code: 7,
|
||||
type: "requires_confirmation",
|
||||
message: HIGH_RISK_MESSAGE["zh-CN"],
|
||||
hint: "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("legacy commands fail closed without opening a TTY prompt", async () => {
|
||||
process.stdin.isTTY = false;
|
||||
try {
|
||||
await confirmDangerousAction("Delete kb idx-1", false);
|
||||
expect.unreachable("should throw");
|
||||
} catch (error) {
|
||||
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.USAGE);
|
||||
expect((error as { hint?: string }).hint).toMatch(/--yes/);
|
||||
}
|
||||
await expect(confirmDangerousAction("legacy summary", false)).rejects.toMatchObject({
|
||||
exitCode: ExitCode.USAGE,
|
||||
});
|
||||
await expect(confirmDangerousAction("legacy summary", true)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("legacy commands retain their existing TTY confirmation during migration", async () => {
|
||||
process.stdin.isTTY = true;
|
||||
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
await expect(confirmDangerousAction("legacy summary", false)).resolves.toBeUndefined();
|
||||
|
||||
expect(stderrWrite).toHaveBeenCalledWith("legacy summary\n");
|
||||
expect(readlineMocks.createInterface).toHaveBeenCalledOnce();
|
||||
expect(readlineMocks.question).toHaveBeenCalledWith("Proceed? [y/N] ");
|
||||
expect(readlineMocks.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmationStage", () => {
|
||||
test("blocks high-risk execution without echoing the original command", async () => {
|
||||
const next = vi.fn(async () => {});
|
||||
|
||||
const promise = confirmationStage(
|
||||
makeContext({ risk: { level: "high", message: HIGH_RISK_MESSAGE } }),
|
||||
next,
|
||||
);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
exitCode: 7,
|
||||
message: HIGH_RISK_MESSAGE["zh-CN"],
|
||||
hint: "此命令将执行高风险操作。如确认继续,请在原命令中添加 --yes 后重新执行。",
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["explicit --yes", { risk: { level: "high", message: HIGH_RISK_MESSAGE }, confirmed: true }],
|
||||
["dry-run", { risk: { level: "high", message: HIGH_RISK_MESSAGE }, dryRun: true }],
|
||||
["normal command", {}],
|
||||
] as const)("passes %s through", async (_label, options) => {
|
||||
const next = vi.fn(async () => {});
|
||||
await confirmationStage(makeContext(options), next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ExitCode } from "bailian-cli-core";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { handleError } from "../src/error-handler.ts";
|
||||
import { ConfirmationRequiredError } from "../src/confirm.ts";
|
||||
|
||||
test("handleError: fetch failed JSON includes cause.code from errno", () => {
|
||||
const previousOutput = process.env.DASHSCOPE_OUTPUT;
|
||||
@@ -8,7 +9,7 @@ test("handleError: fetch failed JSON includes cause.code from errno", () => {
|
||||
|
||||
let stderr = "";
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
const originalExit = process.exit;
|
||||
const originalExit = process.exit.bind(process);
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
@@ -52,7 +53,7 @@ test("handleError: fetch failed without nested cause still maps to NETWORK", ()
|
||||
|
||||
let stderr = "";
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
const originalExit = process.exit;
|
||||
const originalExit = process.exit.bind(process);
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
@@ -83,3 +84,42 @@ test("handleError: fetch failed without nested cause still maps to NETWORK", ()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("handleError: confirmation text uses the standard message and hint layout", () => {
|
||||
const previousOutput = process.env.DASHSCOPE_OUTPUT;
|
||||
delete process.env.DASHSCOPE_OUTPUT;
|
||||
|
||||
let stderr = "";
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
const originalExit = process.exit.bind(process);
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
}) as typeof process.exit;
|
||||
|
||||
const confirmation = new ConfirmationRequiredError({
|
||||
message: "This permanently deletes the selected documents and all of their chunks.",
|
||||
hint: "This command performs a high-risk operation. To continue, add --yes to the original command and re-run it.",
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => handleError(confirmation, "bl")).toThrow(
|
||||
new RegExp(`process\\.exit:${ExitCode.CONFIRMATION_REQUIRED}`),
|
||||
);
|
||||
expect(stderr).toContain(
|
||||
"This command performs a high-risk operation. To continue, add --yes to the original command and re-run it.",
|
||||
);
|
||||
expect(stderr).not.toContain("bl knowledge doc delete");
|
||||
expect(stderr).not.toContain("Risk:");
|
||||
expect(stderr).not.toContain("Action:");
|
||||
expect(stderr).not.toContain("Note:");
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
process.exit = originalExit;
|
||||
if (previousOutput === undefined) delete process.env.DASHSCOPE_OUTPUT;
|
||||
else process.env.DASHSCOPE_OUTPUT = previousOutput;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import * as runtimeApi from "../src/index.ts";
|
||||
|
||||
test("confirmation orchestration stays internal to createCli", () => {
|
||||
expect(runtimeApi).toHaveProperty("confirmDangerousAction");
|
||||
expect(runtimeApi).not.toHaveProperty("confirmationStage");
|
||||
expect(runtimeApi).not.toHaveProperty("CONFIRMATION_FLAGS");
|
||||
expect(runtimeApi).not.toHaveProperty("confirmationFlagDefs");
|
||||
expect(runtimeApi).not.toHaveProperty("ConfirmationRequiredError");
|
||||
expect(runtimeApi.CommandRegistry.prototype).not.toHaveProperty("localizeText");
|
||||
});
|
||||
@@ -33,3 +33,49 @@ test("命令重声明其可见域的凭证 flag → 抛错;不可见域的同名
|
||||
});
|
||||
expect(() => new CommandRegistry({ "x y": modelCmd }, "bl")).not.toThrow();
|
||||
});
|
||||
|
||||
test("high risk 命令不能自行声明 runtime 保留的 yes", () => {
|
||||
const high = defineCommand({
|
||||
description: "test",
|
||||
auth: "none",
|
||||
risk: { level: "high", message: "dangerous operation" },
|
||||
flags: { yes: { type: "switch", description: "duplicate" } },
|
||||
run: noopRun,
|
||||
});
|
||||
const normal = defineCommand({
|
||||
description: "test",
|
||||
auth: "none",
|
||||
flags: { yes: { type: "switch", description: "command-owned" } },
|
||||
run: noopRun,
|
||||
});
|
||||
|
||||
expect(() => new CommandRegistry({ "x high": high }, "bl")).toThrow(/yes/);
|
||||
expect(() => new CommandRegistry({ "x normal": normal }, "bl")).not.toThrow();
|
||||
});
|
||||
|
||||
test("命令 help 只为 high risk 展示 runtime 注入的 --yes", () => {
|
||||
const high = defineCommand({
|
||||
description: "danger",
|
||||
auth: "none",
|
||||
risk: { level: "high", message: "dangerous operation" },
|
||||
run: noopRun,
|
||||
});
|
||||
const normal = defineCommand({
|
||||
description: "safe",
|
||||
auth: "none",
|
||||
run: noopRun,
|
||||
});
|
||||
const registry = new CommandRegistry({ "asset delete": high, "asset list": normal }, "bl");
|
||||
|
||||
let highHelp = "";
|
||||
let normalHelp = "";
|
||||
registry.printHelp(["asset", "delete"], {
|
||||
write: (chunk: string) => (highHelp += chunk),
|
||||
} as unknown as NodeJS.WriteStream);
|
||||
registry.printHelp(["asset", "list"], {
|
||||
write: (chunk: string) => (normalHelp += chunk),
|
||||
} as unknown as NodeJS.WriteStream);
|
||||
|
||||
expect(highHelp).toContain("--yes");
|
||||
expect(normalHelp).not.toContain("--yes");
|
||||
});
|
||||
|
||||
@@ -508,8 +508,8 @@ bl knowledge delete --index-id idx-xxx --yes
|
||||
| --------------------- | ------ | -------- | --------------------------------------------------------------- |
|
||||
| `--index-id <id>` | string | yes | Knowledge base ID |
|
||||
| `--doc-id <id>` | array | yes | Document ID to delete (repeatable) |
|
||||
| `--yes` | switch | no | Skip the confirmation prompt |
|
||||
| `--workspace-id <id>` | string | no | Workspace ID for API endpoint URL (or set BAILIAN_WORKSPACE_ID) |
|
||||
| `--yes` | switch | no | Confirm this high-risk operation |
|
||||
| `--api-key <key>` | string | no | API key |
|
||||
| `--base-url <url>` | string | no | API base URL |
|
||||
|
||||
@@ -523,7 +523,7 @@ bl knowledge delete --index-id idx-xxx --yes
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl knowledge doc delete --index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx
|
||||
bl knowledge doc delete --index-id idx-xxx --doc-id file-xxx --workspace-id ws-xxx --dry-run
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type LocalizedText,
|
||||
} from "../packages/core/src/index.ts";
|
||||
import { commands } from "../packages/cli/src/commands.ts";
|
||||
import { confirmationFlagDefs } from "../packages/runtime/src/confirm.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SKILLS_DIR = join(__dirname, "../skills");
|
||||
@@ -160,7 +161,13 @@ function commandSection(path: string, cmd: AnyCommand): string {
|
||||
|
||||
// 与命令 help 的 Flags 区一致:自有 + 该命令可见的凭证域 flag。
|
||||
lines.push("#### Flags", "");
|
||||
lines.push(formatFlagsTable({ ...cmd.flags, ...credentialFlagDefs(cmd) }));
|
||||
lines.push(
|
||||
formatFlagsTable({
|
||||
...cmd.flags,
|
||||
...confirmationFlagDefs(cmd),
|
||||
...credentialFlagDefs(cmd),
|
||||
}),
|
||||
);
|
||||
|
||||
if (cmd.notes?.length) {
|
||||
lines.push("#### Notes", "");
|
||||
|
||||
Reference in New Issue
Block a user