mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user