refactor(runtime): unify validation into UsageError; bare command shows help

- drop IncompleteCommandError: missing-required, failed validate, and bad/unknown
  flags are all UsageError now
- error boundary keys on bareness — bare command that fails → help (exit 0);
  non-bare invalid → error + message (exit 2)
- login: drop config.apiKey fallback, --api-key required-unless-console via validate
- speech: drop empty --text-file guard (empty content is the API's concern)
This commit is contained in:
若麒
2026-06-27 10:56:28 +08:00
parent 91e6c6f553
commit cbd3c1232c
9 changed files with 31 additions and 72 deletions
+2 -5
View File
@@ -5,7 +5,6 @@ import {
type Config,
type GlobalFlags,
} from "bailian-cli-core";
import { IncompleteCommandError } from "bailian-cli-core";
import { printQuickStart } from "bailian-cli-runtime";
import { emitBare } from "bailian-cli-runtime";
import {
@@ -31,6 +30,7 @@ export default defineCommand({
},
],
exampleArgs: ["--api-key sk-xxxxx", "--console"],
validate: (f) => (!f.console && !f.apiKey ? "Provide --api-key or --console" : undefined),
async run(config: Config, flags: GlobalFlags) {
if (flags.console) {
if (config.dryRun) {
@@ -51,10 +51,7 @@ export default defineCommand({
process.stderr.write(`Warning: DASHSCOPE_API_KEY is already set in environment.\n`);
}
const key = (flags.apiKey as string) || config.apiKey;
if (!key) {
throw new IncompleteCommandError("Missing required argument: --api-key");
}
const key = flags.apiKey as string;
const baseUrl = (flags.baseUrl as string) || undefined;
const effectiveConfig = baseUrl ? { ...config, baseUrl } : config;
@@ -14,7 +14,6 @@ import {
type OutputFormat,
speechSynthesizeEndpoint,
parseSSE,
IncompleteCommandError,
resolveOutputDir,
request,
DOCS_HOSTS,
@@ -207,9 +206,8 @@ export default defineCommand({
return;
}
let text = flags.text as string | undefined;
// --text-file takes precedence if provided and --text is empty
// --text / --text-file presence enforced by validate; empty file content → API rejects.
let text = (flags.text as string) || "";
if (!text && flags.textFile) {
const filePath = flags.textFile as string;
try {
@@ -218,10 +216,6 @@ export default defineCommand({
throw new BailianError(`Cannot read text file: ${filePath}`, ExitCode.USAGE);
}
}
if (!text) {
throw new IncompleteCommandError("Provide --text or --text-file.");
}
const voice = flags.voice as string;
const language = (flags.language as string) || undefined;
+1 -21
View File
@@ -45,13 +45,7 @@ export class BailianError extends Error {
}
}
/**
* The user invoked the CLI in a structurally invalid way: unknown command path,
* unknown/badly-typed flag, or a missing required argument. Always carries
* {@link ExitCode.USAGE}. The runtime's error boundary recognises this type and
* renders the relevant command's help before printing the message — so command
* code never builds usage strings or prints help itself; it just throws this.
*/
/** Invalid usage: unknown command, bad/unknown flag, missing required, failed validation. */
export class UsageError extends BailianError {
constructor(message: string, hint?: string) {
super(message, ExitCode.USAGE, hint);
@@ -59,20 +53,6 @@ export class UsageError extends BailianError {
}
}
/**
* The command is *incomplete* — a valid prefix that stopped short (a missing
* required flag / positional). Distinct from {@link UsageError} ("you typed
* something wrong"): an incomplete command is not an error. The runtime's error
* boundary renders that command's help and exits 0 — exactly like landing on a
* command group with no subcommand. Carries {@link ExitCode.SUCCESS}.
*/
export class IncompleteCommandError extends BailianError {
constructor(message: string) {
super(message, ExitCode.SUCCESS);
this.name = "IncompleteCommandError";
}
}
function serializeCause(cause: unknown): Record<string, unknown> | undefined {
if (cause == null) return undefined;
if (cause instanceof Error) {
+1 -1
View File
@@ -1,4 +1,4 @@
export { BailianError, UsageError, IncompleteCommandError } from "./errors/base.ts";
export { BailianError, UsageError } from "./errors/base.ts";
export { mapApiError, type ApiErrorBody } from "./errors/api.ts";
export { ExitCode } from "./errors/codes.ts";
+4 -6
View File
@@ -47,12 +47,10 @@ export interface Command {
auth: AuthRequirement;
notes?: string[];
/**
* Cross-flag validation, run after parsing and before execute. Return an
* error message when the flag combination is incomplete (one-of, 3-of-N,
* value-conditional, dependency, …); the runtime throws IncompleteCommandError
* and renders this command's help. Return undefined to pass. Single-flag
* `required: true` is enforced by the parser — use this only for rules that
* span multiple flags or depend on a flag's *value*.
* Cross-flag validation, run after parsing and before execute (one-of, 3-of-N,
* value-conditional, dependency, …). Return an error message → UsageError;
* undefined to pass. Single-flag `required: true` is enforced by the parser —
* use this only for rules spanning multiple flags or depending on a flag's *value*.
*/
validate?: (flags: GlobalFlags) => string | undefined;
execute: (config: Config, flags: GlobalFlags) => Promise<void>;
+4 -6
View File
@@ -1,6 +1,6 @@
import type { GlobalFlags } from "bailian-cli-core";
import type { OptionDef } from "bailian-cli-core";
import { UsageError, IncompleteCommandError } from "bailian-cli-core";
import { UsageError } from "bailian-cli-core";
function kebabToCamel(str: string): string {
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
@@ -92,10 +92,8 @@ export function parsePath(argv: string[]): ParsePathResult {
/**
* Second pass — parse the flag region into typed values, driven entirely by the
* OptionDef schema. Pure: returns typed flags or throws — never prints/exits.
* The runtime's error boundary decides rendering. Throws IncompleteCommandError
* for missing required flags (incomplete → help) and UsageError for malformed
* input (unknown/short flag, bad value, unexpected token, duplicate).
* OptionDef schema. Pure: returns typed flags or throws UsageError — never
* prints/exits. The runtime's error boundary decides rendering.
*/
export function parseFlags(rest: string[], options: OptionDef[]): GlobalFlags {
const allowedKeys = buildAllowedFlagKeys(options);
@@ -189,7 +187,7 @@ export function parseFlags(rest: string[], options: OptionDef[]): GlobalFlags {
});
if (missing.length > 0) {
const names = missing.map((opt) => opt.flag.match(/^(--[a-z][a-z0-9-]*)/i)?.[1] ?? opt.flag);
throw new IncompleteCommandError(
throw new UsageError(
`Missing required ${names.length > 1 ? "flags" : "flag"}: ${names.join(", ")}`,
);
}
+12 -15
View File
@@ -10,12 +10,7 @@ import {
type RunContext,
} from "./middleware.ts";
import type { Command, Config, GlobalFlags } from "bailian-cli-core";
import {
GLOBAL_OPTIONS,
IncompleteCommandError,
loadConfig,
flushTelemetry,
} from "bailian-cli-core";
import { GLOBAL_OPTIONS, UsageError, loadConfig, flushTelemetry } from "bailian-cli-core";
import { setupProxyFromEnv } from "./proxy.ts";
import { handleError } from "./error-handler.ts";
import { printWelcomeBanner, printQuickStart } from "./output/banner.ts";
@@ -37,12 +32,9 @@ export interface Cli {
}
/**
* Build a CLI from an injected command set. The kernel is agnostic to *which*
* commands exist — each product (bailian-cli, rag-cli, …) passes its own map and
* identity. `run` is a thin orchestrator: resolve argv into a {@link Resolution},
* then dispatch — terminal kinds render and return; `run` enters the middleware
* stack guarded by a single error boundary. No business `if`s, no scattered
* `process.exit`.
* Build a CLI from an injected command set — each product (bl / rag / …) passes
* its own commands + identity. `run` resolves argv into a {@link Resolution},
* then dispatches it.
*/
export function createCli(commands: Record<string, Command>, opts: CliOptions): Cli {
const registry = new CommandRegistry(commands, opts.binName);
@@ -117,9 +109,12 @@ export function createCli(commands: Record<string, Command>, opts: CliOptions):
case "run": {
try {
// 解析 flag + 跨 flag 校验:任何用法问题都抛 UsageError
const flags = parseFlags(res.rest, [...GLOBAL_OPTIONS, ...(res.command.options ?? [])]);
const invalid = res.command.validate?.(flags);
if (invalid) throw new IncompleteCommandError(invalid);
if (invalid) throw new UsageError(invalid);
// 校验通过 → 准备配置、进中间件执行命令
const config = buildConfig(flags);
const ctx: RunContext = {
binName,
@@ -133,11 +128,13 @@ export function createCli(commands: Record<string, Command>, opts: CliOptions):
await runMiddleware(ctx);
await flushTelemetry(1000);
} catch (err) {
await flushTelemetry(1000);
if (err instanceof IncompleteCommandError) {
// 裸调用(命令后什么都没写)下的 UsageError → 当"还没写完",打 help、exit 0;
// 写了 flag 却无效、或执行时报错 → 报错、exit 2。
if (err instanceof UsageError && res.rest.length === 0) {
registry.printHelp(res.path, process.stderr);
return;
}
await flushTelemetry(1000);
handleError(err, binName);
}
return;
+2 -7
View File
@@ -2,13 +2,8 @@ import { formatOutput, type OutputFormat } from "bailian-cli-core";
/**
* Emit the primary result of a command.
*
* Design principle:
* stdout → structured data only (JSON when piped, text when TTY)
* stderr → human info (progress, logs, tips) — handled elsewhere
*
* This ensures `bl cmd ... | jq .` always receives clean JSON,
* while interactive users see human-readable text.
* stdout → result (text by default; JSON with --output json)
* stderr → human info (progress, logs, tips) — handled elsewhere
*/
export function emitResult(data: unknown, format: OutputFormat): void {
process.stdout.write(formatOutput(data, format) + "\n");
+3 -3
View File
@@ -129,11 +129,11 @@ test("parseFlags validates number flags", () => {
);
});
test("parseFlags throws IncompleteCommandError when a required flag is missing", () => {
test("parseFlags throws UsageError when a required flag is missing", () => {
expect(() => parseFlags(["--model", "qwen-image-2.0"], OPTS)).toThrowError(
expect.objectContaining({
name: "IncompleteCommandError",
exitCode: ExitCode.SUCCESS,
name: "UsageError",
exitCode: ExitCode.USAGE,
message: expect.stringContaining("Missing required flag: --prompt"),
}),
);