refactor(cmdline): make command providers ordinary

This commit is contained in:
Turtle
2026-08-10 21:49:11 +08:00
parent 668bdb3d8e
commit 09e2d2ddc1
46 changed files with 400 additions and 742 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md
README.md: a1512ae3357f06cd4de6347ea5ec2197fea40a90
README.zh.md: e27060db433e5c234febb28d6c120d75f82072cc
README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96
README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114

View File

@@ -13,30 +13,28 @@ A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which
An embedding host with no command line provides an empty list; that is the honest answer, not a missing value.
## Startup rows, and the service their app reads
## Ordinary providers and injected config
An app reads those arguments from its **startup row** — a Loader row and plugin that inject `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`:
Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service:
```ts ignore
export const name = 'web-startup'
export const inject = ['cmdlineArgs']
export function apply(ctx: Context): void {
runStartup(ctx, 'webStartup', webCommand(), planWebStartup)
const values = parseCmdline(ctx, webCommand(), planWebStartup)
if (values !== undefined) ctx.provide('webStartup', values)
}
```
The Loader-row injection is also its discovery declaration, so no bundle manifest field is needed:
Its Loader row carries no launcher marker or special kind:
```yaml
- id: web-startup
name: '@deepseek-ai/dsh-web-app/startup'
inject: [cmdlineArgs]
```
The launcher uses that injection only to reject arguments for a composition with no command-line owner, and to reject a composition with multiple owners. Loader mounts the composition once and holds each row until its own injections are active.
Every row the app configures from flags then reads what the startup row resolved, naming the key it takes and the value it falls back to:
Every row configured from those values uses ordinary service injection and direct lazy config access:
```yaml
- id: webserver
@@ -47,9 +45,7 @@ Every row the app configures from flags then reads what the startup row resolved
port: !!js ctx.webStartup.port ?? 3080
```
`runStartup` parses the arguments, asks `plan` for the values, and provides them as the service. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text and requests exit — nothing is provided, so rows that depend on the startup service never activate.
`plan` receives the startup context and the options of every row that injects the service, for a value that has to take the composition into account. Include still holds nested expressions raw at this point, so a plan that needs a composed fallback can interpolate the relevant row config against the pre-service startup context; the `/api` fence authorities are the shipped example.
`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate.
### How injection orders config
@@ -57,9 +53,9 @@ Loader defers a row's `!!js` interpolation until that row's declared injections
`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering.
### One command line, one owner
### Shared immutable arguments
A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and provides every startup service its retained rows inject.
`get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments.
An out-of-tree plugin brings its own commander copy, so commander's control-flow errors are detected structurally rather than by class identity; an identity check would rethrow a printed help as a fatal load failure.
@@ -74,5 +70,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Launcher flags must precede app arguments.** The split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`.
- **A startup service has no declared owner.** Reading rows name it and a `cmdlineArgs` consumer provides it; nothing links those two injections statically, so a bundle that ships reading rows without its startup row fails at settlement (pending entries naming the service) rather than at load.
- **An app-owned service has no statically declared provider.** Consumer rows name it through ordinary injection; a bundle that omits its provider fails at settlement with pending entries naming the service rather than at load.
- **A user patch that replaces a row's whole `config` drops its expressions.** A flag beats the value written beside it, not a literal a user wrote in place of the expression; keeping the expression is what keeps the flag winning.

View File

@@ -13,30 +13,28 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属
没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。
## 启动行,以及它的应用所读取的服务
## 普通提供方与注入配置
应用从自己的**启动行**读取这些参数:这是一个在 Loader 行与插件中都注入 `cmdlineArgs`,并调用 `runStartup(ctx, service, program, plan)` 的插件
任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander返回值与服务都归调用方持有
```ts ignore
export const name = 'web-startup'
export const inject = ['cmdlineArgs']
export function apply(ctx: Context): void {
runStartup(ctx, 'webStartup', webCommand(), planWebStartup)
const values = parseCmdline(ctx, webCommand(), planWebStartup)
if (values !== undefined) ctx.provide('webStartup', values)
}
```
Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字段
它的 Loader 行不携带启动器标记,也没有特殊类型
```yaml
- id: web-startup
name: '@deepseek-ai/dsh-web-app/startup'
inject: [cmdlineArgs]
```
启动器只用该注入来拒绝那些没有命令行所有者却带有应用参数的组合以及拒绝存在多个所有者的组合。Loader 只挂载一次整套组合,并让每一行等待自身的注入激活。
应用用 flag 配置的每一行随后读取启动行解析出的取值,各自点名自己取用的键,以及回退时使用的值:
所有由这些取值配置的行都使用普通服务注入,并在惰性配置中直接访问该服务:
```yaml
- id: webserver
@@ -47,9 +45,7 @@ Loader 行的注入同时也是发现声明,因此无需组合包 manifest 字
port: !!js ctx.webStartup.port ?? 3080
```
`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本请求退出:什么也不会被提供,因此依赖启动服务的行不会激活。
`plan` 会收到启动上下文,以及所有注入该服务的行的选项,用于那些必须顾及组合本身的取值。此时 Include 仍保留着嵌套表达式的原始形态,因此需要组合回退值的 plan 可以基于服务提供前的启动上下文插值相关行配置;随附的例子是 `/api` 栅栏 authority。
`parseCmdline` 解析不可变参数,向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。
### 注入如何排列配置求值
@@ -57,9 +53,9 @@ Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活
`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。
### 一条命令行,一个所有者
### 共享不可变参数
一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并提供保留下来的各行所注入的全部启动服务
`get()` 不会消费或修改 argv。多个插件可以解析同一份快照并分别提供服务。启动器不会检查组合中的命令行所有者没有读取方的 profile 只会忽略自己的应用参数
树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。
@@ -74,5 +70,5 @@ Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活
## 已知限制与延期工作
- **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。
- **启动服务没有声明所有者**:读取行点名它,由 `cmdlineArgs` 消费方提供它;这两种注入之间没有静态关联,因此交付了读取行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。
- **应用自有服务没有静态声明的提供方**:消费行通过普通注入点名它;缺少提供方的组合包会在结算时失败,由待处理条目点名该服务,而不是在加载时失败。
- **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-cmdline",
"description": "Command-line handoff between a dsh launcher and app bundles: cmdlineArgs exposes inner arguments, while injected startup rows parse them into app-owned runtime services",
"description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -24,9 +24,6 @@
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {
"commander": "^15.0.0"
},
"peerDependencies": {
"@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-invariants": "^0.0.1",
@@ -35,6 +32,7 @@
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"commander": "^15.0.0",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "^4.0.0-rc.7"
}

View File

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

View File

@@ -14,14 +14,10 @@ export const name = 'cmdline-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the owned relation is "no row is left waiting for a
* startup service", which is a property of the whole tree at Loader
* settlement, and the invariant service carries no settlement signal to
* evaluate it at. Observing it from the entry stream would fire while startup
* is still parsing, when every waiting row is legitimately still waiting. The
* launcher's post-settlement audit (`assertEntriesActivated`) already reports
* a startup service that was never provided as a pending entry naming it, and
* the built-bin e2e asserts the apps boot with flag values applied.
* No runtime invariant: `cmdlineArgs` is an immutable launcher fact that any
* number of ordinary plugins may read. App-owned providers and consumers use
* normal Cordis service injection, whose missing dependencies are already
* reported by Loader settlement.
*/
const install: InvariantInstaller = () => {}

View File

@@ -15,7 +15,7 @@ import Include from '@cordisjs/plugin-include'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { afterEach, describe, expect, it } from 'vitest'
import {
enableRow, hasCmdlineConsumer, internals, provideCmdline, runStartup, type StartupPlan,
enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan,
} from '../src/index.ts'
/** Every value one boot of the fixture tree observed. */
@@ -26,7 +26,7 @@ interface Observed {
out: string
}
/** A booted fixture tree: what it observed, and its root for direct startup calls. */
/** A booted fixture tree: what it observed, and its root for direct parser calls. */
interface Fixture {
observed: Observed
ctx: Context
@@ -46,7 +46,7 @@ function demoCommand(): Command {
}
/** The fixture app's plan: the resolved values its rows read. */
const demoPlan: StartupPlan<{ port?: number }> = (program) => {
const demoPlan: CmdlinePlan<{ port?: number }> = (program) => {
const port = program.opts<{ port?: string }>().port
if (port === undefined) return {}
if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`)
@@ -65,8 +65,8 @@ const expression = (source: string): unknown => ({ __jsExpr: source })
*/
async function bootFixture(
args: string[],
plan: StartupPlan = demoPlan,
options: { objectInject?: boolean; withoutStartup?: boolean } = {},
plan: CmdlinePlan = demoPlan,
options: { objectInject?: boolean; withoutProvider?: boolean } = {},
): Promise<Fixture> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
const observed: Observed = { exits: [], out: '' }
@@ -81,28 +81,31 @@ export function apply(ctx, config) { globalThis.__observed.started = config }
writeFileSync(join(dir, 'startup.mjs'), `
export const name = 'demo-startup'
export const inject = ['cmdlineArgs']
export function apply(ctx) { return globalThis.__runStartup(ctx) }
export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) }
`)
writeFileSync(join(dir, 'cordis.yml'), '[]\n')
const observing = { write: (chunk: string) => { observed.out += chunk; return true } }
internals.stdout = observing
internals.stderr = observing
const globals = globalThis as unknown as { __observed: Observed; __runStartup: (ctx: Context) => void }
const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void }
globals.__observed = observed
globals.__runStartup = (ctx: Context) => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }
globals.__provideDemoArgs = (ctx: Context) => {
const values = parseCmdline(ctx, demoCommand(), plan)
if (values !== undefined) ctx.provide('demoStartup', values)
}
// The composition, exactly as a profile delivers one: include patches whose
// config carries `!!js` expressions.
const composition: PatchOptions[] = [{
insert: [
...options.withoutStartup === true
...options.withoutProvider === true
? []
: [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href, inject: ['cmdlineArgs'] }],
: [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href }],
{
id: 'reader',
name: pathToFileURL(join(dir, 'reader.mjs')).href,
inject: options.objectInject === true ? { demoStartup: { required: true } } : ['demoStartup'],
config: { port: expression('ctx.demoStartup?.port ?? 3080') },
config: { port: expression('ctx.demoStartup.port ?? 3080') },
},
],
}]
@@ -119,55 +122,7 @@ export function apply(ctx) { return globalThis.__runStartup(ctx) }
return { observed, ctx }
}
describe('hasCmdlineConsumer', () => {
it('recognizes active array and object injections', () => {
expect(hasCmdlineConsumer([
{ id: 'ordinary', name: 'ordinary' },
{ id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true },
{ id: 'tui-startup', name: 'tui-startup', inject: { cmdlineArgs: { required: true } } },
])).toBe(true)
expect(hasCmdlineConsumer([
{ id: 'ordinary', name: 'ordinary' },
{ id: 'disabled-startup', name: 'disabled-startup', inject: ['cmdlineArgs'], disabled: true },
])).toBe(false)
expect(() => hasCmdlineConsumer([
{ id: 'web-startup', name: 'web-startup', inject: ['cmdlineArgs'] },
{ id: 'tui-startup', name: 'tui-startup', inject: ['cmdlineArgs'] },
])).toThrow('multiple active rows inject cmdlineArgs ("web-startup", "tui-startup")')
})
it('walks nested groups and ignores consumers disabled by an ancestor', () => {
expect(hasCmdlineConsumer([{
id: 'app',
name: 'cordis:group',
group: true,
config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }],
}])).toBe(true)
expect(hasCmdlineConsumer([{
id: 'app',
name: 'cordis:group',
group: true,
disabled: true,
config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }],
}])).toBe(false)
expect(() => hasCmdlineConsumer([
{
id: 'first',
name: 'cordis:group',
group: true,
config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }],
},
{
id: 'second',
name: 'cordis:group',
group: true,
config: [{ id: 'startup', name: 'startup', inject: ['cmdlineArgs'] }],
},
])).toThrow('multiple active rows inject cmdlineArgs ("first:startup", "second:startup")')
})
})
describe('runStartup', () => {
describe('parseCmdline', () => {
it('lets a row read the flag value the app resolved', async () => {
const { observed } = await bootFixture(['--port', '8080'])
expect(observed.started).toEqual({ port: 8080 })
@@ -179,7 +134,7 @@ describe('runStartup', () => {
expect(observed.started).toEqual({ port: 3080 })
})
it('recognizes the Loader object form of a startup-service injection', async () => {
it('recognizes the Loader object form of a provider-service injection', async () => {
const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true })
expect(observed.started).toEqual({ port: 8080 })
})
@@ -199,32 +154,24 @@ describe('runStartup', () => {
})
it('rethrows a plan failure that is not commander asking to exit', async () => {
const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true })
const plan: StartupPlan = () => { throw new Error('plan exploded') }
expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan exploded')
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
const plan: CmdlinePlan = () => { throw new Error('plan exploded') }
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded')
})
it('rethrows a thrown value that is not an object at all', async () => {
const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true })
const plan: StartupPlan = () => {
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
const plan: CmdlinePlan = () => {
const thrown: unknown = 'plan threw a string'
throw thrown
}
expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan threw a string')
expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string')
})
it('fails loud when no row injects the service the app provides', async () => {
// The bundle patch and its startup row disagree; a silent no-op would leave
// every row of the app on its fallbacks with no explanation.
const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true })
expect(() => { runStartup(ctx, 'absentStartup', demoCommand()) })
.toThrow('absentStartup: no row injects this startup service')
})
it('accepts a service-name list when the app declares no plan', async () => {
const { ctx } = await bootFixture([], demoPlan, { withoutStartup: true })
runStartup(ctx, ['demoStartup'], demoCommand())
expect(ctx.get('demoStartup')).toEqual({})
it('returns values without inspecting Loader rows or owning a service', async () => {
const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true })
expect(parseCmdline(ctx, demoCommand())).toEqual({})
expect(ctx.get('demoStartup')).toBeUndefined()
})
})
@@ -302,19 +249,17 @@ describe('provideCmdline', () => {
expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc'])
})
it('fails loud when a startup row runs without the launcher values', () => {
it('fails loud when a parser runs without the launcher values', () => {
const ctx = new Context()
expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) })
expect(() => { parseCmdline(ctx, demoCommand()) })
.toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit')
})
it('resolves nothing when the tree was disposed while the startup row parsed', () => {
// An early SIGTERM takes the Loader with it; there is nothing left to
// configure, and the bundle did nothing wrong.
const exits: number[] = []
it('lets multiple parsers read the same immutable snapshot', () => {
const ctx = new Context()
provideCmdline(ctx, { args: [], exit: code => void exits.push(code) })
expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }).not.toThrow()
expect(exits).toEqual([])
provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} })
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 })
expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true)
})
})