|
|
@@ -7,22 +7,17 @@
|
|
|
* {@link CmdlineArgs} service, so an app owns its flag family, its `--help`
|
|
|
* text, and its parse errors instead of the launcher knowing them.
|
|
|
*
|
|
|
- * An app consumes those arguments from a **startup plugin**: a row that
|
|
|
- * injects `cmdlineArgs` and calls {@link runStartup}. What that plugin resolves
|
|
|
- * becomes its own service, and the rows it configures read the values from
|
|
|
- * there — `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats
|
|
|
- * the value written beside it. Nothing is handed back to the launcher.
|
|
|
- *
|
|
|
- * Loader delays each row's config interpolation until its declared injections
|
|
|
- * are active. A startup row consumes `cmdlineArgs`, provides the app's resolved
|
|
|
- * values, and thereby activates only the rows that depend on those values.
|
|
|
+ * Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A
|
|
|
+ * provider may publish the parsed values as its own service, 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.
|
|
|
* @module @deepseek-ai/dsh-cmdline
|
|
|
*/
|
|
|
|
|
|
import type { Command } from 'commander'
|
|
|
import type { Context } from 'cordis'
|
|
|
-import type { Entry, EntryOptions } from '@cordisjs/plugin-loader'
|
|
|
-// Empty type import carries the loader Context merge used to walk the tree.
|
|
|
+// Empty type import carries the Loader Context merge used by enableRow.
|
|
|
import type {} from '@cordisjs/plugin-loader'
|
|
|
|
|
|
/**
|
|
|
@@ -72,44 +67,11 @@ export interface CmdlineHost {
|
|
|
* @param host - the invocation's arguments and its exit request.
|
|
|
*/
|
|
|
export function provideCmdline(ctx: Context, host: CmdlineHost): void {
|
|
|
- const snapshot = [...host.args]
|
|
|
+ const snapshot: readonly string[] = Object.freeze([...host.args])
|
|
|
ctx.provide('cmdlineArgs', { get: () => snapshot })
|
|
|
ctx.provide('appExit', host.exit)
|
|
|
}
|
|
|
|
|
|
-/**
|
|
|
- * Detect whether an active row consumes the launcher's command line.
|
|
|
- *
|
|
|
- * The Loader-row injection is the declaration: an active row that names
|
|
|
- * `cmdlineArgs` owns startup for this composition. No bundle manifest field or
|
|
|
- * plugin import is needed, so an out-of-tree app adds its command line by
|
|
|
- * adding the same injection its startup plugin already requires.
|
|
|
- * @param rows - the composed Loader rows.
|
|
|
- * @returns whether this composition has a command-line owner.
|
|
|
- * @throws when more than one active row claims the command line.
|
|
|
- */
|
|
|
-export function hasCmdlineConsumer(rows: readonly EntryOptions[]): boolean {
|
|
|
- const consumers: string[] = []
|
|
|
- const visit = (entries: readonly EntryOptions[], ancestorDisabled = false, prefix = ''): void => {
|
|
|
- for (const row of entries) {
|
|
|
- const id = prefix + row.id
|
|
|
- // Loader group containers stay active when disabled, but their children
|
|
|
- // inherit that disabled state.
|
|
|
- const active = row.group === true || (!ancestorDisabled && row.disabled !== true)
|
|
|
- if (active && waitsForAny(row.inject, ['cmdlineArgs'])) consumers.push(id)
|
|
|
- if (row.group === true && Array.isArray(row.config)) {
|
|
|
- visit(row.config, ancestorDisabled || row.disabled === true, `${id}:`)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- visit(rows)
|
|
|
- if (consumers.length > 1) {
|
|
|
- const ids = consumers.map(id => JSON.stringify(id)).join(', ')
|
|
|
- throw new Error(`dsh-cmdline: multiple active rows inject cmdlineArgs (${ids}); disable all but one startup row`)
|
|
|
- }
|
|
|
- return consumers.length === 1
|
|
|
-}
|
|
|
-
|
|
|
/** The process streams commander output is written to; production writes to the process. */
|
|
|
export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = {
|
|
|
stdout: process.stdout,
|
|
|
@@ -117,58 +79,36 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Resolve this invocation into the values the app's rows read.
|
|
|
- *
|
|
|
- * Runs after a successful parse, with the waiting rows' composed options
|
|
|
- * available for a value that has to take the composition into account (the
|
|
|
- * `/api` fence authorities are the shipped example). Call `program.error(...)`
|
|
|
- * to reject the invocation with a usage message instead of throwing.
|
|
|
+ * 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 rows - the waiting rows' composed options, in tree order.
|
|
|
- * @param ctx - the startup row's context, for resolving composed fallbacks before the service exists.
|
|
|
- * @returns the service value the app's rows read; `undefined` keys let a row's
|
|
|
- * own fallback stand.
|
|
|
+ * @param ctx - the plugin context that received the command line.
|
|
|
+ * @returns the value an ordinary provider plugin may publish.
|
|
|
*/
|
|
|
-export type StartupPlan<T = unknown> = (program: Command, rows: readonly EntryOptions[], ctx: Context) => T
|
|
|
+export type CmdlinePlan<T = unknown> = (program: Command, ctx: Context) => T
|
|
|
|
|
|
/**
|
|
|
- * Run one app's startup: parse the invocation's inner arguments with the app's
|
|
|
- * own commander program and provide the resolved values as `service`. The
|
|
|
- * Loader then activates the rows that were waiting for the provided service.
|
|
|
+ * 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.
|
|
|
*
|
|
|
- * The rows read their values from the service, so nothing is written into
|
|
|
- * their config from here: a row asks for `ctx.<service>.<key>` and
|
|
|
- * falls back to the value written beside it, which is why a flag wins. Loader
|
|
|
- * resolves a row's config only after its injections are active. A live
|
|
|
- * recomposition reads the service that remains active, so editing a user patch
|
|
|
- * cannot reset an invocation value.
|
|
|
- *
|
|
|
- * Help, version, and rejected arguments are terminal for the process: the text
|
|
|
- * is written, the service is never provided, dependent rows stay pending, and
|
|
|
- * `ctx.appExit` is requested.
|
|
|
- *
|
|
|
- * A custom app that layers over another one disables the underlying startup
|
|
|
- * row and names every startup service its retained rows inject, because a
|
|
|
- * composition has exactly one command-line owner.
|
|
|
- * @param ctx - plugin context carrying `cmdlineArgs`, `appExit`, and the Loader.
|
|
|
- * @param services - the service name, or names, this startup row provides.
|
|
|
+ * 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.
|
|
|
+ * @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 values; omitted provides an empty value.
|
|
|
- * @returns the resolved values, or `undefined` when the app asked to exit
|
|
|
- * instead (help, version, or arguments it rejected).
|
|
|
- * @throws when the launcher provided no command line, or when a named service
|
|
|
- * is injected by no row.
|
|
|
+ * @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.
|
|
|
*/
|
|
|
-export function runStartup<T>(
|
|
|
+export function parseCmdline<T>(
|
|
|
ctx: Context,
|
|
|
- services: string | readonly string[],
|
|
|
program: Command,
|
|
|
- plan: StartupPlan<T> = (() => ({}) as T),
|
|
|
+ plan: CmdlinePlan<T> = (() => ({}) as T),
|
|
|
): T | undefined {
|
|
|
- const names = typeof services === 'string' ? [services] : services
|
|
|
- // Read through the global service store, not the property proxy: these are
|
|
|
- // optional host values, and a row that injects only `cmdlineArgs` may not
|
|
|
- // read the others as declared injections.
|
|
|
+ // 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')
|
|
|
const exit = ctx.get('appExit')
|
|
|
if (args === undefined || exit === undefined) {
|
|
|
@@ -180,26 +120,17 @@ export function runStartup<T>(
|
|
|
writeOut: text => void internals.stdout.write(text),
|
|
|
writeErr: text => void internals.stderr.write(text),
|
|
|
})
|
|
|
- let values: T
|
|
|
try {
|
|
|
program.parse(args.get(), { from: 'user' })
|
|
|
- // An app can dispose the whole tree while this row is still parsing (an
|
|
|
- // early SIGTERM, or another app exiting). There is then nothing to resolve
|
|
|
- // and nothing to start, and the check below would blame the bundle for a
|
|
|
- // tree that simply went away.
|
|
|
- if (ctx.get('loader') === undefined) return undefined
|
|
|
- values = plan(program, waitingRows(ctx, names), ctx)
|
|
|
+ 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. With no startup service,
|
|
|
- // dependent rows remain pending and the app stays unstarted.
|
|
|
+ // text through the output configured above.
|
|
|
if (!isCommanderError(error)) throw error
|
|
|
exit(error.exitCode)
|
|
|
return undefined
|
|
|
}
|
|
|
- for (const service of names) ctx.provide(service, values)
|
|
|
- return values
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -224,34 +155,6 @@ export async function enableRow(ctx: Context, id: string): Promise<void> {
|
|
|
await entry.enableRuntime()
|
|
|
}
|
|
|
|
|
|
-/**
|
|
|
- * The composed options of every row waiting on one of `services`, in tree order.
|
|
|
- * @param ctx - plugin context whose Loader tree carries the rows.
|
|
|
- * @param services - the startup service names.
|
|
|
- * @returns the waiting rows' options.
|
|
|
- * @throws when a service is injected by no row, which means the bundle patch
|
|
|
- * and its startup plugin disagree.
|
|
|
- */
|
|
|
-function waitingRows(ctx: Context, services: readonly string[]): EntryOptions[] {
|
|
|
- for (const service of services) {
|
|
|
- if (waitingEntries(ctx, [service]).length === 0) {
|
|
|
- throw new Error(`${service}: no row injects this startup service — the bundle patch must set "inject: [${service}]" on every row this app configures`)
|
|
|
- }
|
|
|
- }
|
|
|
- return waitingEntries(ctx, services).map(entry => entry.options)
|
|
|
-}
|
|
|
-
|
|
|
-/**
|
|
|
- * The Loader entries waiting on any of `services`.
|
|
|
- * @param ctx - plugin context whose Loader tree carries the rows.
|
|
|
- * @param services - the startup service names.
|
|
|
- * @returns the waiting entries in tree order.
|
|
|
- */
|
|
|
-function waitingEntries(ctx: Context, services: readonly string[]): Entry[] {
|
|
|
- // Called only after runStartup established the tree is still live.
|
|
|
- return [...ctx.loader.entries()].filter(entry => waitsForAny(entry.options.inject, services))
|
|
|
-}
|
|
|
-
|
|
|
/**
|
|
|
* Whether a thrown value is commander's own control-flow error (help, version,
|
|
|
* a parse error, or `program.error`).
|
|
|
@@ -269,17 +172,3 @@ function isCommanderError(error: unknown): error is { code: string; exitCode: nu
|
|
|
return typeof candidate.code === 'string' && candidate.code.startsWith('commander.')
|
|
|
&& typeof candidate.exitCode === 'number'
|
|
|
}
|
|
|
-
|
|
|
-/**
|
|
|
- * Whether a row's `inject` declaration names any of `services`.
|
|
|
- * @param inject - the row's `inject` value: the array form, the object form, or absent.
|
|
|
- * @param services - the startup service names.
|
|
|
- * @returns true when the row waits for one of them.
|
|
|
- */
|
|
|
-function waitsForAny(inject: EntryOptions['inject'], services: readonly string[]): boolean {
|
|
|
- if (inject === undefined || inject === null) return false
|
|
|
- // The array form lists service names; the object form maps each name to its
|
|
|
- // intercept config. Both name the service as a key of the same shape.
|
|
|
- const declared = Array.isArray(inject) ? inject : Object.keys(inject)
|
|
|
- return services.some(service => declared.includes(service))
|
|
|
-}
|