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:
Turtle
2026-08-07 11:58:03 +08:00
parent f749e04881
commit 1f0a0440f3
23 changed files with 720 additions and 593 deletions

View File

@@ -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))
}