refactor(cmdline)!: an app's entrypoint provides values its rows read
Replaces the patch round trip. An app's entrypoint resolves the command
line into a service, and the rows it configures read that service from
their own config — port: !!js ctx.get('webStartup')?.port ?? 3080 — so the
resolved value beats the value written beside it and nothing is written
back into a row or handed to the launcher.
A bundle names the entrypoint row in its manifest (dsh.bundle.entrypoint),
which is what lets the boot mount in two passes: entrypoints alone, then
the whole composition. That ordering is required, not cosmetic — a row's
config expressions are evaluated when the include applies the row, and a
strict ctx.get only answers for a service whose providing fiber is already
active.
What this removes: ctx.appPatches and the launcher-owned patch layer, the
disable/re-enable recycle and its in-flight-mount barrier, overrideConfig,
and the reload hazard they existed for. A live config edit now re-applies
the second pass against services that are still up, so a served port
survives by construction.
What it adds: ctx.appReady, because Loader settlement no longer means the
app is up — a row mounted in the second pass can observe a settled tree
while that pass is still running, or already rolling back. The web URL line
waits for it, so a boot that fails in the second pass announces nothing.
This commit is contained in:
@@ -8,17 +8,21 @@
|
||||
* 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}. Every row the app
|
||||
* configures from flags declares `inject: [<startup service>]` in the bundle
|
||||
* patch and therefore waits until the startup plugin provides that service;
|
||||
* `--help` prints, disables exactly those rows, and requests exit, so the app
|
||||
* never starts.
|
||||
* 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.get('webStartup')?.port ?? 3080` — so a flag beats
|
||||
* the value written beside it. Nothing is handed back to the launcher.
|
||||
*
|
||||
* Those rows ship `disabled: true`, because a row's config is resolved when the
|
||||
* Loader creates its fiber and a strict `ctx.get` only sees a service whose
|
||||
* providing fiber is already active. The startup plugin enables them once its
|
||||
* own fiber is active, and keeps them enabled when a recomposition of the tree
|
||||
* puts them back.
|
||||
* @module @deepseek-ai/dsh-cmdline
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
import type { Entry, EntryOptions } from '@cordisjs/plugin-loader'
|
||||
// Empty type import carries the loader Context merge used to walk the tree.
|
||||
import type {} from '@cordisjs/plugin-loader'
|
||||
@@ -45,61 +49,47 @@ export interface AppExit {
|
||||
(code: number): void
|
||||
}
|
||||
|
||||
/**
|
||||
* The launcher's own patch layer, above every layer a user can edit.
|
||||
*
|
||||
* A startup row's decisions are facts about this invocation, so they must
|
||||
* outlive a recomposition of the tree: a launcher that re-applies its patch
|
||||
* stack when the user edits a live patch file rebuilds every row from its
|
||||
* composed options, which would otherwise silently reset a flag-configured
|
||||
* row (a browser served on `--port 8080` would move back to the composed
|
||||
* port on an unrelated edit).
|
||||
*/
|
||||
export interface AppPatches {
|
||||
/**
|
||||
* Record patches the launcher must keep applying on every later composition.
|
||||
* @param patches - the startup row's decisions, as patches over the composed rows.
|
||||
*/
|
||||
contribute(patches: readonly PatchOptions[]): void
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** The invocation's inner arguments; provided by a launcher before the tree mounts. */
|
||||
cmdlineArgs?: CmdlineArgs
|
||||
/** Bounded process-exit request; provided by a launcher before the tree mounts. */
|
||||
appExit?: AppExit
|
||||
/** The launcher's own patch layer; provided by a launcher that recomposes its tree. */
|
||||
appPatches?: AppPatches
|
||||
/** Settles when the launcher has mounted the whole composition; see {@link CmdlineHost.ready}. */
|
||||
appReady?: Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher facts an app's startup row needs. */
|
||||
/** The launcher facts an app needs. */
|
||||
export interface CmdlineHost {
|
||||
/** The invocation's inner arguments, in argv order. */
|
||||
args: readonly string[]
|
||||
/** Bounded process-exit request. */
|
||||
exit: AppExit
|
||||
/**
|
||||
* Sink for startup decisions a later recomposition must keep. A launcher
|
||||
* that never recomposes its tree (a one-shot embedding host) omits it.
|
||||
* Settles when the launcher has finished mounting, which a row that
|
||||
* publishes readiness (a URL line a supervisor waits for) must await.
|
||||
*
|
||||
* A boot mounts in phases, so Loader settlement no longer means the whole
|
||||
* composition is up: a row mounted in a later phase can observe a settled
|
||||
* tree while rows beside it have yet to mount, or while the phase that
|
||||
* mounted it is already rolling back. Rejects with the boot failure.
|
||||
*/
|
||||
contribute?: AppPatches['contribute']
|
||||
ready?: Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the command line, the exit request, and the patch sink on a host
|
||||
* context before any tree entry mounts. These are launcher facts, not config:
|
||||
* an embedding host with no command line provides an empty argument list.
|
||||
* Provide the command line and the exit request on a host context before any
|
||||
* tree entry mounts. Both are launcher facts, not config: an embedding host
|
||||
* with no command line provides an empty argument list.
|
||||
* @param ctx - the host context the tree will mount under.
|
||||
* @param host - the invocation's arguments, exit request, and optional patch sink.
|
||||
* @param host - the invocation's arguments and its exit request.
|
||||
*/
|
||||
export function provideCmdline(ctx: Context, host: CmdlineHost): void {
|
||||
const snapshot = [...host.args]
|
||||
ctx.provide('cmdlineArgs', { get: () => snapshot })
|
||||
ctx.provide('appExit', host.exit)
|
||||
const contribute = host.contribute
|
||||
if (contribute !== undefined) ctx.provide('appPatches', { contribute })
|
||||
if (host.ready !== undefined) ctx.provide('appReady', host.ready)
|
||||
}
|
||||
|
||||
/** The process streams commander output is written to; production writes to the process. */
|
||||
@@ -109,62 +99,55 @@ export const internals: { stdout: { write(chunk: string): unknown }; stderr: { w
|
||||
}
|
||||
|
||||
/**
|
||||
* What a startup plugin changes on one waiting row. A row with a change is
|
||||
* re-enabled as part of applying it; `{ disabled: true }` keeps it off (and
|
||||
* `{ disabled: false }` is how a row a bundle ships disabled gets turned on).
|
||||
*/
|
||||
export type RowChange = Omit<Partial<EntryOptions>, 'id' | 'inject'>
|
||||
|
||||
/**
|
||||
* Decide this invocation's changes for the rows waiting on an app's startup
|
||||
* service.
|
||||
* Resolve this invocation into the values the app's rows read.
|
||||
*
|
||||
* Runs after a successful parse, with every waiting row's composed options
|
||||
* (bundle layers, the user's layers, and any `--patch` overlay already
|
||||
* applied), so a decision can read what the composition agreed on before
|
||||
* overriding it. Call `program.error(...)` to reject the invocation with a
|
||||
* usage message instead of throwing.
|
||||
* 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.
|
||||
* @param program - the parsed commander program.
|
||||
* @param rows - the waiting rows' composed options, in tree order.
|
||||
* @returns row id → the changes for that row; ids absent from the map start unchanged.
|
||||
* @returns the service value the app's rows read; `undefined` keys let a row's
|
||||
* own fallback stand.
|
||||
*/
|
||||
export type StartupPlan = (program: Command, rows: readonly EntryOptions[]) => Map<string, RowChange>
|
||||
export type StartupPlan<T = unknown> = (program: Command, rows: readonly EntryOptions[]) => T
|
||||
|
||||
/**
|
||||
* Run one app's startup: parse the invocation's inner arguments with the app's
|
||||
* own commander program, apply the resulting changes to the waiting rows, and
|
||||
* release them by providing the startup service they inject.
|
||||
* own commander program, provide the resolved values as `service`, and start
|
||||
* the rows that were waiting for it.
|
||||
*
|
||||
* A waiting row's config is resolved when the Loader creates its fiber, which
|
||||
* happens while the row is still waiting, so writing a new config onto that
|
||||
* fiber would never reach the plugin. Each changed row is therefore recycled —
|
||||
* disabled, then re-enabled with its new values — which drops the stale fiber
|
||||
* and resolves the config again. Recycling deliberately leaves `inject` alone:
|
||||
* an `inject` update restarts the row from its unwrapped callback and loses the
|
||||
* plugin's own static injections.
|
||||
* The rows read their values from the service, so nothing is written into
|
||||
* their config from here: a row asks for `ctx.get('<service>')?.<key>` and
|
||||
* falls back to the value written beside it, which is why a flag wins. They are
|
||||
* enabled from inside an injection on the service itself, because a strict
|
||||
* `ctx.get` only resolves a service whose providing fiber is already active,
|
||||
* and re-enabled whenever a recomposition of the tree disables them again — a
|
||||
* user editing a live patch file must not take the app down.
|
||||
*
|
||||
* Help, version, and rejected arguments are terminal for the process: the text
|
||||
* is written, every waiting row is disabled so the settlement audit sees a tree
|
||||
* that was asked not to start this app, and `ctx.appExit` is requested.
|
||||
* is written, the service is never provided, the app's rows stay disabled, and
|
||||
* `ctx.appExit` is requested.
|
||||
*
|
||||
* An app that layers over another one (the one-shot bundle rides over the web
|
||||
* bundle) disables the underlying startup row and names both startup services,
|
||||
* because a composition has exactly one command-line owner: the rows of the app
|
||||
* it absorbed then start on their composed values.
|
||||
* bundle) disables the underlying startup row and names both services, because
|
||||
* a composition has exactly one command-line owner: the rows of the app it
|
||||
* absorbed then start on the values their own fallbacks name.
|
||||
* @param ctx - plugin context carrying `cmdlineArgs`, `appExit`, and the Loader.
|
||||
* @param services - the startup service name, or names, that this app's rows declare in their `inject`.
|
||||
* @param services - the service name, or names, this startup row provides.
|
||||
* @param program - the app's commander program, with its flags and description already declared.
|
||||
* @param plan - this invocation's per-row changes; omitted starts the waiting rows unchanged.
|
||||
* @returns nothing once the waiting rows are released, or once the exit was requested.
|
||||
* @throws when the launcher provided no command line, when a startup service is
|
||||
* declared by no row, or when `plan` names a row that is not waiting.
|
||||
* @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.
|
||||
*/
|
||||
export async function runStartup(
|
||||
export function runStartup<T>(
|
||||
ctx: Context,
|
||||
services: string | readonly string[],
|
||||
program: Command,
|
||||
plan: StartupPlan = () => new Map(),
|
||||
): Promise<void> {
|
||||
plan: StartupPlan<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
|
||||
@@ -180,75 +163,47 @@ export async function runStartup(
|
||||
writeOut: text => void internals.stdout.write(text),
|
||||
writeErr: text => void internals.stderr.write(text),
|
||||
})
|
||||
let decisions: Map<string, RowChange>
|
||||
let rows: EntryOptions[]
|
||||
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
|
||||
// configure and nothing to release, and the checks below would blame the
|
||||
// bundle for a tree that simply went away.
|
||||
if (ctx.get('loader') === undefined) return
|
||||
rows = waitingRows(ctx, names)
|
||||
decisions = plan(program, rows)
|
||||
// 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))
|
||||
} 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.
|
||||
// text through the output configured above. The app's rows ship disabled,
|
||||
// so leaving them alone is what keeps the app unstarted.
|
||||
if (!isCommanderError(error)) throw error
|
||||
for (const entry of waitingEntries(ctx, names)) await stopRow(entry)
|
||||
exit(error.exitCode)
|
||||
return
|
||||
return undefined
|
||||
}
|
||||
const unknown = [...decisions.keys()].filter(id => !rows.some(row => row.id === id))
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`${program.name()}: startup planned changes for row(s) ${unknown.join(', ')}, which inject none of ${names.join(', ')}`)
|
||||
}
|
||||
const contributed: PatchOptions[] = []
|
||||
for (const entry of waitingEntries(ctx, names)) {
|
||||
const change = decisions.get(entry.options.id)
|
||||
if (change === undefined) continue
|
||||
await stopRow(entry)
|
||||
await entry.update({ disabled: false, ...change })
|
||||
contributed.push({ id: entry.options.id, disabled: false, ...change })
|
||||
}
|
||||
// Hand the same decisions to the launcher as patches, so a later
|
||||
// recomposition of the tree (a user editing a live patch file) rebuilds
|
||||
// these rows with this invocation's values instead of the composed ones.
|
||||
if (contributed.length > 0) ctx.get('appPatches')?.contribute(contributed)
|
||||
// The rows are ready; providing the service they inject starts them, and a
|
||||
// row this invocation left disabled stays that way.
|
||||
for (const service of names) ctx.provide(service, true)
|
||||
for (const service of names) ctx.provide(service, values)
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a waiting row, including one whose own mount is still in flight.
|
||||
* Turn on a row this composition ships disabled, because this invocation asked
|
||||
* for it (`dsh web --dev` and its client-plugin reload chain).
|
||||
*
|
||||
* Disabling alone is not a barrier: a row whose init has not finished has no
|
||||
* fiber yet, so the update returns while that init goes on to create one, and
|
||||
* the re-enable would then take the config-patch path, which a still-waiting
|
||||
* fiber never applies — the row would start on stale values. Letting the mount
|
||||
* settle first gives the disable a fiber to dispose. A row the composition
|
||||
* ships disabled has no mount to settle and is left alone.
|
||||
* @param entry - the waiting row's Loader entry.
|
||||
* A row cannot be inserted from inside a mounting plugin — the Loader returns a
|
||||
* prefixed id it then fails to resolve — so a conditional row ships disabled
|
||||
* and an entrypoint enables it.
|
||||
* Call it from a row that mounts alongside the one being enabled: an
|
||||
* entrypoint runs before the rest of the composition, so a row it enabled
|
||||
* there would wait for services that have yet to mount.
|
||||
* @param ctx - plugin context whose Loader tree carries the row.
|
||||
* @param id - the row id.
|
||||
* @returns nothing once the row has started.
|
||||
* @throws when the composition has no row with that id.
|
||||
*/
|
||||
async function stopRow(entry: Entry): Promise<void> {
|
||||
await entry.refresh()
|
||||
await entry.update({ disabled: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge flag overrides over a waiting row's composed config.
|
||||
*
|
||||
* A row's composed config is what the bundle patches and the user's own layers
|
||||
* agreed on; a flag replaces exactly the keys it names and leaves the rest of
|
||||
* that agreement intact.
|
||||
* @param options - the waiting row's composed options.
|
||||
* @param overrides - the values this invocation's flags decided, by config key.
|
||||
* @returns the change to put in a {@link StartupPlan}'s map.
|
||||
*/
|
||||
export function overrideConfig(options: EntryOptions, overrides: Record<string, unknown>): RowChange {
|
||||
return { config: { ...(options.config ?? {}) as Record<string, unknown>, ...overrides } }
|
||||
export async function enableRow(ctx: Context, id: string): Promise<void> {
|
||||
const entry = [...ctx.loader.entries()].find(candidate => candidate.options.id === id)
|
||||
if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`)
|
||||
await entry.update({ disabled: false })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,8 +211,8 @@ export function overrideConfig(options: EntryOptions, overrides: Record<string,
|
||||
* @param ctx - plugin context whose Loader tree carries the rows.
|
||||
* @param services - the startup service names.
|
||||
* @returns the waiting rows' options.
|
||||
* @throws when a startup service is declared by no row, which means the bundle
|
||||
* patch and its startup plugin disagree.
|
||||
* @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) {
|
||||
@@ -276,7 +231,7 @@ function waitingRows(ctx: Context, services: readonly string[]): EntryOptions[]
|
||||
*/
|
||||
function waitingEntries(ctx: Context, services: readonly string[]): Entry[] {
|
||||
// Called only after runStartup established the tree is still live.
|
||||
return [...ctx.loader.entries()].filter(entry => services.some(service => waitsFor(entry.options.inject, service)))
|
||||
return [...ctx.loader.entries()].filter(entry => waitsForAny(entry.options.inject, services))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,14 +253,15 @@ function isCommanderError(error: unknown): error is { code: string; exitCode: nu
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a row's `inject` declaration names `service`.
|
||||
* 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 service - the startup service name.
|
||||
* @returns true when the row waits for it.
|
||||
* @param services - the startup service names.
|
||||
* @returns true when the row waits for one of them.
|
||||
*/
|
||||
function waitsFor(inject: EntryOptions['inject'], service: string): boolean {
|
||||
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.
|
||||
return Array.isArray(inject) ? inject.includes(service) : Object.hasOwn(inject, service)
|
||||
const declared = Array.isArray(inject) ? inject : Object.keys(inject)
|
||||
return services.some(service => declared.includes(service))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user