refactor(cmdline): run the program's own commander action instead of a plan callback
parseCmdline(ctx, program): void only adapts commander control flow to
the launcher: it parses the immutable cmdlineArgs snapshot and turns
help, version, parse errors, and action rejections into a ctx.appExit
request. App validation and the ctx.provide of the app-owned service
live in the program's own synchronous .action(), which commander runs
inside parse — program.error(...) there shares the exit path with a
grammar rejection. Deletes the CmdlinePlan export, its unread ctx
parameter, the type-unsound (() => ({}) as T) default, and the
T | undefined return with its per-caller publish guard.
This commit is contained in:
@@ -8,7 +8,8 @@
|
||||
* text, and its parse errors instead of the launcher knowing them.
|
||||
*
|
||||
* Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A
|
||||
* provider may publish the parsed values as its own service, and ordinary rows
|
||||
* provider may publish the parsed values as its own service from its program's
|
||||
* commander action, and ordinary rows
|
||||
* can inject that service and read it from lazily resolved config —
|
||||
* `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written
|
||||
* beside it. No row has launcher-level command-line status.
|
||||
@@ -76,35 +77,25 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w
|
||||
stderr: process.stderr,
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve parsed arguments into an app-owned value. Call
|
||||
* `program.error(...)` to reject the invocation with a usage message instead
|
||||
* of throwing.
|
||||
* @param program - the parsed commander program.
|
||||
* @param ctx - the plugin context that received the command line.
|
||||
* @returns the value an ordinary provider plugin may publish.
|
||||
*/
|
||||
export type CmdlinePlan<T = unknown> = (program: Command, ctx: Context) => T
|
||||
|
||||
/**
|
||||
* Parse the launcher's immutable argument snapshot with an app's commander
|
||||
* program. The caller decides whether and how to publish the returned value;
|
||||
* this helper has no Loader-row or service ownership semantics.
|
||||
* program. Commander runs the program's own synchronous action handler on a
|
||||
* successful parse; app code there publishes its service and rejects an
|
||||
* invalid invocation with `program.error(...)`. This helper has no Loader-row
|
||||
* or service ownership semantics.
|
||||
*
|
||||
* Help, version, and rejected arguments are terminal for the process: commander
|
||||
* writes the text, the helper requests `ctx.appExit`, and it returns
|
||||
* `undefined` so the caller publishes nothing.
|
||||
* Help, version, and rejected arguments — from the grammar or from an action
|
||||
* — are terminal for the process: commander writes the text and the helper
|
||||
* requests `ctx.appExit`. The action never runs on help, version, or a
|
||||
* grammar rejection; an action must reject before it publishes, because
|
||||
* statements before its `program.error(...)` have already run.
|
||||
* @param ctx - plugin context carrying `cmdlineArgs` and `appExit`.
|
||||
* @param program - the app's commander program, with its flags and description already declared.
|
||||
* @param plan - this invocation's resolved value; omitted returns an empty object.
|
||||
* @returns the resolved value, or `undefined` when the app asked to exit.
|
||||
* @throws when the launcher did not provide the command line and exit request.
|
||||
* @param program - the app's commander program, with its flags, description,
|
||||
* actions, and any subcommands already declared.
|
||||
* @throws when the launcher did not provide the command line and exit request,
|
||||
* or when no command in the program declares an action.
|
||||
*/
|
||||
export function parseCmdline<T>(
|
||||
ctx: Context,
|
||||
program: Command,
|
||||
plan: CmdlinePlan<T> = (() => ({}) as T),
|
||||
): T | undefined {
|
||||
export function parseCmdline(ctx: Context, program: Command): void {
|
||||
// Read through the global service store, not the property proxy: appExit is
|
||||
// an optional host value and the plugin only needs to inject cmdlineArgs.
|
||||
const args = ctx.get('cmdlineArgs')
|
||||
@@ -112,23 +103,54 @@ export function parseCmdline<T>(
|
||||
if (args === undefined || exit === undefined) {
|
||||
throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`)
|
||||
}
|
||||
program
|
||||
if (!hasAction(program)) {
|
||||
throw new Error(`${program.name()}: no command in the program declares an action; parseCmdline runs the invoked command's action on a successful parse, and app code there publishes its service`)
|
||||
}
|
||||
configureExitAndOutput(program)
|
||||
try {
|
||||
program.parse(args.get(), { from: 'user' })
|
||||
} catch (error) {
|
||||
// exitOverride turns help, version, a parse error, and the action's own
|
||||
// program.error() into a CommanderError; commander has already written the
|
||||
// text through the output configured above.
|
||||
if (!isCommanderError(error)) throw error
|
||||
exit(error.exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any command in the tree declares an action handler.
|
||||
*
|
||||
* The `Command` type cannot express the action precondition, so the handler is
|
||||
* read structurally (as {@link isCommanderError} reads commander's control-flow
|
||||
* errors): without this guard, a program that forgot its action would parse
|
||||
* successfully, publish nothing, and surface only as dependent rows pending on
|
||||
* the absent service.
|
||||
* @param command - the command whose tree is inspected.
|
||||
* @returns true when the command or any registered subcommand has an action.
|
||||
*/
|
||||
function hasAction(command: Command): boolean {
|
||||
if (typeof (command as unknown as { _actionHandler?: unknown })._actionHandler === 'function') return true
|
||||
return command.commands.some(hasAction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Route every command's exit and output through the launcher adapter.
|
||||
*
|
||||
* Commander copies `exitOverride` and output configuration into a subcommand
|
||||
* only at registration, so a root-only override would let an
|
||||
* already-registered subcommand's rejection write to the process streams and
|
||||
* call `process.exit` directly, bypassing `ctx.appExit`.
|
||||
* @param command - the root of the command tree to configure.
|
||||
*/
|
||||
function configureExitAndOutput(command: Command): void {
|
||||
command
|
||||
.exitOverride()
|
||||
.configureOutput({
|
||||
writeOut: text => void internals.stdout.write(text),
|
||||
writeErr: text => void internals.stderr.write(text),
|
||||
})
|
||||
try {
|
||||
program.parse(args.get(), { from: 'user' })
|
||||
return plan(program, ctx)
|
||||
} catch (error) {
|
||||
// exitOverride turns help, version, a parse error, and a plan's own
|
||||
// program.error() into a CommanderError; commander has already written the
|
||||
// text through the output configured above.
|
||||
if (!isCommanderError(error)) throw error
|
||||
exit(error.exitCode)
|
||||
return undefined
|
||||
}
|
||||
for (const child of command.commands) configureExitAndOutput(child)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user