diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 11165122e5..c0ec7836ae 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: f7db56e298c11f2663f63cc05d71121a03856668 -2026-08-06-app-owned-command-line.zh.md: f17fdc9a78d9baa36852392e11744a585adfe578 +2026-08-06-app-owned-command-line.md: 4765629c0cc3fee1d850de215af18bdbe51324bb +2026-08-06-app-owned-command-line.zh.md: 48782fbb9ce53ba9b3e8dbc6c2f746c7f1d46ea1 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index f7db56e298..4765629c0c 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -12,34 +12,39 @@ After profiles, compositions were installable but their command lines were not. The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. -The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appPatches`. An app consumes them from a **startup row** that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)` with its own commander program; rows the app configures inject that startup service in the bundle patch, so they cannot start before their values are resolved, and `--help` prints, disables those rows, and exits without the app ever starting. +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`), `ctx.appExit`, and `ctx.appReady`. An app consumes them from its **entrypoint row** — named by its bundle manifest (`dsh.bundle.entrypoint`) — which injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)` with its own commander program, then provides what it resolved as its own service. The rows the app configures read that service from their own config expressions (`port: !!js ctx.get('webStartup')?.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. + +The boot mounts in two passes, which is what the manifest declaration buys: entrypoints alone, then the whole composition. 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 active, so the rest of the tree has to be applied after the entrypoints are up. `--help` therefore exits before the second pass exists, and a user editing a live patch file re-applies that pass against services that are still up, so a served port cannot be silently reset. The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any row id. Out of tree, turtle-ui gained `--resume ` / `--session ` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. -Two further consequences fell out of review. An app's decisions are also handed back to the launcher as patches (`ctx.appPatches`), because the launcher re-applies its whole patch stack when a user edits a live patch file: without that layer, an unrelated edit rebuilt every row from its composed options and silently moved a server started on `--port 8080` back to the composed port, dropping `--dev` and the derived `/api` fence authorities with it. And `dsh --profile web` now adds the harness-source prompt section that only the `dsh web` alias used to add — the two paths finally boot identically, which also means a user profile named `web` inherits it. +Two further consequences. Loader settlement stopped meaning "the app is up" — a row mounted in the second pass can observe a settled tree while the pass that mounted it is still going, or already rolling back — so a row that publishes readiness (the web URL line) awaits `ctx.appReady` instead. And `dsh --profile web` now adds the harness-source prompt section that only the `dsh web` alias used to add: the two paths finally boot identically, which also means a user profile named `web` inherits it. -## How a waiting row actually receives its values +## Why the boot has phases -Three vendored-Loader facts shaped the mechanism, all found by probe: +Four vendored-Loader facts shaped the mechanism, all found by probe: -- **A row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting for its startup service.** Writing a new config onto that waiting fiber never reaches 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. -- **Updating a row's `inject` loses the plugin's own static injections.** The Loader restarts a replaced row from `runtime.callback`, the unwrapped function, and `Inject.resolve(plugin.inject)` then finds nothing: a row declaring `inject = ['httpServer', 'apiProxy']` comes back unable to read either. Recycling therefore never touches `inject`; the waiting rows are released by providing the service. -- **A row's config is validated at fiber creation too**, so a row whose *required* config the startup supplies (the one-shot runner's `task`) must ship `disabled: true`; making it wait is not enough, because the boot fails before the startup row can run. It only appeared to work because the startup module happened to import first. +- **A profile's rows arrive as the root include's `patches` option, and an entry's whole config is interpolated when that entry starts.** Every `!!js` in every row is therefore evaluated once, when the include mounts — before any row exists. Rows in the root config *file* would interpolate per row, but a profile root is empty by design. +- **A strict `ctx.get` hides a service whose providing fiber is not yet ACTIVE**, and a plugin's own fiber is not active while its `apply` is still running. Providing a service and configuring rows from it in the same pass cannot work. +- **Updating a row's `inject` loses the plugin's own static injections.** The Loader restarts a replaced row from `runtime.callback`, the unwrapped function, and `Inject.resolve(plugin.inject)` then finds nothing: a row declaring `inject = ['httpServer', 'apiProxy']` comes back unable to read either. +- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and a row that mounts beside it enables it (`dsh web --dev` and its reload chain). -A related constraint: a row cannot be inserted from inside a mounting plugin (`tree.create` returns a prefixed id it then fails to resolve), so a conditional row ships `disabled: true` and startup enables it. Recycling also lets a still-in-flight mount settle first, since disabling alone is not a barrier. +Together these rule out configuring rows from a service in one pass, and rule in the phased mount: rows keep their own `inject` and their own config, and the only thing the launcher does between phases is apply the composition again. ## Alternatives considered -- **Releasing the rows by clearing their `inject`** (one atomic update per row): it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. -- **Reading flags from the row's config through `!!js ctx.get('webStartup')`**: config expressions are interpolated when the fiber is created, before the startup service exists, so every waiting row would read `undefined`. -- **The launcher running each bundle's startup function before boot** (no cordis involvement): simplest and strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. The maintainer's ruling was a startup *service* other rows depend on, which keeps one protocol. +- **Writing the resolved values into each row** (a config update per row, plus a patch layer handed back to the launcher so a reload could not undo it): it worked, but it meant patches travelling from an app to the launcher and back, two mechanisms for one fact, and a recycle whose correctness depended on Loader restart internals. The maintainer rejected the round trip; the service the rows read replaced all of it. +- **Releasing rows by clearing their `inject`**: it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. +- **Rows waiting on the service in a single-pass mount**: the config expressions are interpolated before any row exists, so every reader would see `undefined`. +- **The launcher running each bundle's startup function before boot** (no cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. Declaring an entrypoint *row* keeps one protocol: the entrypoint is an ordinary row, dumpable and patchable, and a layering bundle disables it like any other. - **Both apps parsing the same argv** (the one-shot bundle rides over the web bundle): two parsers cannot both own `-h`. A composition has exactly one command-line owner: the layering bundle disables the underlying startup row and names both startup services, so the absorbed rows start on their composed values. - **`instanceof CommanderError`**: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed `--help` was rethrown as a fatal load failure. Commander's control-flow errors are detected structurally instead. ## Consequences - An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change. -- `--help` cost is a boot: the tree mounts far enough for the startup row to run, then tears down. The rows waiting on that app never start, which is what the maintainer accepted when choosing the service-shaped design. -- A startup service has no statically declared owner: a bundle shipping waiting rows without its startup row fails at settlement with pending entries naming the service, not at load. +- `--help` mounts only the entrypoints and exits, so nothing else in the composition ever starts. +- A startup service has no statically declared owner: a bundle shipping reading rows without its entrypoint fails at settlement with pending entries naming the service, not at load. +- A user patch that replaces a row's whole `config` drops its expressions, and with them the flag's precedence for that row. - Launcher flags must precede app arguments; a first app argument reading `web` or `plugin` selects those subcommands instead, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`. - `--dump-config` never runs a startup row, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index f17fdc9a78..48782fbb9c 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -12,34 +12,39 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 -新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appPatches`。应用从**启动行**消费它们:启动行注入 `cmdlineArgs`,并以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`;应用所配置的行在组合包 patch 中注入这个启动服务,因此在取值解析完成之前无法启动,而 `--help` 会打印文本、禁用这些行并退出,应用自始至终不会启动。 +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)、`ctx.appExit` 和 `ctx.appReady`。应用从自己的**入口点行**消费它们——该行由其组合包 manifest(元数据清单)点名(`dsh.bundle.entrypoint`),注入 `cmdlineArgs`,以自己的 commander program 调用 `runStartup(ctx, service, program, plan)`,再把解析结果作为自己的服务提供出去。应用所配置的行从各自的配置表达式中读取该服务(`port: !!js ctx.get('webStartup')?.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 + +boot 分两趟挂载,这正是 manifest 声明所换来的:先是各入口点,然后才是整套组合。行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答,因此配置树的其余部分必须在入口点起来之后才施加。于是 `--help` 在第二趟存在之前就退出;用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新施加,因此已经服务中的端口不会被悄悄重置。 已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 -评审中还落出两条后果。应用的决策同时以 patch 的形式交还给启动器(`ctx.appPatches`),因为用户编辑一个活动的 patch 文件时,启动器会重新施加自己的整个 patch 栈:没有这一层,一次无关的编辑就会把每一行都从其组合出的选项重建出来,把一台以 `--port 8080` 启动的服务器悄悄挪回组合出的端口,并连带丢掉 `--dev` 和由此派生的 `/api` 围栏 authority。另外,`dsh --profile web` 现在也会加上过去只有 `dsh web` 别名才会加的 harness 源码提示词章节 —— 两条路径终于以完全相同的方式引导,这也意味着名为 `web` 的用户 profile 会继承它。 +还有两条后果。Loader 结算不再意味着「应用已经起来」——在第二趟中挂载的行可能看到一棵已结算的树,而挂载它的那一趟仍在进行,甚至已经在回滚——因此公布就绪信号的行(web 的 URL 行)改为等待 `ctx.appReady`。另外,`dsh --profile web` 现在也会加上过去只有 `dsh web` 别名才会加的 harness 源码提示词章节:两条路径终于以完全相同的方式引导,这也意味着名为 `web` 的用户 profile 会继承它。 -## 等待中的行实际如何拿到自己的取值 +## 为什么 boot 分阶段 -vendored Loader 的三个事实塑造了这套机制,三者都是靠探针试出来的: +vendored Loader 的四个事实塑造了这套机制,它们都是靠探针试出来的: -- **行的配置在 Loader 创建其 fiber 时就已解析,而这发生在它仍在等待自己启动服务的时候。** 把新配置写到这个等待中的 fiber 上,永远到不了插件。因此每个改动过的行都会被回收重建:先禁用,再带着新取值重新启用,从而丢弃陈旧的 fiber 并重新解析配置。 -- **更新一行的 `inject` 会丢失插件自身的静态注入。** Loader 从 `runtime.callback`(未经包装的函数)重启被替换的行,此时 `Inject.resolve(plugin.inject)` 什么也找不到:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。因此回收重建绝不触碰 `inject`;等待中的行是靠提供服务来放行的。 -- **行的配置同样在 fiber 创建时被校验**,因此一个*必填*配置由启动流程提供的行(一次性运行器的 `task`)必须以 `disabled: true` 交付;只让它等待并不够,因为 boot 会在启动行得以运行之前就失败。它之所以看起来能工作,只是因为启动模块碰巧先被 import。 +- **profile 的各行是作为根 include 的 `patches` 选项送达的,而一个条目的整份配置会在该条目启动时被插值。** 因此每一行里的每个 `!!js` 都会在 include 挂载时一次性求值——早于任何行的存在。位于根配置*文件*中的行会逐行插值,但 profile 的根按设计就是空的。 +- **严格的 `ctx.get` 会隐藏提供方 fiber 尚未 ACTIVE 的服务**,而插件自身的 fiber 在其 `apply` 仍在运行时并未 active。在同一趟里既提供服务又用它配置各行,是不可能成立的。 +- **更新一行的 `inject` 会丢失插件自身的静态注入。** Loader 从 `runtime.callback`(未经包装的函数)重启被替换的行,此时 `Inject.resolve(plugin.inject)` 什么也找不到:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。 +- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,由与它同趟挂载的行来启用(`dsh web --dev` 及其重载链路)。 -还有一条相关约束:不能从正在挂载的插件内部插入一行(`tree.create` 返回一个带前缀的 id,随后它自己解析不出来),因此条件性的行以 `disabled: true` 交付,由启动流程启用。回收重建还会先让某次仍在进行中的挂载结算完毕,因为单靠禁用并不构成屏障。 +这些事实合起来排除了「一趟之内用服务配置各行」,并确立了分阶段挂载:各行保留自己的 `inject` 和自己的配置,而启动器在两阶段之间所做的,仅仅是再施加一次组合。 ## 曾考虑的替代方案 -- **通过清空行的 `inject` 来放行**(每行一次原子更新):孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 -- **通过 `!!js ctx.get('webStartup')` 从行配置中读取 flag**:配置表达式在 fiber 创建时求值,早于启动服务存在,因此每个等待中的行都会读到 `undefined`。 -- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):最简单,而且严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。维护者的裁定是做成其他行所依赖的启动*服务*,从而只保留一套协议。 +- **把解析出的取值写进每一行**(逐行一次配置更新,外加交还给启动器的一层 patch,使重载无法撤销它):它能工作,但这意味着 patch 在应用与启动器之间来回传递、同一件事有两套机制,以及一套其正确性依赖 Loader 重启内部细节的回收重建。维护者否决了这次往返;供各行读取的服务取代了这一切。 +- **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 +- **在单趟挂载中让各行等待该服务**:配置表达式在任何行存在之前就已插值,因此每个读取方都会看到 `undefined`。 +- **由启动器在 boot 之前运行每个组合包的启动函数**(完全不经过 cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。声明一个入口点*行*则只保留一套协议:入口点就是一个普通的行,可 dump、可 patch,叠加的组合包也能像禁用其他行那样禁用它。 - **两个应用解析同一份 argv**(一次性组合包叠加在 web 组合包之上):两个解析器不可能同时持有 `-h`。一套组合有且只有一个命令行所有者:叠加的组合包禁用下层的启动行,并同时提供这两个启动服务,使被吸收的行按组合后的取值启动。 - **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 ## 后果 - 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 -- `--help` 的代价是一次 boot:配置树挂载到足以运行启动行,随后拆除。等待该应用的行从不启动,这正是维护者选择服务形态的设计时所接受的代价。 -- 启动服务没有静态声明的所有者:交付了等待中的行却缺少对应启动行的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- `--help` 只挂载各入口点然后退出,组合中的其余部分从不启动。 +- 启动服务没有静态声明的所有者:交付了读取行却缺少对应入口点的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。 - 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好是 `web` 或 `plugin`,选中的将是这两个子命令,而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 - `--dump-config` 从不运行启动行,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 64c7722a3c..7cd481e6d5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2546,7 +2546,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:32`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:36`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 854da5646a..e5596229ae 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -39,6 +39,7 @@ export { PROFILES_DIR, readProfileManifest, resolveBundleDir, + resolveEntrypoints, resolveProfileDir, writeProfileManifest, type DshBundleManifest, @@ -527,6 +528,31 @@ export async function mountRootInclude( return entry } +/** + * Re-apply the root include's patch list on a booted tree, and wait for the + * result to settle. + * + * This is how a boot mounts its composition in phases: an app's entrypoint row + * resolves what the rest of the tree reads (`!!js ctx.get('webStartup')?.port`), + * and a row's config expressions are evaluated when the include applies them — + * so the rest of the composition must be applied after the entrypoints are + * active, not before. + * @param ctx - the booted context whose root include to re-apply. + * @param patches - the full patch list for this generation. + * @returns nothing once the new generation has settled; a disposed tree is a no-op. + * @throws when the tree was booted without the root include. + */ +export async function applyRootPatches(ctx: Context, patches: readonly PatchOptions[]): Promise { + const entry = bootstrapIncludes.get(ctx) + if (entry === undefined) throw new Error('dsh: applying root patches requires the root Include entry') + // A surface can dispose the whole tree while an entrypoint is still parsing + // (`--help`, or an early SIGTERM); there is then nothing left to mount. + if (ctx.get('loader') === undefined) return + const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config + await entry.update({ config: { ...includeConfig, patches: [...patches] } }) + await ctx.get('loader')?.await() +} + /** * The slice of `process` {@link installFailLoud} needs — injectable so tests * exercise the handler without registering on (or exiting) the real process. diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index e19bb13c41..e105287808 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -42,6 +42,16 @@ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml' export interface DshBundleManifest { /** The patch layer this bundle exports, relative to its package root. */ patch: string + /** + * Id of the row in that patch which must run before every other row of the + * composition — the app's entrypoint. + * + * An entrypoint resolves what the rest of the tree needs in order to be + * configured at all (the command line an app was invoked with), and provides + * it as a service. The boot mounts entrypoints alone first, so by the time + * any other row's config is resolved, `ctx.get('')` answers. + */ + entrypoint?: string } /** The profile half of the `dsh` manifest section: what a profile directory composes. */ @@ -79,6 +89,37 @@ export interface ProfileLayer { patchPath: string /** The parsed patch list. */ patches: PatchOptions[] + /** Row id this bundle declares as its entrypoint, when it has one. */ + entrypoint?: string +} + +/** + * The composition's entrypoint row ids, in bundle order. + * @param binName - the diagnostic prefix on the thrown error. + * @param profile - the loaded profile. + * @param rows - the composed rows, so an entrypoint a later layer removed or + * disabled is not mounted (the one-shot bundle takes over the web one this way). + * @returns the row ids to mount before the rest of the tree. + * @throws when a bundle declares an entrypoint its own patch never inserts. + */ +export function resolveEntrypoints( + binName: string, + profile: Profile, + rows: readonly { id?: string; disabled?: boolean | null }[], +): string[] { + const entrypoints: string[] = [] + for (const layer of profile.layers) { + if (layer.entrypoint === undefined) continue + const row = rows.find(candidate => candidate.id === layer.entrypoint) + if (row === undefined) { + throw new Error( + `${binName}: bundle ${JSON.stringify(layer.packageName)} declares entrypoint ${JSON.stringify(layer.entrypoint)}, ` + + 'which the composed tree has no row for', + ) + } + if (row.disabled !== true) entrypoints.push(layer.entrypoint) + } + return entrypoints } /** A loaded profile: resolved bundle layers plus the user's own patch layer. */ @@ -391,7 +432,14 @@ export function loadProfile( throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`) } const patchPath = join(packageDir, declared) - return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) } + const entrypoint = bundleManifest.dsh?.bundle?.entrypoint + return { + packageName, + packageDir, + patchPath, + patches: loadOverlayPatches(binName, patchPath), + ...entrypoint === undefined ? {} : { entrypoint }, + } }) const patchPath = join(dir, PROFILE_PATCH_FILENAME) const patches = options.userLayer !== false && existsSync(patchPath) diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index bd0294475d..48166042f0 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -17,6 +17,7 @@ import { PROFILE_TEMPLATES, readProfileManifest, resolveBundleDir, + resolveEntrypoints, resolveProfileDir, writeProfileManifest, } from '../src/index.ts' @@ -197,6 +198,37 @@ describe('loadProfile', () => { }) }) +describe('resolveEntrypoints', () => { + const profile = (layers: { packageName: string; entrypoint?: string }[]): Parameters[1] => ({ + name: 'p', + dir: '/p', + patchPath: '/p/cordis.patch.yml', + patches: [], + layers: layers.map(layer => ({ ...layer, packageDir: '/b', patchPath: '/b/cordis.patch.yml', patches: [] })), + }) + + it('names each bundle entrypoint in bundle order', () => { + expect(resolveEntrypoints( + 'dsh', + profile([{ packageName: 'a' }, { packageName: 'b', entrypoint: 'b-startup' }, { packageName: 'c', entrypoint: 'c-startup' }]), + [{ id: 'b-startup' }, { id: 'c-startup' }, { id: 'other' }], + )).toEqual(['b-startup', 'c-startup']) + }) + + it('skips an entrypoint a later layer disabled, which is how one app takes over another', () => { + expect(resolveEntrypoints( + 'dsh', + profile([{ packageName: 'web', entrypoint: 'web-startup' }, { packageName: 'one-shot', entrypoint: 'one-shot-startup' }]), + [{ id: 'web-startup', disabled: true }, { id: 'one-shot-startup' }], + )).toEqual(['one-shot-startup']) + }) + + it('fails loud when a bundle declares an entrypoint its patch never inserts', () => { + expect(() => resolveEntrypoints('dsh', profile([{ packageName: 'b', entrypoint: 'absent' }]), [{ id: 'other' }])) + .toThrow('declares entrypoint "absent", which the composed tree has no row for') + }) +}) + describe('composeEntries', () => { it('applies layers over an empty root and reports skipped patches', () => { const warnings: string[] = [] diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 2e67bd08f8..a55d2d246f 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -13,7 +13,9 @@ import { Context } from '@deepseek-ai/cordis' import Hmr from '@deepseek-ai/cordis-plugin-hmr' import Loader from '@deepseek-ai/cordis-plugin-loader' import Timer from '@deepseek-ai/cordis-plugin-timer' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { + applyRootPatches, boot, loadOptionalPatches, PROFILE_PATCH_FILENAME, @@ -92,23 +94,81 @@ describe('loadOptionalPatches', () => { }) }) -describe('boot with user patches', () => { - function writeTree(dir: string): string { - writeFileSync(join(dir, 'noop.mjs'), [ - 'export const name = "noop"', - 'export function apply(_ctx, config = {}) {', - ' if (config.fail) throw new Error("candidate config failed")', - '}', +function writeTree(dir: string): string { + writeFileSync(join(dir, 'noop.mjs'), [ + 'export const name = "noop"', + 'export function apply(_ctx, config = {}) {', + ' if (config.fail) throw new Error("candidate config failed")', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') + return join(dir, 'cordis.yml') +} + +function entryConfig(ctx: Context, id: string): unknown { + return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config +} + +describe('applyRootPatches', () => { + it('mounts a later phase whose rows read what the first phase provided', async () => { + // The phased boot in one test: a row's `!!js` config is evaluated when the + // include applies it, so a value an earlier phase provided is what a later + // phase's rows read. + const dir = tmp() + writeFileSync(join(dir, 'provider.mjs'), [ + 'export const name = "provider"', + 'export function apply(ctx) { ctx.provide("phaseOne", { value: "resolved" }) }', '', ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') - return join(dir, 'cordis.yml') - } + writeFileSync(join(dir, 'reader.mjs'), [ + 'export const name = "reader"', + 'export const inject = ["phaseOne"]', + 'export function apply() {}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const composition: PatchOptions[] = [{ + insert: [ + { id: 'provider', name: './provider.mjs' }, + { + id: 'reader', + name: './reader.mjs', + inject: ['phaseOne'], + config: { value: { __jsExpr: "ctx.get('phaseOne')?.value ?? 'fallback'" } }, + }, + ], + }] + const ctx = await boot(NAME, join(dir, 'cordis.yml'), [ + ...structuredClone(composition), + { id: 'reader', disabled: true }, + ]) + try { + // Phase one leaves the reader disabled, so the plugin never ran. + const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader') + expect(reader?.fiber).toBeUndefined() + await applyRootPatches(ctx, structuredClone(composition)) + // Phase two evaluates its config expression against the provided value. + expect(entryConfig(ctx, 'reader')).toEqual({ value: 'resolved' }) + } finally { + await ctx.fiber.dispose() + } + }) - function entryConfig(ctx: Context, id: string): unknown { - return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config - } + it('does nothing on a tree that was already disposed', async () => { + const dir = tmp() + const ctx = await boot(NAME, writeTree(dir)) + await ctx.fiber.dispose() + await expect(applyRootPatches(ctx, [])).resolves.toBeUndefined() + }) + it('fails loud when the tree was booted without the root include', async () => { + const ctx = new Context() + await expect(applyRootPatches(ctx, [])).rejects.toThrow('requires the root Include entry') + }) +}) + +describe('boot with user patches', () => { it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { const dir = tmp() const userDir = tmp() diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index f1e5a30951..7c986b0d26 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -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/ui/cmdline/README.md -README.md: 3d7aa7fd58c7e542ac0c733eb0794436cb0fc42d -README.zh.md: d6eb191e1c0c8136a613d5e9fe29bb66420139ac +README.md: acdc3a310f0062f1b27dbd74d20b81e1a8198bca +README.zh.md: 365a2c7f3cdf5710ce7e3abe76f009dc1ba4217f diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 3d7aa7fd58..acdc3a310f 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -4,57 +4,59 @@ English | [中文](README.zh.md) The command line a dsh launcher hands to the app it boots. The launcher parses only its own flags (`--profile`, `--patch`, the config dumps) and hands **everything after them** to the tree verbatim, so an app owns its flag family, its `--help` text, and its parse errors instead of the launcher knowing them. -## The three launcher values +## The launcher values A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which provides: - `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`. - `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller. -- `ctx.appPatches` — where a startup row records its decisions, for a launcher that recomposes its tree. Omitted by a host that never does. +- `ctx.appReady` — settles when the launcher has finished mounting, for a row that publishes readiness (a URL line a supervisor waits for). An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. -## Startup rows and the services their rows wait for +## Entrypoints, and the service their app reads -An app reads those arguments from a **startup row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: +An app reads those arguments from its **entrypoint row** — a plugin that injects `cmdlineArgs` and calls `runStartup(ctx, service, program, plan)`: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] -export function apply(ctx: Context): Promise { - return runStartup(ctx, 'webStartup', webCommand(), planWebStartup) +export function apply(ctx: Context): void { + runStartup(ctx, 'webStartup', webCommand(), planWebStartup) } ``` -Every row the app configures from flags injects that startup service in the bundle patch: +The bundle's `package.json` names that row, which is what makes the boot mount it before everything else: + +```json +{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } } +``` + +Every row the app configures from flags then reads what the entrypoint resolved, naming the key it takes and the value it falls back to: ```yaml - id: webserver name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: 127.0.0.1 - port: 3080 + host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' + port: !!js ctx.get('webStartup')?.port ?? 3080 ``` -`runStartup` parses the arguments, asks `plan` what each waiting row's values should be, applies them, and provides the startup service, which is what lets those rows start. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, disables the waiting rows, and requests exit — the app never starts, and the settlement audit sees a tree that was asked not to start it. +`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, and the rest of the composition never mounts. -`plan` receives every waiting row's **composed** options, so a decision reads what the bundle patches and the user's own layers agreed on before overriding it; `overrideConfig(row, { port })` replaces exactly the named keys. A row absent from the plan starts on its composed values, and planning a change for a row also enables it. +`plan` receives the options of every row that injects the service, for a value that has to take the composition into account: the `/api` fence authorities are the shipped example, since a bind the composition configured decides whether LAN literals are derived at all. -A row whose required config the startup **supplies** rather than overrides must ship `disabled: true`, because a waiting row's config is validated when its fiber is created — before the startup service arrives — and a missing required key fails the boot there. The one-shot runner's `task` is the shipped example. A row shipped disabled for another reason is turned on the same way: `dsh web --dev` plans `{ disabled: false }` for the HMR receiver. +### Why the boot has phases -The decisions also reach the launcher through `ctx.appPatches`, which is what keeps them alive across a recomposition: without it, a user editing a live patch file would rebuild every row from its composed options and silently move a server started on `--port 8080` back to the composed port. +A row's config expressions are evaluated when the include applies it, and a strict `ctx.get` only answers for a service whose providing fiber is already active. A composition therefore mounts in two passes: the entrypoints alone, then everything else — which is exactly what the manifest declaration buys. The rows of a later pass read live values, a `--help` exits before the second pass exists, and a user editing a live patch file re-runs that pass against services that are still up, so a flag cannot be silently reset. -### Why a changed row is recycled - -A waiting row's config is resolved when the Loader creates its fiber, which happens while the row is still waiting. Writing a new config onto that fiber never reaches the plugin, so each changed row is disabled and re-enabled, which drops the stale fiber and resolves the config again. A row whose own mount is still in flight is allowed to settle first, so the disable has a fiber to dispose instead of racing one into existence. - -Recycling deliberately leaves `inject` alone. Updating a row's `inject` restarts it from its unwrapped callback, which loses the plugin's own static injections — a row that declares `inject = ['httpServer', 'apiProxy']` would come back unable to read either. +`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). Call it from a row that mounts beside the one being enabled, not from an entrypoint: a row enabled in the first pass would wait for services the second pass has yet to mount. ### One command line, one owner -A composition has exactly one command-line owner. An app that layers over another one disables the underlying startup row and names both startup services, so the rows it absorbed start on their composed values — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md). +A composition has exactly one command-line owner. An app that layers over another one disables the underlying entrypoint row and names both services, so the rows it absorbed start on the values their own fallbacks name — [`dsh-headless`](../../bundle/headless/README.md) does this over [`dsh-web-app`](../../bundle/web-app/README.md). 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. @@ -69,4 +71,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.** The rows name it and a startup row provides it; nothing links the two statically, so a bundle that ships waiting rows without its startup row fails at settlement (pending entries naming the service) rather than at load. +- **A startup service has no declared owner.** The rows name it and an entrypoint provides it; nothing links the two statically, so a bundle that ships reading rows without its entrypoint fails at settlement (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. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index d6eb191e1c..365a2c7f3c 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -4,57 +4,59 @@ dsh 启动器交给它所引导应用的那条命令行。启动器只解析属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给配置树,因此 flag 家族、`--help` 文本和解析错误都由应用自己持有,启动器不必知道它们。 -## 启动器提供的三个值 +## 启动器提供的值 启动器在任何配置树条目挂载之前调用 `provideCmdline(ctx, host)`,它提供: - `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 - `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 -- `ctx.appPatches`:启动行记录自身决策的去处,面向会重新组合自己配置树的启动器。从不重新组合的宿主不提供它。 +- `ctx.appReady`:在启动器挂载完毕时结算,供需要公布就绪信号的行使用(例如督程会等待的 URL 行)。 没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 -## 启动行,以及各行所等待的服务 +## 入口点,以及它的应用所读取的服务 -应用从**启动行**读取这些参数:启动行是一个注入 `cmdlineArgs` 并调用 `runStartup(ctx, service, program, plan)` 的插件: +应用从自己的**入口点行**读取这些参数:入口点行是一个注入 `cmdlineArgs` 并调用 `runStartup(ctx, service, program, plan)` 的插件: ```ts ignore export const name = 'web-startup' export const inject = ['cmdlineArgs'] -export function apply(ctx: Context): Promise { - return runStartup(ctx, 'webStartup', webCommand(), planWebStartup) +export function apply(ctx: Context): void { + runStartup(ctx, 'webStartup', webCommand(), planWebStartup) } ``` -应用用 flag 配置的每一行,都在组合包 patch 中注入那个启动服务: +组合包的 `package.json` 点名那一行,这正是 boot 先于其他一切挂载它的依据: + +```json +{ "dsh": { "bundle": { "patch": "./cordis.patch.yml", "entrypoint": "web-startup" } } } +``` + +应用用 flag 配置的每一行随后读取入口点解析出的取值,各自点名自己取用的键,以及回退时使用的值: ```yaml - id: webserver name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: 127.0.0.1 - port: 3080 + host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' + port: !!js ctx.get('webStartup')?.port ?? 3080 ``` -`runStartup` 解析参数,向 `plan` 询问每个等待中的行应有的取值,应用这些取值,然后提供启动服务,正是这一步让这些行得以启动。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本,禁用等待中的行并请求退出:应用从不启动,结算审计看到的是一棵被要求不要启动它的树。 +`runStartup` 解析参数,向 `plan` 索取取值,并把它们作为服务提供出去。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本并请求退出:什么也不会被提供,组合的其余部分也从不挂载。 -`plan` 收到的是每个等待中的行**组合后**的选项,因此决策在覆盖之前能读到组合包 patch 与用户自己那几层达成的结果;`overrideConfig(row, { port })` 只替换点名的那些配置键。plan 中未出现的行按组合后的取值启动;而为某一行 plan 了改动,也会顺带启用它。 +`plan` 收到的是所有注入该服务的行的选项,用于那些必须顾及组合本身的取值:随附的例子是 `/api` 栅栏 authority,因为组合所配置的 bind 决定了是否要派生 LAN 字面量。 -必填配置由启动流程**供给**而非覆盖的行,必须以 `disabled: true` 交付,因为等待中的行的配置在其 fiber 创建时就会被校验(此时启动服务尚未到达),缺少一个必填键会在那里就让 boot 失败。一次性运行器的 `task` 就是随附的例子。因其他原因以禁用状态交付的行也以同样方式打开:`dsh web --dev` 为 HMR(热模块替换)接收方 plan 了一个 `{ disabled: false }`。 +### 为什么 boot 分阶段 -这些决策同时经 `ctx.appPatches` 到达启动器,正是这一点让它们在一次重新组合中存活下来:没有它,用户编辑一个活动的 patch 文件就会把每一行都从其组合后的选项重建出来,并悄悄把一台以 `--port 8080` 启动的服务器挪回组合后的端口。 +行的配置表达式在 include 施加该行时求值,而严格的 `ctx.get` 只对提供方 fiber 已经 active 的服务作答。因此一套组合分两趟挂载:先是各入口点,然后才是其余部分——这正是 manifest(元数据清单)声明所换来的东西。后一趟的行读到的是活的取值,`--help` 在第二趟存在之前就退出,而用户编辑一个活动的 patch 文件时,这一趟会针对仍然在线的服务重新运行,因此 flag 不会被悄悄重置。 -### 为什么改动过的行要回收重建 - -等待中的行的配置在 Loader 创建它的 fiber 时就已解析,而这发生在该行仍在等待的时候。把新配置写到这个 fiber 上,永远到不了插件,因此每个改动过的行都会先禁用再重新启用,从而丢弃陈旧的 fiber 并重新解析配置。自身挂载仍在进行中的行会先被放行至停稳,这样禁用时才有一个 fiber 可供 dispose(资源释放),而不是与一个正在诞生的 fiber 抢跑。 - -回收重建刻意不动 `inject`。更新一行的 `inject` 会让它从未经包装的回调重新启动,从而丢失插件自身的静态注入:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。 +`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。要从与被启用行同一趟挂载的行里调用它,而不是从入口点:在第一趟被启用的行会去等待第二趟才挂载的服务。 ### 一条命令行,一个所有者 -一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并同时点名两个启动服务,使它吸收过来的行按组合后的取值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 +一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的入口点行,并同时点名两个服务,使它吸收过来的行按各自回退值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 @@ -69,4 +71,5 @@ export function apply(ctx: Context): Promise { ## 已知限制与延期工作 - **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 -- **启动服务没有声明所有者**:各行点名它,由启动行提供它;两者之间没有静态关联,因此交付了等待中的行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 +- **启动服务没有声明所有者**:各行点名它,由入口点提供它;两者之间没有静态关联,因此交付了读取行却缺少对应入口点的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 +- **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index 3dce71079e..43c3b9ec58 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -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: []` 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 } } -/** 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 } /** - * 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, '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 +export type StartupPlan = (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('')?.` 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( ctx: Context, services: string | readonly string[], program: Command, - plan: StartupPlan = () => new Map(), -): Promise { + plan: StartupPlan = (() => ({}) 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 - 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 { - 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): RowChange { - return { config: { ...(options.config ?? {}) as Record, ...overrides } } +export async function enableRow(ctx: Context, id: string): Promise { + 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 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)) } diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index b4ea1a3624..ee405bb135 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -1,8 +1,8 @@ /** - * The launcher-to-app command line over a REAL Loader tree: a startup row parses the - * invocation's inner arguments and releases the rows waiting for it, waiting rows start - * with the resolved values, `--help` leaves the app unstarted, and a - * bundle whose patch and startup plugin disagree fails loud. + * The launcher-to-app command line over a REAL Loader tree, mounted the way a + * profile boot mounts it: the entrypoint row first, then the rest of the + * composition, whose rows read the entrypoint's values from their own config + * expressions. `--help` never reaches that second phase. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -13,12 +13,14 @@ import { Command } from 'commander' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' +import type { PatchOptions } from '@cordisjs/plugin-include' import { afterEach, describe, expect, it } from 'vitest' -import { internals, overrideConfig, provideCmdline, runStartup, type RowChange, type StartupPlan } from '../src/index.ts' +import { internals, provideCmdline, runStartup, type StartupPlan } from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ interface Observed { - applied: { id: string; config: Record }[] + /** Config the reading row started with; absent means it never started. */ + started?: Record exits: number[] out: string } @@ -27,13 +29,8 @@ interface Observed { interface Fixture { observed: Observed ctx: Context - /** Patches the startup row handed the launcher for later compositions. */ - contributed: unknown[] } -/** Cordis FiberState.ACTIVE, mirrored because the const enum has no runtime object. */ -const FIBER_ACTIVE = 2 - const disposers: (() => Promise)[] = [] afterEach(async () => { @@ -42,202 +39,145 @@ afterEach(async () => { internals.stderr = process.stderr }) -/** The fixture's flag family: one `--port` over the waiting row's composed config. */ +/** The fixture app's flag family: one `--port` its rows read from the service. */ function demoCommand(): Command { return new Command().name('demo').exitOverride().option('--port ', 'listen port') } -/** The fixture's plan: `--port` overrides the waiting row, absent leaves it composed. */ -const demoPlan: StartupPlan = (program, rows) => { +/** The fixture app's plan: the resolved values its rows read. */ +const demoPlan: StartupPlan<{ port?: number }> = (program) => { const port = program.opts<{ port?: string }>().port - if (port === undefined) return new Map() + if (port === undefined) return {} if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) - const row = rows.find(candidate => candidate.id === 'waiting') - return new Map(row === undefined ? [] : [['waiting', overrideConfig(row, { port: Number(port) })]]) + return { port: Number(port) } } +/** A YAML `!!js` expression node, as the include parses one out of a patch file. */ +const expression = (source: string): unknown => ({ __jsExpr: source }) + /** - * Mount a tree with one waiting row, and — unless the caller drives startup - * itself — a startup row that calls {@link runStartup} on this package's real - * code path. + * Mount a two-row composition the way a profile boot does: the entrypoint row + * alone first, then everything. * @param args - the invocation's inner arguments. - * @param options - fixture knobs for the shapes a bundle patch can produce. + * @param plan - the app's plan; defaults to the fixture's own. * @returns the booted fixture. */ async function bootFixture( args: string[], - options: { injectObjectForm?: boolean; withoutStartupRow?: boolean; slowWaitingImport?: boolean } = {}, + plan: StartupPlan = demoPlan, + options: { withoutEntrypoint?: boolean } = {}, ): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) - const observed: Observed = { applied: [], exits: [], out: '' } - writeFileSync(join(dir, 'waiting.mjs'), ` -${options.slowWaitingImport === true ? 'await new Promise(resolve => setTimeout(resolve, 30))' : ''} -export const name = 'waiting' -export function apply(ctx, config) { globalThis.__observed.applied.push({ id: 'waiting', config }) } + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'reader.mjs'), ` +export const name = 'reader' +export const inject = ['demoStartup'] +export function apply(ctx, config) { globalThis.__observed.started = config } `) // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real function the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'startup.mjs'), ` -export const name = 'startup' + writeFileSync(join(dir, 'entrypoint.mjs'), ` +export const name = 'demo-startup' export const inject = ['cmdlineArgs'] export function apply(ctx) { return globalThis.__runStartup(ctx) } `) - writeFileSync(join(dir, 'cordis.yml'), [ - '- id: waiting', - ` name: ${pathToFileURL(join(dir, 'waiting.mjs')).href}`, - options.injectObjectForm === true ? ' inject: { demoStartup: null }' : ' inject: [demoStartup]', - ' config:', - ' port: 3080', - ' host: 127.0.0.1', - ...options.withoutStartupRow === true ? [] : [ - '- id: startup', - ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, - ], - '', - ].join('\n')) + 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) => Promise } + const globals = globalThis as unknown as { __observed: Observed; __runStartup: (ctx: Context) => void } globals.__observed = observed - globals.__runStartup = (ctx: Context) => runStartup(ctx, 'demoStartup', demoCommand(), demoPlan) + globals.__runStartup = (ctx: Context) => { runStartup(ctx, 'demoStartup', demoCommand(), plan) } - const contributed: unknown[] = [] + // The composition, exactly as a profile delivers one: include patches whose + // config carries `!!js` expressions. + const composition: PatchOptions[] = [{ + insert: [ + ...options.withoutEntrypoint === true + ? [] + : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'entrypoint.mjs')).href }], + { + id: 'reader', + name: pathToFileURL(join(dir, 'reader.mjs')).href, + inject: ['demoStartup'], + config: { port: expression("ctx.get('demoStartup')?.port ?? 3080") }, + }, + ], + }] const ctx = new Context() await ctx.plugin(Loader) ctx.loader.builtins.include = Include - provideCmdline(ctx, { - args, - exit: code => void observed.exits.push(code), - contribute: patches => void contributed.push(...patches), + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) + const rootConfig = { path: pathToFileURL(join(dir, 'cordis.yml')).href } + // Phase one: the entrypoint alone. + const includeId = await ctx.loader.create({ + name: 'cordis:include', + config: { ...rootConfig, patches: [...structuredClone(composition), { id: 'reader', disabled: true }] }, }) - await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - return { observed, ctx, contributed } + if (observed.exits.length === 0) { + // Phase two: the whole composition, now that the entrypoint's values answer. + await ctx.loader.resolve(includeId).update({ config: { ...rootConfig, patches: structuredClone(composition) } }) + await ctx.loader.await() + } + return { observed, ctx } } describe('runStartup', () => { - it('starts a waiting row only after the startup service arrives, with the flag value applied over its composed config', async () => { + it('lets a row read the flag value the app resolved', async () => { const { observed } = await bootFixture(['--port', '8080']) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) + expect(observed.started).toEqual({ port: 8080 }) expect(observed.exits).toEqual([]) }) - it('starts the waiting row unchanged when the invocation carries no flags', async () => { + it('leaves a row on the value written beside the expression when no flag names one', async () => { const { observed } = await bootFixture([]) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 3080, host: '127.0.0.1' } }]) + expect(observed.started).toEqual({ port: 3080 }) }) - it('applies the flag value to a row whose own mount was still in flight', async () => { - // The row has no fiber yet when startup disables it, so the disable is not - // a barrier: the in-flight mount still produces one. Without disposing - // that late fiber, the row would start on its composed port. - const { observed } = await bootFixture(['--port', '8080'], { slowWaitingImport: true }) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) - }) - - it('starts a row that injects the startup service in the intercept-map form of inject', async () => { - const { observed } = await bootFixture(['--port', '8080'], { injectObjectForm: true }) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) - }) - - it('prints the app help, leaves the app unstarted, and requests exit 0', async () => { + it('prints the app help, starts no reading row, and requests exit 0', async () => { const { observed } = await bootFixture(['--help']) expect(observed.out).toContain('Usage: demo') - expect(observed.applied).toEqual([]) + expect(observed.started).toBeUndefined() expect(observed.exits).toEqual([0]) }) it('rejects the invocation from the plan without starting the app', async () => { const { observed } = await bootFixture(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') - expect(observed.applied).toEqual([]) + expect(observed.started).toBeUndefined() expect(observed.exits).toEqual([1]) }) -}) - -describe('startup-service lifetime', () => { - it('unloads the waiting rows when the startup row is disposed, and reopens on a fresh run', async () => { - // The startup service is an effect of the startup row: HMR restarting that - // row must take its app down with it, then bring it back. - const { ctx, observed } = await bootFixture(['--port', '8080']) - const startup = [...ctx.loader.entries()].find(entry => entry.options.id === 'startup') - const waiting = [...ctx.loader.entries()].find(entry => entry.options.id === 'waiting') - expect(waiting?.fiber?.state).toBe(FIBER_ACTIVE) - await startup?.update({ disabled: true }) - expect(waiting?.fiber?.state).not.toBe(FIBER_ACTIVE) - await startup?.update({ disabled: false }) - await ctx.loader.await() - expect(waiting?.fiber?.state).toBe(FIBER_ACTIVE) - // The second run re-resolved the same arguments, so the row is back on the - // flag value rather than the composed one. - expect(observed.applied.at(-1)).toEqual({ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }) - }) -}) - -describe('runStartup rejects a bundle that disagrees with its own patch', () => { - it('fails when no row declares the startup service it provides', async () => { - // The patch and its startup plugin disagree; a silent no-op would leave - // the app's rows waiting forever with no explanation. - const { ctx } = await bootFixture([], { withoutStartupRow: true }) - await expect(runStartup(ctx, 'absentStartup', demoCommand(), demoPlan)) - .rejects.toThrow('absentStartup: no row injects this startup service') - }) - - it('fails when the plan names a row that is not waiting', async () => { - const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) - const plan: StartupPlan = () => new Map([['not-waiting', {}]]) - await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)) - .rejects.toThrow('startup planned changes for row(s) not-waiting') - expect(observed.applied).toEqual([]) - }) it('rethrows a plan failure that is not commander asking to exit', async () => { - const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) const plan: StartupPlan = () => { throw new Error('plan exploded') } - await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)).rejects.toThrow('plan exploded') - expect(observed.exits).toEqual([]) + expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan exploded') }) it('rethrows a thrown value that is not an object at all', async () => { - const { ctx } = await bootFixture([], { withoutStartupRow: true }) + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) const plan: StartupPlan = () => { const thrown: unknown = 'plan threw a string' throw thrown } - await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)).rejects.toThrow('plan threw a string') - }) -}) - -describe('the launcher patch layer', () => { - it('hands the startup row\'s decisions to the launcher as patches', async () => { - const { contributed } = await bootFixture(['--port', '8080']) - // The same decisions the rows started with: a launcher that recomposes its - // tree re-applies these, so an unrelated user edit cannot reset the port. - expect(contributed).toEqual([ - { id: 'waiting', disabled: false, config: { port: 8080, host: '127.0.0.1' } }, - ]) + expect(() => { runStartup(ctx, 'demoStartup', demoCommand(), plan) }).toThrow('plan threw a string') }) - it('contributes nothing when the invocation decided nothing', async () => { - const { contributed } = await bootFixture([]) - expect(contributed).toEqual([]) - }) -}) - -describe('an app with nothing to decide', () => { - it('starts every waiting row unchanged when it declares no plan', async () => { - const { ctx, observed } = await bootFixture([], { withoutStartupRow: true }) - // The list form of the service argument, which an app layering over - // another one uses to absorb that app's startup service. - await runStartup(ctx, ['demoStartup'], demoCommand()) - expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 3080, host: '127.0.0.1' } }]) + it('fails loud when no row injects the service the app provides', async () => { + // The bundle patch and its entrypoint disagree; a silent no-op would leave + // every row of the app on its fallbacks with no explanation. + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + expect(() => { runStartup(ctx, 'absentStartup', demoCommand()) }) + .toThrow('absentStartup: no row injects this startup service') }) - it('overrides a row that carries no composed config', () => { - expect(overrideConfig({ id: 'row', name: 'plugin' }, { port: 8080 })).toEqual({ config: { port: 8080 } }) + it('provides an empty value when the app declares no plan', async () => { + const { ctx } = await bootFixture([], demoPlan, { withoutEntrypoint: true }) + runStartup(ctx, 'demoStartup', demoCommand()) + expect(ctx.get('demoStartup')).toEqual({}) }) }) @@ -250,19 +190,19 @@ describe('provideCmdline', () => { expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) }) - it('fails loud when a startup row runs without the launcher values', async () => { + it('fails loud when an entrypoint runs without the launcher values', () => { const ctx = new Context() - await expect(runStartup(ctx, 'demoStartup', demoCommand())) - .rejects.toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') + expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }) + .toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') }) - it('opens nothing, and blames nobody, when the tree was disposed while startup was parsing', async () => { - // An early SIGTERM disposes the Loader mid-parse. There is nothing left to - // open, and the bundle did nothing wrong. + it('resolves nothing when the tree was disposed while the entrypoint parsed', () => { + // An early SIGTERM takes the Loader with it; there is nothing left to + // configure, and the bundle did nothing wrong. const exits: number[] = [] const ctx = new Context() provideCmdline(ctx, { args: [], exit: code => void exits.push(code) }) - await expect(runStartup(ctx, 'demoStartup', demoCommand())).resolves.toBeUndefined() + expect(() => { runStartup(ctx, 'demoStartup', demoCommand()) }).not.toThrow() expect(exits).toEqual([]) }) }) diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 6904931acc..eb8a2289cd 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -26,9 +26,10 @@ - id: headless-startup name: '@deepseek-ai/dsh-headless/startup' - # Shipped off, not merely waiting: the runner's schema requires the task. - # The startup row enables it with the task after parsing this app's argv. + # Reads its task from the headlessStartup service after the startup row + # resolves this app's command line. - id: headless-runner name: '@deepseek-ai/dsh-headless' inject: [headlessStartup] - disabled: true + config: + task: !!js ctx.get('headlessStartup')?.task diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 5216aa3048..5d2af07463 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -33,7 +33,8 @@ "license": "BSD-3-Clause", "dsh": { "bundle": { - "patch": "./cordis.patch.yml" + "patch": "./cordis.patch.yml", + "entrypoint": "headless-startup" } }, "dependencies": { diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index eb9907a6b7..0f613aae08 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -15,7 +15,7 @@ import { Command } from 'commander' import type { Context } from 'cordis' import type { EntryOptions } from '@cordisjs/plugin-loader' -import { overrideConfig, runStartup, type RowChange } from '@deepseek-ai/dsh-cmdline' +import { runStartup } from '@deepseek-ai/dsh-cmdline' import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' /** Stable Cordis plugin name. */ @@ -24,12 +24,18 @@ export const name = 'headless-startup' /** Services required before the task can be resolved. */ export const inject = ['cmdlineArgs'] -/** The startup service the one-shot runner row injects. */ +/** The service this row provides and the one-shot runner row reads. */ export const HEADLESS_STARTUP_SERVICE = 'headlessStartup' -/** The runner row this app configures. */ +/** The row that runs the task, and the only reason this app has a command line. */ const RUNNER_ROW_ID = 'headless-runner' +/** What the runner row reads from {@link HEADLESS_STARTUP_SERVICE}. */ +export interface HeadlessStartupValues { + /** The task text this invocation asked for. */ + task: string +} + /** * This app's command: the task positional, its description, and its help text. * @returns a fresh program, so one process can parse more than once (tests). @@ -49,22 +55,25 @@ Examples: /** * Turn the parsed command line into the runner row's task. * @param program - the parsed headless command. - * @param rows - the waiting rows' composed options, in tree order. - * @returns row id → changes. + * @param rows - the rows waiting on this app's service, in tree order. + * @returns the runner row's service value. + * @throws when the composition has no runner row, which would otherwise accept + * a task and silently run nothing. */ -function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): Map { +function planHeadlessStartup(program: Command, rows: readonly EntryOptions[]): HeadlessStartupValues { const task = program.args.join(' ') if (task === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') - const runner = rows.find(row => row.id === RUNNER_ROW_ID) - if (runner === undefined) throw new Error(`headless-startup: the composition has no waiting "${RUNNER_ROW_ID}" row to run the task`) - return new Map([[RUNNER_ROW_ID, overrideConfig(runner, { task })]]) + if (!rows.some(row => row.id === RUNNER_ROW_ID)) { + throw new Error(`headless-startup: the composition has no waiting "${RUNNER_ROW_ID}" row to run the task`) + } + return { task } } /** - * Resolve the task and start the rows waiting for it. + * Resolve the task and start the runner that reads it. * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the runner is released, or once `--help` or a missing task requested exit. + * @returns nothing once the runner is started, or once `--help` or a missing task requested exit. */ -export function apply(ctx: Context): Promise { - return runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup) +export function apply(ctx: Context): void { + runStartup(ctx, [HEADLESS_STARTUP_SERVICE, WEB_STARTUP_SERVICE], headlessCommand(), planHeadlessStartup) } diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 1b4d6f1430..fc908306e3 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -1,7 +1,8 @@ /** - * The one-shot app's startup row over a REAL Loader tree: the task - * positional reaches the runner row, a missing task is a usage error, and the - * web startup service this app absorbs releases its rows on the composed values. + * The one-shot app's entrypoint row over a REAL Loader tree: the task + * positional becomes the value the runner row reads, a missing task is a usage + * error, and the web service this app absorbs is provided too, so the web rows + * it rides over resolve on their own fallbacks. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -9,21 +10,17 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { Context } from 'cordis' -import z from 'schemastery' import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' import { WEB_STARTUP_SERVICE } from '@deepseek-ai/dsh-web-app/startup' import { afterEach, describe, expect, it } from 'vitest' -import { apply, HEADLESS_STARTUP_SERVICE } from '../src/startup.ts' +import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts' /** What one boot of the fixture tree observed. */ interface Observed { - started: Record> exits: number[] out: string - /** Patches the startup row handed the launcher for later compositions. */ - contributed: unknown[] } const disposers: (() => Promise)[] = [] @@ -35,112 +32,90 @@ afterEach(async () => { }) /** - * Boot the real headless startup row over stand-ins for the runner row and one - * web row it absorbs. + * Mount the real entrypoint row over stand-ins for the runner row and one web + * row this app absorbs, the way a profile mounts phase one. * @param args - the invocation's inner arguments. - * @returns what the boot observed. + * @param options - fixture knobs for the shapes a composition can take. + * @returns the resolved service values (absent when the app requested exit) and what the boot observed. */ -async function bootStartup(args: string[], options: { withoutRunner?: boolean } = {}): Promise { +async function bootStartup( + args: string[], + options: { withoutRunner?: boolean } = {}, +): Promise<{ task: HeadlessStartupValues | undefined; web: unknown; observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) - const observed: Observed = { started: {}, exits: [], out: '', contributed: [] } - // The runner's real schema requires the task, which is exactly what makes a - // waiting-but-enabled row fail at fiber creation; the stand-in keeps that. - writeFileSync(join(dir, 'row.mjs'), ` -export const Config = globalThis.__headlessRunnerConfigSchema -export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } -`) - writeFileSync(join(dir, 'plain-row.mjs'), ` -export function apply(ctx, config) { globalThis.__headlessStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } -`) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'row.mjs'), 'export function apply() {}\n') // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real plugin the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'startup-row.mjs'), ` + writeFileSync(join(dir, 'entrypoint.mjs'), ` export const name = 'headless-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__headlessStartupApply(ctx) `) const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href - const plainRowUrl = pathToFileURL(join(dir, 'plain-row.mjs')).href writeFileSync(join(dir, 'cordis.yml'), [ - // A composition that lost the runner still injects the startup service, so - // the startup row reaches its own row check rather than the generic one. + // A composition that lost the runner still injects the service, so the + // entrypoint reaches its own row check rather than the generic one. options.withoutRunner === true ? '- id: displaced-runner' : '- id: headless-runner', ` name: ${rowUrl}`, ` inject: [${HEADLESS_STARTUP_SERVICE}]`, - // Shipped off, like the bundle patch: the schema below requires the task, - // which only the startup row can supply. ' disabled: true', '- id: webserver', - ` name: ${plainRowUrl}`, + ` name: ${rowUrl}`, ` inject: [${WEB_STARTUP_SERVICE}]`, - ' config:', - ' port: 0', + ' disabled: true', '- id: headless-startup', - ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`, + ` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`, '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - const globals = globalThis as unknown as { - __headlessStartupObserved: Observed - __headlessStartupApply: typeof apply - __headlessRunnerConfigSchema: unknown - } - globals.__headlessStartupObserved = observed - globals.__headlessStartupApply = apply - globals.__headlessRunnerConfigSchema = z.object({ task: z.string().required() }) + ;(globalThis as unknown as { __headlessStartupApply: typeof apply }).__headlessStartupApply = apply const ctx = new Context() await ctx.plugin(Loader) ctx.loader.builtins.include = Include - provideCmdline(ctx, { - args, - exit: code => void observed.exits.push(code), - contribute: patches => void observed.contributed.push(...patches), - }) + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - return observed + return { + task: ctx.get(HEADLESS_STARTUP_SERVICE) as HeadlessStartupValues | undefined, + web: ctx.get(WEB_STARTUP_SERVICE), + observed, + } } describe('headless startup', () => { - it('joins the task positional and starts the runner with it', async () => { - const observed = await bootStartup(['run', 'the', 'tests']) - expect(observed.started['headless-runner']).toEqual({ task: 'run the tests' }) + it('joins the task positional into the value the runner reads', async () => { + const { task, observed } = await bootStartup(['run', 'the', 'tests']) + expect(task).toEqual({ task: 'run the tests' }) expect(observed.exits).toEqual([]) }) - it('hands the task to the launcher as a patch, so a recomposition keeps it', async () => { - const observed = await bootStartup(['run', 'the', 'tests']) - expect(observed.contributed).toEqual([ - { id: 'headless-runner', disabled: false, config: { task: 'run the tests' } }, - ]) - }) - - it('starts the web rows it absorbed on the composed one-shot values', async () => { - const observed = await bootStartup(['task']) - expect(observed.started.webserver).toEqual({ port: 0 }) + it('provides the web service it absorbed, so those rows resolve on their own fallbacks', async () => { + const { web } = await bootStartup(['task']) + expect(web).toEqual({ task: 'task' }) }) it('rejects an invocation with no task instead of failing inside the runner schema', async () => { - const observed = await bootStartup([]) + const { task, observed } = await bootStartup([]) expect(observed.out).toContain('a task is required') - expect(observed.started).toEqual({}) + expect(task).toBeUndefined() expect(observed.exits).toEqual([1]) }) + it('prints its own help and resolves nothing', async () => { + const { task, observed } = await bootStartup(['--help']) + expect(observed.out).toContain('dsh --profile headless') + expect(task).toBeUndefined() + expect(observed.exits).toEqual([0]) + }) + it('fails the boot when the composition has no runner row to give the task to', async () => { await expect(bootStartup(['task'], { withoutRunner: true })) .rejects.toThrow('the composition has no waiting "headless-runner" row') }) - - it('prints its own help and starts nothing', async () => { - const observed = await bootStartup(['--help']) - expect(observed.out).toContain('dsh --profile headless') - expect(observed.started).toEqual({}) - expect(observed.exits).toEqual([0]) - }) }) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 7825d68383..bc410b698b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -5,11 +5,13 @@ # A patch replaces the targeted row's whole `config`, so each row below # restates every key it owns. # -# Rows this app configures from flags declare `inject: [webStartup]`: they wait -# until the web-startup row has parsed --host/--port/--dev/--workspace-root/ -# --trusted-host and provided that service with the resolved values. -# `dsh --profile web --help` therefore prints this app's own help and exits -# without ever binding a port. +# Rows this app configures from flags read them from the `webStartup` service: +# each names the key it takes and the value it falls back to, so a flag wins +# over the value written beside it. The web-startup row is this bundle's +# manifest-declared entrypoint, so it runs before any of them and has already +# parsed --host/--port/--dev/--workspace-root/--trusted-host by the time their +# config is resolved. `dsh --profile web --help` therefore prints this app's own +# help and exits before the rest of the composition mounts at all. # ── surface-specific values the base deliberately omits ───────────────────── @@ -79,9 +81,13 @@ # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' + inject: [webStartup] + config: + workspaceRoot: !!js ctx.get('webStartup')?.workspaceRoot - # Owns the web flag family and its --help; provides webStartup with the - # values this invocation resolved. Nothing waiting on it starts first. + # This bundle's entrypoint (declared in its package.json): it owns the web + # flag family and its --help, and provides webStartup with the values this + # invocation resolved. The boot runs it before every row above. - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' @@ -94,8 +100,8 @@ name: '@deepseek-ai/dsh-host-webserver' inject: [webStartup] config: - host: 127.0.0.1 - port: 3080 + host: !!js ctx.get('webStartup')?.host ?? '127.0.0.1' + port: !!js ctx.get('webStartup')?.port ?? 3080 # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the @@ -108,11 +114,15 @@ name: '@deepseek-ai/dsh-web-app' inject: [webStartup] config: - mode: production + mode: !!js ctx.get('webStartup')?.mode ?? 'production' printUrl: true surfaceContext: true + lanAddresses: !!js ctx.get('webStartup')?.lanAddresses ?? [] - # The client-plugin HMR receiver ships disabled; `--dev` enables it. + # The client-plugin reload chain: a dev-only row this bundle ships off, + # which the entrypoint turns on for `--dev`. It is a row rather than a + # child of web-runtime because its node half is a client-side package, + # which a host-side bundle cannot import. - id: client-hmr name: '@deepseek-ai/dsh-client-hmr' inject: [webStartup] @@ -132,6 +142,11 @@ - id: connection name: '@deepseek-ai/dsh-client-connection' inject: [webStartup] + config: + # The LAN literals an all-interfaces bind derived plus the + # --trusted-host extras. A deployment that configures its own fence + # authorities adds them to this list. + trustedHosts: !!js ctx.get('webStartup')?.trustedHosts ?? [] - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index e8240e1b63..0e2eff0e3b 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -33,7 +33,8 @@ "license": "BSD-3-Clause", "dsh": { "bundle": { - "patch": "./cordis.patch.yml" + "patch": "./cordis.patch.yml", + "entrypoint": "web-startup" } }, "dependencies": { diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 286a006f28..abf2ac4ca3 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -13,6 +13,7 @@ import { createRequire } from 'node:module' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { enableRow } from '@deepseek-ai/dsh-cmdline' import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -22,6 +23,9 @@ import type {} from '@deepseek-ai/dsh-bash-env' /** Stable Cordis plugin name. */ export const name = 'web-app' +/** The client-plugin reload chain row this bundle ships disabled, for `--dev`. */ +const HMR_ROW_ID = 'client-hmr' + /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] @@ -112,6 +116,11 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex */ export function apply(ctx: Context, config: Config): void { ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) + // The client-plugin reload chain is a row this bundle ships off, because it + // exists only in development. Turning it on belongs here rather than in the + // entrypoint: it needs the host rows this phase of the boot mounts, and the + // entrypoint runs before them. + if (config.mode === 'development') void enableRow(ctx, HMR_ROW_ID) if (config.surfaceContext) { ctx.inject(['systemPrompt'], (promptCtx) => { promptCtx.systemPrompt.section({ @@ -143,15 +152,20 @@ export function apply(ctx: Context, config: Config): void { const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } - const loader = ctx.get('loader') - if (loader === undefined) printUrl() + // A launcher that mounts in phases tells this row when the whole + // composition is up; Loader settlement alone would let the line print + // between phases, announcing a server whose boot can still fail. A + // hand-built tree has neither and prints at once. + const settled = ctx.get('appReady') ?? ctx.get('loader')?.await() + if (settled === undefined) printUrl() else { - void loader.await().then(() => { - // The tree can be disposed while settlement was in flight (early + void settled.then(() => { + // The tree can be disposed while the boot was in flight (early // SIGTERM); a URL line for a dead server would only mislead, and // reading the torn-down port would turn a clean shutdown into a crash. if (ctx.get('httpServer') !== undefined) printUrl() - }) + // A failed boot is reported by the launcher; this row only stays quiet. + }, () => {}) } } } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 1b1969b0d6..96692e0116 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -12,7 +12,7 @@ import { networkInterfaces } from 'node:os' import { Command } from 'commander' import type { Context } from 'cordis' import type { EntryOptions } from '@cordisjs/plugin-loader' -import { overrideConfig, runStartup, type RowChange } from '@deepseek-ai/dsh-cmdline' +import { runStartup } from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ export const name = 'web-startup' @@ -21,12 +21,32 @@ export const name = 'web-startup' export const inject = ['cmdlineArgs'] /** - * The startup service every flag-configured web row injects. The rows are - * listed in this bundle's `cordis.patch.yml`; a row this startup plans changes - * for without injecting the service fails loud. + * The service this row provides and every flag-configured web row reads. The + * rows are listed in this bundle's `cordis.patch.yml`, where each names the key + * it takes from here and the value it falls back to. */ export const WEB_STARTUP_SERVICE = 'webStartup' +/** What the web rows read from {@link WEB_STARTUP_SERVICE}. */ +export interface WebStartupValues { + /** `--host`, absent when the invocation did not name one. */ + host?: string + /** `--port`, absent when the invocation did not name one. */ + port?: number + /** `--workspace-root`, absent when the invocation did not name one. */ + workspaceRoot?: string + /** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */ + mode: 'production' | 'development' + /** + * The `/api` fence authorities for this invocation: the LAN literals an + * all-interfaces bind derived, plus the `--trusted-host` extras, over what + * the composition already configured. + */ + trustedHosts: string[] + /** The LAN literals the fence was configured with, for display. */ + lanAddresses: string[] +} + /** The webserver schema's all-interfaces bind literal: only this bind derives LAN authorities. */ const ALL_INTERFACES_HOST = '0.0.0.0' @@ -95,58 +115,39 @@ Examples: } /** - * Turn the parsed flags into the changes each waiting row needs. + * Turn the parsed flags into the values the web rows read. * @param program - the parsed web command. * @param rows - the waiting rows' composed options, in tree order. - * @returns row id → changes; rows absent from the map start on their composed values. + * @returns the web rows' service value. */ -function planWebStartup(program: Command, rows: readonly EntryOptions[]): Map { +function planWebStartup(program: Command, rows: readonly EntryOptions[]): WebStartupValues { const options = program.opts() if (options.port !== undefined && !/^\d+$/.test(options.port)) { program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) } - const row = (id: string): EntryOptions => { - const found = rows.find(candidate => candidate.id === id) - if (found === undefined) throw new Error(`web-startup: the web composition has no waiting "${id}" row to configure`) - return found - } - const plan = new Map() - const webserver = row('webserver') - const composedHost = (webserver.config as { host?: string } | undefined)?.host - plan.set('webserver', overrideConfig(webserver, { + const webserver = rows.find(row => row.id === 'webserver') + if (webserver === undefined) throw new Error('web-startup: the web composition has no waiting "webserver" row to configure') + // The bind this invocation ends on: the flag, else what the row falls back + // to, which is the same literal its config expression names. + const bindHost = options.host ?? (webserver.config as { host?: string } | undefined)?.host + const { lanAddresses, trustedHosts } = resolveLanTrust(bindHost, options.trustedHost ?? []) + return { ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, - })) - if (options.workspaceRoot !== undefined) { - plan.set('api-gateway', overrideConfig(row('api-gateway'), { workspaceRoot: options.workspaceRoot })) - } - const { lanAddresses, trustedHosts } = resolveLanTrust(options.host ?? composedHost, options.trustedHost ?? []) - if (trustedHosts.length > 0) { - // Additive over the composed value: a cordis.patch.yml-configured fence - // authority must survive the derived LAN literals and the flag extras — - // dropping it silently would weaken security-relevant configuration. - const connection = row('connection') - const composedTrusted = (connection.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] - plan.set('connection', overrideConfig(connection, { trustedHosts: [...composedTrusted, ...trustedHosts] })) - } - // mode and lanAddresses are resolved on every boot, never pass-throughs of - // composed values: they describe this invocation, not the deployment. - plan.set('web-runtime', overrideConfig(row('web-runtime'), { + ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, + // mode and lanAddresses describe this invocation, never the deployment, so + // they are resolved on every boot. mode: options.dev === true ? 'development' : 'production', + trustedHosts, lanAddresses, - })) - // The receiver ships disabled so `--dev` is a row toggle rather than a - // runtime insert (the Loader cannot resolve a row inserted from inside a - // mounting plugin). - if (options.dev === true) plan.set('client-hmr', { disabled: false }) - return plan + } } /** - * Resolve the web flag family and start the rows waiting for it. + * Resolve the web flag family and start the rows that read it. * @param ctx - plugin context carrying the command line and the Loader. - * @returns nothing once the waiting rows are released, or once `--help` requested exit. + * @returns nothing once the web rows are started, or once `--help` requested exit. */ -export function apply(ctx: Context): Promise { - return runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) +export function apply(ctx: Context): void { + runStartup(ctx, WEB_STARTUP_SERVICE, webCommand(), planWebStartup) } diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index c3cdfa08dc..7ac0f5192c 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -1,8 +1,8 @@ /** - * The web app's startup row over a REAL Loader tree carrying this bundle's - * waiting row ids: flags reach the rows they configure, absent flags leave the - * composed values standing, `--dev` enables the shipped-disabled HMR receiver, - * and `--help` leaves the app unstarted. + * The web app's entrypoint row over a REAL Loader tree: every flag lands in the + * `webStartup` service the web rows read, the bind it reports comes from the + * flag or from what the composition falls back to, `--help` resolves nothing, + * and a rejected argument exits without resolving anything. */ import { mkdtempSync, writeFileSync } from 'node:fs' @@ -14,7 +14,7 @@ import Loader from '@cordisjs/plugin-loader' import Include from '@cordisjs/plugin-include' import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' import { afterEach, describe, expect, it, vi } from 'vitest' -import { apply, WEB_STARTUP_SERVICE } from '../src/startup.ts' +import { apply, WEB_STARTUP_SERVICE, type WebStartupValues } from '../src/startup.ts' vi.mock('node:os', async importOriginal => ({ ...await importOriginal(), @@ -26,8 +26,6 @@ vi.mock('node:os', async importOriginal => ({ /** What one boot of the fixture tree observed. */ interface Observed { - /** Config each waiting row started with, by row id; absent means it never started. */ - started: Record> exits: number[] out: string } @@ -40,57 +38,57 @@ afterEach(async () => { internals.stderr = process.stderr }) -/** One stand-in for a row this bundle's patch makes wait for the web startup. */ -interface WaitingRow { - id: string - config?: Record - disabled?: boolean -} - -/** The waiting rows this bundle's patch declares, with the composed values they ship. */ -const WAITING_ROWS: WaitingRow[] = [ - { id: 'webserver', config: { host: '127.0.0.1', port: 3080 } }, - { id: 'api-gateway', config: { provider: 'deepseek-official' } }, - { id: 'connection', config: { trustedHosts: ['configured.internal'] } }, - { id: 'web-runtime', config: { mode: 'production', printUrl: true } }, - { id: 'client-hmr', disabled: true }, -] - /** - * Boot the real startup row over stand-ins for this bundle's waiting rows. + * Mount the real entrypoint row over a stand-in for the `webserver` row whose + * composed bind it reads, the way a profile mounts phase one. * @param args - the invocation's inner arguments. - * @returns what the boot observed. + * @param webserverConfig - the composed `webserver` row config, or `null` to omit the row. + * @returns the resolved service value (absent when the app requested exit) and what the boot observed. */ -async function bootStartup(args: string[], rows: readonly WaitingRow[] = WAITING_ROWS): Promise { +async function bootStartup( + args: string[], + webserverConfig: Record | null = { host: '127.0.0.1', port: 3080 }, +): Promise<{ values: WebStartupValues | undefined; observed: Observed; ctx: Context }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) - const observed: Observed = { started: {}, exits: [], out: '' } - writeFileSync(join(dir, 'row.mjs'), ` -export function apply(ctx, config) { globalThis.__webStartupObserved.started[ctx.fiber.entry.options.id] = config ?? {} } -`) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'row.mjs'), 'export function apply() {}\n') // The Loader imports a row through Node's own resolver, which cannot resolve // this workspace's sources; the row delegates to the real plugin the test // imported through the source-plane path mapping. - writeFileSync(join(dir, 'startup-row.mjs'), ` + writeFileSync(join(dir, 'entrypoint.mjs'), ` export const name = 'web-startup' export const inject = ['cmdlineArgs'] export const apply = ctx => globalThis.__webStartupApply(ctx) `) const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href - const lines = rows.flatMap(row => [ - `- id: ${row.id}`, + writeFileSync(join(dir, 'cordis.yml'), [ + ...webserverConfig === null ? [] : [ + '- id: webserver', + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' disabled: true', + ' config:', + ...Object.entries(webserverConfig).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`), + ], + // A second reader keeps the composition honest when the webserver row is + // the one under test: the service must still have someone to serve. + '- id: web-runtime', ` name: ${rowUrl}`, ` inject: [${WEB_STARTUP_SERVICE}]`, - ...row.disabled === true ? [' disabled: true'] : [], - ...row.config === undefined ? [] : [' config:', ...Object.entries(row.config).map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)], - ]) - lines.push('- id: web-startup', ` name: ${pathToFileURL(join(dir, 'startup-row.mjs')).href}`) - writeFileSync(join(dir, 'cordis.yml'), lines.join('\n') + '\n') + ' disabled: true', + // The reload chain this bundle ships off, which `--dev` turns on. + '- id: client-hmr', + ` name: ${rowUrl}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' disabled: true', + '- id: web-startup', + ` name: ${pathToFileURL(join(dir, 'entrypoint.mjs')).href}`, + '', + ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } internals.stdout = observing internals.stderr = observing - const globals = globalThis as unknown as { __webStartupObserved: Observed; __webStartupApply: typeof apply } - globals.__webStartupObserved = observed - globals.__webStartupApply = apply + ;(globalThis as unknown as { __webStartupApply: typeof apply }).__webStartupApply = apply const ctx = new Context() await ctx.plugin(Loader) @@ -99,65 +97,67 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) await ctx.loader.await() disposers.push(async () => { await ctx.fiber.dispose() }) - return observed + return { values: ctx.get(WEB_STARTUP_SERVICE) as WebStartupValues | undefined, observed, ctx } } + describe('web startup', () => { - it('applies each flag to the row that owns it and leaves the rest composed', async () => { - const observed = await bootStartup(['--port', '8080', '--workspace-root', '/w']) - expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 8080 }) - expect(observed.started['api-gateway']).toEqual({ provider: 'deepseek-official', workspaceRoot: '/w' }) - expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: [] }) - expect(observed.started['client-hmr']).toBeUndefined() - expect(observed.exits).toEqual([]) + it('resolves each flag into the value its row reads', async () => { + const { values } = await bootStartup(['--port', '8080', '--workspace-root', '/w']) + expect(values).toEqual({ + port: 8080, + workspaceRoot: '/w', + mode: 'production', + trustedHosts: [], + lanAddresses: [], + }) }) - it('starts every row on its composed values when the invocation carries no flags', async () => { - const observed = await bootStartup([]) - expect(observed.started.webserver).toEqual({ host: '127.0.0.1', port: 3080 }) - expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal'] }) + it('names no value for a flag the invocation left out, so each row keeps its own', async () => { + const { values } = await bootStartup([]) + expect(values).toEqual({ mode: 'production', trustedHosts: [], lanAddresses: [] }) + expect(values).not.toHaveProperty('host') + expect(values).not.toHaveProperty('port') }) - it('adds the LAN literals over the configured fence authorities for an all-interfaces bind', async () => { - const observed = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal']) - expect(observed.started.webserver).toEqual({ host: '0.0.0.0', port: 3080 }) - expect(observed.started.connection).toEqual({ trustedHosts: ['configured.internal', '192.168.1.5', 'lab.internal'] }) + it('derives the LAN literals for an all-interfaces bind, and the extras with them', async () => { + const { values } = await bootStartup(['--host', '0.0.0.0', '--trusted-host', 'lab.internal']) + expect(values?.trustedHosts).toEqual(['192.168.1.5', 'lab.internal']) // Display gets the same single sample the fence was configured with. - expect(observed.started['web-runtime']).toEqual({ mode: 'production', printUrl: true, lanAddresses: ['192.168.1.5'] }) + expect(values?.lanAddresses).toEqual(['192.168.1.5']) }) - it('enables the shipped-disabled HMR receiver for --dev', async () => { - const observed = await bootStartup(['--dev']) - expect(observed.started['client-hmr']).toEqual({}) - expect(observed.started['web-runtime']).toEqual({ mode: 'development', printUrl: true, lanAddresses: [] }) + it('reads the composed bind when no flag names one, so a configured 0.0.0.0 still derives them', async () => { + const { values } = await bootStartup([], { host: '0.0.0.0', port: 3080 }) + expect(values?.lanAddresses).toEqual(['192.168.1.5']) }) - it('prints its own help and starts nothing', async () => { - const observed = await bootStartup(['--help']) + it('reports the development mode for --dev, which the web runtime reads', async () => { + const { values } = await bootStartup(['--dev']) + // The runtime row is what turns the reload chain on, in the phase whose + // host rows it needs; this row only reports the mode. + expect(values?.mode).toBe('development') + }) + + it('prints its own help and resolves nothing', async () => { + const { values, observed } = await bootStartup(['--help']) expect(observed.out).toContain('dsh --profile web') expect(observed.out).toContain('--trusted-host') - expect(observed.started).toEqual({}) + expect(values).toBeUndefined() expect(observed.exits).toEqual([0]) }) - it('fails the boot when the composition lost a row this app configures', async () => { - // The bundle patch and this startup plugin must agree on the row set; a - // missing row would otherwise silently drop the flag that targets it. - const withoutWebserver = WAITING_ROWS.filter(row => row.id !== 'webserver') - await expect(bootStartup([], withoutWebserver)) - .rejects.toThrow('the web composition has no waiting "webserver" row') - }) - - it('derives the fence authorities alone when the composition configured none', async () => { - const withoutTrust = WAITING_ROWS.map(row => row.id === 'connection' ? { id: 'connection' } : row) - const observed = await bootStartup(['--host', '0.0.0.0'], withoutTrust) - expect(observed.started.connection).toEqual({ trustedHosts: ['192.168.1.5'] }) - }) - it('rejects a non-numeric port before anything binds', async () => { - const observed = await bootStartup(['--port', 'abc']) + const { values, observed } = await bootStartup(['--port', 'abc']) expect(observed.out).toContain('--port must be a number') - expect(observed.started).toEqual({}) + expect(values).toBeUndefined() expect(observed.exits).toEqual([1]) }) + + it('fails the boot when the composition lost the row whose bind it reads', async () => { + // The bundle patch and this entrypoint must agree on the row set; a + // missing row would otherwise silently drop the flag that targets it. + await expect(bootStartup([], null)) + .rejects.toThrow('the web composition has no waiting "webserver" row to configure') + }) }) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index eeb2aefc07..ab56e87db4 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -131,6 +131,38 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) + it('waits for the launcher readiness the phased boot provides, and stays quiet when that boot failed', async () => { + stageDist() + // The launcher-provided readiness wins over Loader settlement: a phased + // boot settles the Loader between phases, long before the app is up. + const ready = new Context() + ready.provide('httpServer', fakeHttpServer().server) + ready.provide('loader', { await: () => Promise.resolve() } as never) + let announce: () => void + ready.provide('appReady', new Promise((resolve) => { announce = resolve })) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + apply(ready, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + announce!() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + await ready.fiber.dispose() + + // A boot that failed announces nothing: the launcher reports it, and a URL + // for a process that is about to exit would only mislead. + log.mockClear() + const failed = new Context() + failed.provide('httpServer', fakeHttpServer().server) + const rejection = Promise.reject(new Error('boot failed')) + rejection.catch(() => {}) + failed.provide('appReady', rejection) + apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + await failed.fiber.dispose() + }) + it('defers the URL line until Loader settlement and drops it when the server is gone', async () => { stageDist() // Settlement path: the line waits for loader.await() so supervisors can