refactor(commands): route all exits through the central error handler

Commands no longer call process.exit() directly. Every failure now throws
UsageError (bad input → exit 2) or BailianError (runtime failure, with
AUTH/TIMEOUT codes), so the runtime's handleError stays the single exit
point and telemetry always flushes.

- convert 27 process.exit() sites across 15 commands to throws
- move cross-flag/value checks into validate(); use flag `choices` for
  --events / --sort; drop dead --model required check
- keep pipeline's process.exitCode for lint-style soft failures
- enforce with unicorn/no-process-exit, allowed only in runtime/tools/tests
- fix incidental lint: void floating run(), narrow console errorCode,
  align generate-reference type imports to source
This commit is contained in:
若麒
2026-06-29 09:07:12 +08:00
parent 2f9558c161
commit 1f56feab24
24 changed files with 116 additions and 118 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ import { createCli } from "bailian-cli-runtime";
import { commands } from "./commands.ts";
import pkg from "../package.json" with { type: "json" };
createCli(commands, {
void createCli(commands, {
binName: "bl",
version: pkg.version,
clientName: "bailian-cli",
+1 -1
View File
@@ -245,6 +245,6 @@ describe("e2e: pipeline", () => {
]);
expect(exitCode).toBe(2);
expect(stdout).toBe("");
expect(stderr).toMatch(/unsupported --events format: bogus/i);
expect(stderr).toMatch(/--events must be one of: jsonl/i);
});
});
+2 -2
View File
@@ -53,7 +53,7 @@ describe("e2e: quota", () => {
test("quota check --period 0 报错最小值", async () => {
const { stderr, exitCode } = await runCli(["quota", "check", "--period", "0.5"]);
expect(exitCode).toBe(1);
expect(exitCode).toBe(2);
expect(stderr).toContain("at least 1 minute");
});
});
@@ -184,7 +184,7 @@ describe.skipIf(!isConsoleE2EReady())("e2e: quota(Console)", () => {
"--tpm",
"999",
]);
expect(exitCode).toBe(1);
expect(exitCode).toBe(2);
expect(stderr).toContain("out of range");
expect(stderr).toContain("Current");
expect(stderr).toContain("Range");
+2 -2
View File
@@ -1,5 +1,6 @@
import {
defineCommand,
UsageError,
appCompletionPath,
parseSSE,
detectOutputFormat,
@@ -114,8 +115,7 @@ export default defineCommand({
try {
body.input.biz_params = JSON.parse(flags.bizParams);
} catch {
process.stderr.write("Error: --biz-params must be valid JSON\n");
process.exit(1);
throw new UsageError("--biz-params must be valid JSON");
}
}
@@ -1,4 +1,9 @@
import { defineCommand, effectiveConsoleGatewayConfig, detectOutputFormat } from "bailian-cli-core";
import {
defineCommand,
UsageError,
effectiveConsoleGatewayConfig,
detectOutputFormat,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
@@ -39,8 +44,7 @@ export default defineCommand({
try {
data = JSON.parse(dataRaw) as Record<string, unknown>;
} catch {
process.stderr.write("Error: --data must be valid JSON\n");
process.exit(1);
throw new UsageError("--data must be valid JSON");
}
const format = detectOutputFormat(config.output);
+17 -15
View File
@@ -1,4 +1,10 @@
import { defineCommand, bailianMcpPath, detectOutputFormat } from "bailian-cli-core";
import {
defineCommand,
UsageError,
BailianError,
bailianMcpPath,
detectOutputFormat,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
function parseArgFlags(raw: string[]): Record<string, unknown> {
@@ -6,8 +12,7 @@ function parseArgFlags(raw: string[]): Record<string, unknown> {
for (const item of raw) {
const idx = item.indexOf("=");
if (idx <= 0) {
process.stderr.write(`Error: --arg must be in K=V form, got: ${item}\n`);
process.exit(1);
throw new UsageError(`--arg must be in K=V form, got: ${item}`);
}
const key = item.slice(0, idx).trim();
const rawVal = item.slice(idx + 1);
@@ -64,25 +69,23 @@ export default defineCommand({
const dot = target.indexOf(".");
if (dot <= 0 || dot === target.length - 1) {
process.stderr.write(`Error: target must be <server-code>.<tool>, got "${target}".\n`);
process.exit(1);
throw new UsageError(`target must be <server-code>.<tool>, got "${target}".`);
}
const serverCode = target.slice(0, dot);
const toolName = target.slice(dot + 1);
let toolArgs: Record<string, unknown> = {};
if (flags.json) {
let parsed: unknown;
try {
const parsed = JSON.parse(flags.json);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
process.stderr.write("Error: --json must decode to an object.\n");
process.exit(1);
}
toolArgs = parsed as Record<string, unknown>;
parsed = JSON.parse(flags.json);
} catch (err) {
process.stderr.write(`Error: --json is not valid JSON — ${(err as Error).message}\n`);
process.exit(1);
throw new UsageError(`--json is not valid JSON — ${(err as Error).message}`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new UsageError("--json must decode to an object.");
}
toolArgs = parsed as Record<string, unknown>;
}
Object.assign(toolArgs, parseArgFlags(flags.arg ?? []));
if (flags.query !== undefined) toolArgs.query = flags.query;
@@ -109,8 +112,7 @@ export default defineCommand({
if (result.isError) {
const errText = result.content.map((c) => c.text || "").join("\n");
process.stderr.write(`Tool error: ${errText}\n`);
process.exit(1);
throw new BailianError(`Tool error: ${errText}`);
}
emitResult(result, format);
+2 -2
View File
@@ -1,5 +1,6 @@
import {
defineCommand,
UsageError,
memoryAddPath,
detectOutputFormat,
type FlagsDef,
@@ -52,8 +53,7 @@ export default defineCommand({
try {
body.messages = JSON.parse(flags.messages);
} catch {
process.stderr.write("Error: --messages must be valid JSON array\n");
process.exit(1);
throw new UsageError("--messages must be valid JSON array");
}
}
@@ -1,5 +1,6 @@
import {
defineCommand,
UsageError,
profileSchemaPath,
detectOutputFormat,
type ProfileSchemaCreateRequest,
@@ -38,8 +39,7 @@ export default defineCommand({
try {
attributes = JSON.parse(attrStr);
} catch {
process.stderr.write("Error: --attributes must be valid JSON array\n");
process.exit(1);
throw new UsageError("--attributes must be valid JSON array");
}
const body: ProfileSchemaCreateRequest = { name, attributes };
@@ -1,5 +1,6 @@
import {
defineCommand,
UsageError,
memorySearchPath,
detectOutputFormat,
type FlagsDef,
@@ -49,8 +50,7 @@ export default defineCommand({
try {
body.messages = JSON.parse(flags.messages);
} catch {
process.stderr.write("Error: --messages must be valid JSON array\n");
process.exit(1);
throw new UsageError("--messages must be valid JSON array");
}
}
@@ -1,11 +1,11 @@
import { readFile } from "node:fs/promises";
import { extname } from "node:path";
import { UsageError } from "bailian-cli-core";
import type { PipelineDefinition } from "bailian-cli-runtime";
export async function loadPipelineFile(filePath: string): Promise<PipelineDefinition> {
const raw = await readFile(filePath, "utf-8").catch((err: Error) => {
process.stderr.write(`Error: cannot read pipeline file: ${err.message}\n`);
process.exit(2);
throw new UsageError(`cannot read pipeline file: ${err.message}`);
});
const ext = extname(filePath).toLowerCase();
let parsed: unknown;
+7 -11
View File
@@ -25,7 +25,12 @@ const RUN_FLAGS = {
valueHint: "<n>",
description: "Max parallel steps (default: 1)",
},
events: { type: "string", valueHint: "<format>", description: "Emit lifecycle events: jsonl" },
events: {
type: "string",
valueHint: "<format>",
description: "Emit lifecycle events: jsonl",
choices: ["jsonl"] as const,
},
timeout: {
type: "number",
valueHint: "<seconds>",
@@ -46,6 +51,7 @@ export default defineCommand({
"--file workflow.json --events jsonl",
"--file workflow.yaml --output json",
],
validate: (f) => (f.input && f.inputFile ? "use --input or --input-file, not both" : undefined),
async run(ctx) {
const { config, flags } = ctx;
const file = flags.file;
@@ -53,12 +59,6 @@ export default defineCommand({
initPipelineSteps();
const eventsFormat = flags.events;
if (eventsFormat !== undefined && eventsFormat !== "jsonl") {
process.stderr.write(
`Error: unsupported --events format: ${eventsFormat}. Supported: jsonl\n`,
);
process.exit(2);
}
const filePath = resolve(file);
const pipeline = await loadPipelineFile(filePath);
@@ -98,10 +98,6 @@ export default defineCommand({
async function resolveRuntimeInput(flags: RunFlags): Promise<Record<string, unknown>> {
const inputJson = flags.input;
const inputFile = flags.inputFile;
if (inputJson && inputFile) {
process.stderr.write("Error: use --input or --input-file, not both\n");
process.exit(2);
}
if (inputJson) return JSON.parse(inputJson) as Record<string, unknown>;
if (inputFile) {
const raw = await readFile(resolve(inputFile), "utf-8");
@@ -254,15 +254,12 @@ export default defineCommand({
"--model qwen3.6-plus,qwen-turbo",
"--output json",
],
validate: (f) =>
(Number(f.period) || 2) < 1 ? "--period must be at least 1 minute." : undefined,
async run(ctx) {
const { config, flags } = ctx;
const modelFlag = flags.model || undefined;
const rawPeriod = Number(flags.period) || 2;
if (rawPeriod < 1) {
process.stderr.write("Error: --period must be at least 1 minute.\n");
process.exit(1);
}
const windowMinutes = rawPeriod;
const windowMinutes = Number(flags.period) || 2;
const format = detectOutputFormat(config.output);
if (config.dryRun) {
@@ -1,4 +1,4 @@
import { defineCommand, detectOutputFormat, BailianError } from "bailian-cli-core";
import { defineCommand, detectOutputFormat, BailianError, ExitCode } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
@@ -132,10 +132,11 @@ export default defineCommand({
result = await ctx.client.console(HISTORY_API, requestData);
} catch (err) {
if (err instanceof BailianError && err.message.includes("NotLogined")) {
process.stderr.write(
`Error: session expired. Run \`${config.binName} auth login --console\` to re-authenticate.\n`,
throw new BailianError(
"session expired.",
ExitCode.AUTH,
`Run \`${config.binName} auth login --console\` to re-authenticate.`,
);
process.exit(1);
}
throw err;
}
+2 -3
View File
@@ -1,4 +1,4 @@
import { defineCommand, detectOutputFormat, type Client } from "bailian-cli-core";
import { defineCommand, BailianError, detectOutputFormat, type Client } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
@@ -197,8 +197,7 @@ export default defineCommand({
);
models = models.filter((m) => names.has(m.model));
if (models.length === 0) {
process.stderr.write(`Error: no matching models found for "${modelFlag}".\n`);
process.exit(1);
throw new BailianError(`no matching models found for "${modelFlag}".`);
}
}
+25 -31
View File
@@ -1,4 +1,11 @@
import { defineCommand, detectOutputFormat, BailianError, type Client } from "bailian-cli-core";
import {
defineCommand,
UsageError,
BailianError,
ExitCode,
detectOutputFormat,
type Client,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
const MODEL_LIST_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
@@ -96,20 +103,11 @@ export default defineCommand({
"--model qwen3.6-plus --tpm 8000000 --yes",
"--model qwen-turbo --tpm 100000 --output json",
],
validate: (f) => (Number(f.tpm) > 0 ? undefined : "--tpm must be a positive number."),
async run(ctx) {
const { config, flags } = ctx;
const modelName = flags.model;
if (!modelName) {
process.stderr.write("Error: --model is required.\n");
process.exit(1);
}
const tpmValue = Number(flags.tpm);
if (!tpmValue || tpmValue <= 0) {
process.stderr.write("Error: --tpm must be a positive number.\n");
process.exit(1);
}
const autoConfirm = Boolean(flags.yes) || config.yes;
const format = detectOutputFormat(config.output);
@@ -126,13 +124,11 @@ export default defineCommand({
const modelInfo = await fetchModelQpmInfo(ctx.client, modelName);
if (!modelInfo) {
process.stderr.write(
`Error: model "${modelName}" not found or does not support self-service quota increase.\n`,
throw new BailianError(
`model "${modelName}" not found or does not support self-service quota increase.`,
ExitCode.GENERAL,
`Run \`${config.binName} quota list\` to view available models.`,
);
process.stderr.write(
`Hint: run \`${config.binName} quota list\` to view available models.\n`,
);
process.exit(1);
}
const modelDefault = modelInfo.qpmInfo["model-default"];
@@ -142,12 +138,10 @@ export default defineCommand({
const maxLimit = minLimit * 2;
if (tpmValue < minLimit || tpmValue > maxLimit) {
process.stderr.write(
`Error: TPM value ${tpmValue.toLocaleString()} is out of range.\n` +
` Current: ${currentLimit.toLocaleString()}\n` +
` Range: ${minLimit.toLocaleString()} ~ ${maxLimit.toLocaleString()}\n`,
throw new UsageError(
`TPM value ${tpmValue.toLocaleString()} is out of range. ` +
`Current: ${currentLimit.toLocaleString()}, Range: ${minLimit.toLocaleString()} ~ ${maxLimit.toLocaleString()}.`,
);
process.exit(1);
}
const requestData = {
@@ -166,10 +160,11 @@ export default defineCommand({
return await ctx.client.console(UPDATE_LIMITS_API, requestData);
} catch (err) {
if (err instanceof BailianError && err.message.includes("NotLogined")) {
process.stderr.write(
`Error: session expired. Run \`${config.binName} auth login --console\` to re-authenticate.\n`,
throw new BailianError(
"session expired.",
ExitCode.AUTH,
`Run \`${config.binName} auth login --console\` to re-authenticate.`,
);
process.exit(1);
}
throw err;
}
@@ -182,17 +177,16 @@ export default defineCommand({
const confirmCode = resp.confirmCode as string;
if (confirmCode === "Refresh_Required") {
process.stderr.write("Error: rate limit has been updated externally. Please retry.\n");
process.exit(1);
throw new BailianError("rate limit has been updated externally. Please retry.");
}
if (confirmCode === "Downgrade") {
if (!autoConfirm) {
process.stderr.write(
`Warning: target TPM (${tpmValue.toLocaleString()}) is lower than current (${currentLimit.toLocaleString()}).\n` +
"Use --yes to confirm downgrade.\n",
throw new BailianError(
`target TPM (${tpmValue.toLocaleString()}) is lower than current (${currentLimit.toLocaleString()}).`,
ExitCode.GENERAL,
"Use --yes to confirm downgrade.",
);
process.exit(1);
}
result = await submitRequest(true);
}
+4 -4
View File
@@ -1,5 +1,6 @@
import {
defineCommand,
BailianError,
detectOutputFormat,
mcpWebSearchPath,
type FlagsDef,
@@ -85,15 +86,14 @@ export default defineCommand({
// Call the search tool
const result = await client.callTool("bailian_web_search", toolArgs);
if (!config.quiet) spinner.stop("Done.");
// Handle error response
if (result.isError) {
const errText = result.content.map((c) => c.text || "").join("\n");
process.stderr.write(`Search error: ${errText}\n`);
process.exit(1);
throw new BailianError(`Search error: ${errText}`);
}
if (!config.quiet) spinner.stop("Done.");
// Output results — always structured to stdout
if (format === "json") {
emitResult(result, format);
+1 -7
View File
@@ -197,6 +197,7 @@ export default defineCommand({
type: "string",
valueHint: "<field>",
description: "Sort by: remaining (ascending), expires (ascending)",
choices: ["remaining", "expires"] as const,
},
consoleRegion: { type: "string", valueHint: "<region>", description: "Console region" },
consoleSite: {
@@ -219,14 +220,7 @@ export default defineCommand({
const { config, flags } = ctx;
const modelFlag = flags.model || undefined;
const expiringDays = Number(flags.expiring) || 0;
const VALID_SORT_FIELDS = ["remaining", "expires"] as const;
const sortField = flags.sort || undefined;
if (sortField && !VALID_SORT_FIELDS.includes(sortField as (typeof VALID_SORT_FIELDS)[number])) {
process.stderr.write(
`Error: invalid --sort value "${sortField}". Must be one of: ${VALID_SORT_FIELDS.join(", ")}\n`,
);
process.exit(1);
}
const format = detectOutputFormat(config.output);
let models: string[];
+13 -9
View File
@@ -1,4 +1,11 @@
import { defineCommand, detectOutputFormat, type Config, type Client } from "bailian-cli-core";
import {
defineCommand,
BailianError,
ExitCode,
detectOutputFormat,
type Config,
type Client,
} from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
import { displayWidth, padEnd } from "bailian-cli-runtime";
@@ -89,13 +96,11 @@ function resolveWorkspaceId(config: Config, flagWorkspaceId?: string): string {
if (flagWorkspaceId) return flagWorkspaceId;
if (config.workspaceId) return config.workspaceId;
process.stderr.write(
`Error: workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${config.binName} config set workspace_id <id>\`.\n`,
throw new BailianError(
`workspace-id is required. Set via --workspace-id, BAILIAN_WORKSPACE_ID, or \`${config.binName} config set workspace_id <id>\`.`,
ExitCode.GENERAL,
`Run \`${config.binName} workspace list\` to view available workspaces.`,
);
process.stderr.write(
`Hint: run \`${config.binName} workspace list\` to view available workspaces.\n`,
);
process.exit(1);
}
function formatNumber(num: number): string {
@@ -408,8 +413,7 @@ export default defineCommand({
const result = await pollTelemetryApi(ctx.client, OVERVIEW_API, reqDTO);
if (!result) {
process.stderr.write("Error: request timed out.\n");
process.exit(1);
throw new BailianError("Request timed out.", ExitCode.TIMEOUT);
}
const stat = extractOverviewData(result);
+1 -1
View File
@@ -142,7 +142,7 @@ export async function callConsoleGateway(
const innerData = json.data as Record<string, unknown> | undefined;
if (innerData?.success === false && innerData.errorCode) {
const errorCode = String(innerData.errorCode);
const errorCode = String(innerData.errorCode as string | number);
const notLogined = errorCode.includes("NotLogined");
const errorMsg = typeof innerData.errorMsg === "string" ? innerData.errorMsg : undefined;
throw new BailianError(
+1 -1
View File
@@ -42,7 +42,7 @@ const commands: Record<string, AnyCommand> = {
retrieve: knowledgeRetrieve,
};
createCli(commands, {
void createCli(commands, {
binName: "rag",
version: pkg.version,
clientName: "rag-cli",
+1 -1
View File
@@ -30,7 +30,7 @@ Index: [index.md](index.md)
| `--input <json>` | string | no | Runtime input as inline JSON |
| `--input-file <path>` | string | no | Runtime input from a JSON file |
| `--concurrency <n>` | number | no | Max parallel steps (default: 1) |
| `--events <format>` | string | no | Emit lifecycle events: jsonl |
| `--events <jsonl>` | string | no | Emit lifecycle events: jsonl |
| `--timeout <seconds>` | number | no | Default step timeout in seconds |
#### Examples
+1 -1
View File
@@ -29,7 +29,7 @@ Index: [index.md](index.md)
| ------------------------------ | ------ | -------- | ------------------------------------------------------------------------- |
| `--model <model>` | string | no | Model name(s) to query, comma-separated for multiple; omit for all models |
| `--expiring <days>` | string | no | Only show quotas expiring within N days |
| `--sort <field>` | string | no | Sort by: remaining (ascending), expires (ascending) |
| `--sort <remaining\|expires>` | string | no | Sort by: remaining (ascending), expires (ascending) |
| `--console-region <region>` | string | no | Console region |
| `--console-site <site>` | string | no | Console site: domestic, international |
| `--console-switch-agent <uid>` | number | no | Switch agent UID |
+2 -6
View File
@@ -11,12 +11,8 @@
import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
GLOBAL_FLAGS,
type AnyCommand,
type FlagDef,
type FlagsDef,
} from "../packages/core/dist/index.mjs";
import { GLOBAL_FLAGS } from "../packages/core/dist/index.mjs";
import type { AnyCommand, FlagDef, FlagsDef } from "../packages/core/src/index.ts";
import { commands } from "../packages/cli/src/commands.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
+12 -1
View File
@@ -9,7 +9,18 @@ export default defineConfig({
staged: {
"*.{js,mjs,cjs,ts,mts,cts,jsx,tsx,json,yaml,yml,md}": "vp check --fix",
},
lint: { options: { typeAware: true, typeCheck: true } },
lint: {
options: { typeAware: true, typeCheck: true },
// 命令只许抛错;进程退出统一收口到 runtime 的 handleError。
rules: { "unicorn/no-process-exit": "error" },
overrides: [
{
// runtime 是统一出口层;tools/ 与测试是独立脚本入口,可直接退出。
files: ["packages/runtime/src/**", "tools/**", "**/tests/**"],
rules: { "unicorn/no-process-exit": "off" },
},
],
},
run: {
cache: true,
},