refactor: improve network error and allow verbose output (#36923)

This commit is contained in:
Yunlu Wen
2026-06-02 10:43:40 +00:00
committed by GitHub
parent b682591c7a
commit f19679b217
28 changed files with 339 additions and 231 deletions
+7 -1
View File
@@ -1,6 +1,7 @@
import type { CommandOutput } from './output'
import type { ArgDefinition, FlagDefinition, ICommand, InferArgs, InferFlags, OptionalArgValueType } from './types'
import { parseArgv } from './flags'
import { setVerbose } from './context'
import { hasBooleanFlag, parseArgv, VERBOSE_CHAR, VERBOSE_FLAG } from './flags'
export type CommandConstructor = {
new(): Command
@@ -28,11 +29,16 @@ type ParseResult<C extends CommandConstructor> = {
export abstract class Command implements ICommand {
static description?: string
static flags: Record<string, FlagDefinition<OptionalArgValueType>> = {}
static args: Record<string, ArgDefinition<string | undefined>> = {}
static examples: string[] = []
abstract run(argv: string[]): Promise<CommandOutput | void>
processGlobalFlags(argv: readonly string[]): void {
setVerbose(hasBooleanFlag(argv, VERBOSE_FLAG, VERBOSE_CHAR))
}
protected parse<C extends CommandConstructor>(ctor: C, argv: string[]): ParseResult<C> {
const meta = {
flags: ctor.flags ?? {},
+15
View File
@@ -0,0 +1,15 @@
type CommandContext = {
verbose: boolean
}
const commandContext: CommandContext = {
verbose: false,
}
export function setVerbose(verbose: boolean): void {
commandContext.verbose = verbose
}
export function isVerbose(): boolean {
return commandContext.verbose
}
+42 -14
View File
@@ -1,6 +1,24 @@
import type { ArgDefinition, CommandMeta, FlagDefinition, ParsedArgs, ParsedFlags } from './types'
import { UnsupportedArgValueError } from './errors'
export const VERBOSE_FLAG = 'verbose'
export const VERBOSE_CHAR = 'v'
export const Flags = {
string: stringFlag,
stringArray: stringRepeatedFlag,
boolean: booleanFlag,
integer: integerFlag,
outputFormat: outputFormatFlag,
}
const GLOBAL_FLAGS: Record<string, FlagDefinition> = {
[VERBOSE_FLAG]: Flags.boolean({
char: VERBOSE_CHAR,
description: 'enable verbose output',
}),
}
function stringFlag<const Opts extends {
description: string
char?: string
@@ -48,14 +66,6 @@ function integerFlag<const Opts extends { description: string, char?: string, de
return { type: 'integer', ...opts } as FlagDefinition<Opts extends { default: number } ? number : number | undefined>
}
export const Flags = {
string: stringFlag,
stringArray: stringRepeatedFlag,
boolean: booleanFlag,
integer: integerFlag,
outputFormat: outputFormatFlag,
}
function stringArg<const Opts extends { description: string, required?: boolean }>(
opts: Opts,
): ArgDefinition<Opts extends { required: true } ? string : string | undefined> {
@@ -99,8 +109,8 @@ function accumulateFlagValue(flags: ParsedFlags, name: string, value: string | b
}
}
function resolveByChar(char: string, meta: CommandMeta): [name: string, def: FlagDefinition] | undefined {
for (const [name, def] of Object.entries(meta.flags)) {
function resolveByChar(char: string, flags: Record<string, FlagDefinition>): [name: string, def: FlagDefinition] | undefined {
for (const [name, def] of Object.entries(flags)) {
if (def.char === char)
return [name, def]
}
@@ -115,12 +125,12 @@ function validateFlagOptions(name: string, raw: string, def: FlagDefinition): vo
type ResolvedFlag = { name: string, def: FlagDefinition, label: string, inlineRaw: string | undefined }
function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null {
function resolveToken(token: string, flags: Record<string, FlagDefinition>): ResolvedFlag | null {
if (token.startsWith('--')) {
const eqIdx = token.indexOf('=')
const name = eqIdx !== -1 ? token.slice(2, eqIdx) : token.slice(2)
const inlineRaw = eqIdx !== -1 ? token.slice(eqIdx + 1) : undefined
const def = meta.flags[name]
const def = flags[name]
if (!def)
throw new Error(`unknown flag: --${name}`)
return { name, def, label: `--${name}`, inlineRaw }
@@ -128,7 +138,7 @@ function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null {
if (token.length === 2 && token[1] !== undefined) {
const char = token[1]
const resolved = resolveByChar(char, meta)
const resolved = resolveByChar(char, flags)
if (!resolved)
throw new Error(`unknown flag: -${char}`)
const [name, def] = resolved
@@ -138,6 +148,21 @@ function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null {
return null
}
// Scans argv for a boolean flag without throwing on unknown tokens, so it is safe
// to call before the command-specific flag set is known (e.g. global flags).
export function hasBooleanFlag(argv: readonly string[], name: string, char?: string): boolean {
for (const token of argv) {
if (token === '--')
break
if (token === `--${name}` || token === `--${name}=true` || token === `--${name}=1`)
return true
if (char !== undefined && token === `-${char}`)
return true
}
return false
}
export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: ParsedArgs, flags: ParsedFlags } {
const flags: ParsedFlags = {}
const positional: string[] = []
@@ -159,7 +184,10 @@ export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: P
continue
}
const resolved = resolveToken(token, meta)
const resolved = resolveToken(token, {
...meta.flags,
...GLOBAL_FLAGS, // pass global flags to prevent unknown flag error
})
if (!resolved) {
positional.push(token)
continue
+4 -4
View File
@@ -1,7 +1,7 @@
import type { CommandConstructor } from './command'
import type { CommandTree } from './registry'
import { describe, expect, it } from 'vitest'
import { BaseError, newError } from '@/errors/base'
import { BaseError, HttpClientError, newError } from '@/errors/base'
import { ErrorCode, ExitCode } from '@/errors/codes'
import { Command } from './command'
import { run, sniffOutputFormat } from './run'
@@ -171,7 +171,7 @@ describe('run() catch routing', () => {
it('routes Server5xx error with http_status line and generic exit', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw newError(ErrorCode.Server5xx, 'upstream boom').withHttpStatus(502)
throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom')).withHttpStatus(502)
}
}
const result = await captureRun(makeTree(Throwing), ['cmd'])
@@ -182,7 +182,7 @@ describe('run() catch routing', () => {
it('renders request line and http_status when both are present', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw newError(ErrorCode.Server5xx, 'upstream boom')
throw HttpClientError.from(newError(ErrorCode.Server5xx, 'upstream boom'))
.withRequest('GET', 'https://api.dify.ai/v1/me')
.withHttpStatus(502)
}
@@ -197,7 +197,7 @@ describe('run() catch routing', () => {
it('serializes method and url in JSON envelope', async () => {
class Throwing extends Command {
async run(_argv: string[]) {
throw newError(ErrorCode.Server4xxOther, 'not found')
throw HttpClientError.from(newError(ErrorCode.Server4xxOther, 'not found'))
.withRequest('GET', 'https://api.dify.ai/v1/apps/x')
.withHttpStatus(404)
}
+4 -1
View File
@@ -45,7 +45,10 @@ export async function run(tree: CommandTree, argv: string[]): Promise<void> {
if (typeof Ctor.deprecated === 'string' && Ctor.deprecated.length > 0)
process.stderr.write(`deprecated: ${Ctor.deprecated}\n`)
const cmd = new Ctor()
const output = await cmd.run(argv.slice(resolved.path.length))
const commandArgv = argv.slice(resolved.path.length)
cmd.processGlobalFlags(commandArgv)
const output = await cmd.run(commandArgv)
if (output !== undefined)
process.stdout.write(stringifyOutput(output))
}