diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1f3d01750c..954f038557 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 2b1f8dd9d41ab5ad34a4787ffa54c6b7d13144af -module-graph.zh.md: 192943312b672c1ae10d32182e9ccd7456eac90b +module-graph.md: 3efb73d075f3d5d7a8bae990fc2f524dc710bcb7 +module-graph.zh.md: df3b9b38497893471b2613c0c95da409dda0262b diff --git a/docs/module-graph.md b/docs/module-graph.md index 2b1f8dd9d4..3efb73d075 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -133,6 +133,7 @@ flowchart TD end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] + pkg_cmdline["cmdline"] end subgraph group_bundle["packages/bundle"] pkg_base["base"] @@ -313,6 +314,7 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_llm_mock_server --> pkg_invariants + pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -1252,6 +1254,7 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | +| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 192943312b..df3b9b3849 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -135,6 +135,7 @@ flowchart TD end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] + pkg_cmdline["cmdline"] end subgraph group_bundle["packages/bundle"] pkg_base["base"] @@ -315,6 +316,7 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_llm_mock_server --> pkg_invariants + pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -1254,6 +1256,7 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | +| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | diff --git a/packages/boot/README.md b/packages/boot/README.md index 5e4e483b60..58a824a7f4 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -7,5 +7,6 @@ The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/ | Package | Role | ctx key | |---|---|---| | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | +| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit`, `appReady` | -The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md). +The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md); app-owned command lines are documented in [`cmdline/README.md`](cmdline/README.md). diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index 95a3f98129..7357b920a0 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -7,5 +7,6 @@ | 包 | 职责 | ctx 键 | |---|---|---| | `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | +| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit`、`appReady` | -启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md)。 +启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md);由应用持有的命令行见 [`cmdline/README.md`](cmdline/README.md)。 diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml new file mode 100644 index 0000000000..f1e5a30951 --- /dev/null +++ b/packages/boot/cmdline/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md new file mode 100644 index 0000000000..3d7aa7fd58 --- /dev/null +++ b/packages/boot/cmdline/README.md @@ -0,0 +1,72 @@ +# `@deepseek-ai/dsh-cmdline` + +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 + +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. + +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 + +An app reads those arguments from a **startup 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) +} +``` + +Every row the app configures from flags injects that startup service in the bundle patch: + +```yaml +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] + config: + host: 127.0.0.1 + 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. + +`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. + +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. + +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. + +### 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. + +### 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). + +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. + +## Model Experience + +None, as this package resolves the process's own command line before any session exists. + +#### KV Cache effect + +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. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md new file mode 100644 index 0000000000..d6eb191e1c --- /dev/null +++ b/packages/boot/cmdline/README.zh.md @@ -0,0 +1,72 @@ +# `@deepseek-ai/dsh-cmdline` + +[English](README.md) | 中文 + +dsh 启动器交给它所引导应用的那条命令行。启动器只解析属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给配置树,因此 flag 家族、`--help` 文本和解析错误都由应用自己持有,启动器不必知道它们。 + +## 启动器提供的三个值 + +启动器在任何配置树条目挂载之前调用 `provideCmdline(ctx, host)`,它提供: + +- `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 +- `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 +- `ctx.appPatches`:启动行记录自身决策的去处,面向会重新组合自己配置树的启动器。从不重新组合的宿主不提供它。 + +没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 + +## 启动行,以及各行所等待的服务 + +应用从**启动行**读取这些参数:启动行是一个注入 `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) +} +``` + +应用用 flag 配置的每一行,都在组合包 patch 中注入那个启动服务: + +```yaml +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] + config: + host: 127.0.0.1 + port: 3080 +``` + +`runStartup` 解析参数,向 `plan` 询问每个等待中的行应有的取值,应用这些取值,然后提供启动服务,正是这一步让这些行得以启动。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 的文本,禁用等待中的行并请求退出:应用从不启动,结算审计看到的是一棵被要求不要启动它的树。 + +`plan` 收到的是每个等待中的行**组合后**的选项,因此决策在覆盖之前能读到组合包 patch 与用户自己那几层达成的结果;`overrideConfig(row, { port })` 只替换点名的那些配置键。plan 中未出现的行按组合后的取值启动;而为某一行 plan 了改动,也会顺带启用它。 + +必填配置由启动流程**供给**而非覆盖的行,必须以 `disabled: true` 交付,因为等待中的行的配置在其 fiber 创建时就会被校验(此时启动服务尚未到达),缺少一个必填键会在那里就让 boot 失败。一次性运行器的 `task` 就是随附的例子。因其他原因以禁用状态交付的行也以同样方式打开:`dsh web --dev` 为 HMR(热模块替换)接收方 plan 了一个 `{ disabled: false }`。 + +这些决策同时经 `ctx.appPatches` 到达启动器,正是这一点让它们在一次重新组合中存活下来:没有它,用户编辑一个活动的 patch 文件就会把每一行都从其组合后的选项重建出来,并悄悄把一台以 `--port 8080` 启动的服务器挪回组合后的端口。 + +### 为什么改动过的行要回收重建 + +等待中的行的配置在 Loader 创建它的 fiber 时就已解析,而这发生在该行仍在等待的时候。把新配置写到这个 fiber 上,永远到不了插件,因此每个改动过的行都会先禁用再重新启用,从而丢弃陈旧的 fiber 并重新解析配置。自身挂载仍在进行中的行会先被放行至停稳,这样禁用时才有一个 fiber 可供 dispose(资源释放),而不是与一个正在诞生的 fiber 抢跑。 + +回收重建刻意不动 `inject`。更新一行的 `inject` 会让它从未经包装的回调重新启动,从而丢失插件自身的静态注入:声明了 `inject = ['httpServer', 'apiProxy']` 的行回来之后,两个服务都读不到。 + +### 一条命令行,一个所有者 + +一套组合有且只有一个命令行所有者。叠加在另一应用之上的应用会禁用下层的启动行,并同时点名两个启动服务,使它吸收过来的行按组合后的取值启动:[`dsh-headless`](../../bundle/headless/README.md) 相对 [`dsh-web-app`](../../bundle/web-app/README.md) 就是这么做的。 + +树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 + +## 模型体验 + +无。本包在任何会话存在之前解析进程自身的命令行。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 +- **启动服务没有声明所有者**:各行点名它,由启动行提供它;两者之间没有静态关联,因此交付了等待中的行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json new file mode 100644 index 0000000000..7ac7f488d9 --- /dev/null +++ b/packages/boot/cmdline/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-cmdline", + "description": "Command-line seam between a dsh launcher and surface bundles: the cmdlineArgs service exposing the invocation's inner arguments, the startup host for contributing flag-derived config patches, and the commander adapter startup plugins share", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^15.0.0" + }, + "peerDependencies": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts new file mode 100644 index 0000000000..3dce71079e --- /dev/null +++ b/packages/boot/cmdline/src/index.ts @@ -0,0 +1,311 @@ +/** + * @deepseek-ai/dsh-cmdline — 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 through the + * {@link CmdlineArgs} service, so an app owns its flag family, its `--help` + * text, and its parse errors instead of the launcher knowing them. + * + * An app consumes those arguments from a **startup plugin**: a row that + * injects `cmdlineArgs` and calls {@link runStartup}. 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. + * @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' + +/** + * The invocation's inner arguments: everything after the launcher's own flags, + * verbatim and in argv order. `dsh --profile tui --resume abc` yields + * `['--resume', 'abc']`. + */ +export interface CmdlineArgs { + /** + * Read the inner arguments. + * @returns the arguments in argv order; empty when the invocation carried none. + */ + get(): readonly string[] +} + +/** Request bounded process exit; the launcher wires it to its shutdown controller. */ +export interface AppExit { + /** + * Request exit once the tree has been disposed. + * @param code - the process exit code. + */ + (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 + } +} + +/** The launcher facts an app's startup row 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. + */ + contribute?: AppPatches['contribute'] +} + +/** + * 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. + * @param ctx - the host context the tree will mount under. + * @param host - the invocation's arguments, exit request, and optional patch sink. + */ +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 }) +} + +/** The process streams commander output is written to; production writes to the process. */ +export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = { + stdout: process.stdout, + stderr: process.stderr, +} + +/** + * 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. + * + * 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. + * @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. + */ +export type StartupPlan = (program: Command, rows: readonly EntryOptions[]) => Map + +/** + * 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. + * + * 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. + * + * 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. + * + * 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. + * @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 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. + */ +export async function runStartup( + ctx: Context, + services: string | readonly string[], + program: Command, + plan: StartupPlan = () => new Map(), +): Promise { + const names = typeof services === 'string' ? [services] : services + // Read through the global service store, not the property proxy: these are + // optional host values, and a row that injects only `cmdlineArgs` may not + // read the others as declared injections. + const args = ctx.get('cmdlineArgs') + const exit = ctx.get('appExit') + if (args === undefined || exit === undefined) { + throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`) + } + program + .exitOverride() + .configureOutput({ + writeOut: text => void internals.stdout.write(text), + writeErr: text => void internals.stderr.write(text), + }) + let decisions: Map + let rows: EntryOptions[] + 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) + } 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. + if (!isCommanderError(error)) throw error + for (const entry of waitingEntries(ctx, names)) await stopRow(entry) + exit(error.exitCode) + return + } + 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) +} + +/** + * Stop a waiting row, including one whose own mount is still in flight. + * + * 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. + */ +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 } } +} + +/** + * The composed options of every row waiting on one of `services`, in tree order. + * @param ctx - plugin context whose Loader tree carries the rows. + * @param services - the startup service names. + * @returns the waiting rows' options. + * @throws when a startup service is declared by no row, which means the bundle + * patch and its startup plugin disagree. + */ +function waitingRows(ctx: Context, services: readonly string[]): EntryOptions[] { + for (const service of services) { + if (waitingEntries(ctx, [service]).length === 0) { + throw new Error(`${service}: no row injects this startup service — the bundle patch must set "inject: [${service}]" on every row this app configures`) + } + } + return waitingEntries(ctx, services).map(entry => entry.options) +} + +/** + * The Loader entries waiting on any of `services`. + * @param ctx - plugin context whose Loader tree carries the rows. + * @param services - the startup service names. + * @returns the waiting entries in tree order. + */ +function waitingEntries(ctx: Context, services: readonly string[]): Entry[] { + // Called only after runStartup established the tree is still live. + return [...ctx.loader.entries()].filter(entry => services.some(service => waitsFor(entry.options.inject, service))) +} + +/** + * Whether a thrown value is commander's own control-flow error (help, version, + * a parse error, or `program.error`). + * + * Detected structurally, not with `instanceof`: an out-of-tree plugin brings + * its own commander copy, whose `CommanderError` class is a different identity + * from this package's, and an identity check there would rethrow a printed + * help as a fatal load failure. + * @param error - the thrown value. + * @returns true when the value carries commander's error code and exit code. + */ +function isCommanderError(error: unknown): error is { code: string; exitCode: number } { + if (typeof error !== 'object' || error === null) return false + const candidate = error as { code?: unknown; exitCode?: unknown } + return typeof candidate.code === 'string' && candidate.code.startsWith('commander.') + && typeof candidate.exitCode === 'number' +} + +/** + * Whether a row's `inject` declaration names `service`. + * @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. + */ +function waitsFor(inject: EntryOptions['inject'], service: 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) +} diff --git a/packages/boot/cmdline/src/invariant.ts b/packages/boot/cmdline/src/invariant.ts new file mode 100644 index 0000000000..f1ec75678f --- /dev/null +++ b/packages/boot/cmdline/src/invariant.ts @@ -0,0 +1,34 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-cmdline`. + * @module @deepseek-ai/dsh-cmdline/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-cmdline' + +/** Cordis companion plugin name. */ +export const name = 'cmdline-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the owned relation is "no row is left waiting for a + * startup service", which is a property of the whole tree at Loader + * settlement, and the invariant service carries no settlement signal to + * evaluate it at. Observing it from the entry stream would fire while startup + * is still parsing, when every waiting row is legitimately still waiting. The + * launcher's post-settlement audit (`assertEntriesActivated`) already reports + * a startup service that was never provided as a pending entry naming it, and + * the built-bin e2e asserts the apps boot with flag values applied. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts new file mode 100644 index 0000000000..b4ea1a3624 --- /dev/null +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -0,0 +1,268 @@ +/** + * 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. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Command } from 'commander' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' +import { afterEach, describe, expect, it } from 'vitest' +import { internals, overrideConfig, provideCmdline, runStartup, type RowChange, type StartupPlan } from '../src/index.ts' + +/** Every value one boot of the fixture tree observed. */ +interface Observed { + applied: { id: string; config: Record }[] + exits: number[] + out: string +} + +/** A booted fixture tree: what it observed, and its root for direct startup calls. */ +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 () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** The fixture's flag family: one `--port` over the waiting row's composed config. */ +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) => { + const port = program.opts<{ port?: string }>().port + if (port === undefined) return new Map() + 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) })]]) +} + +/** + * 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. + * @param args - the invocation's inner arguments. + * @param options - fixture knobs for the shapes a bundle patch can produce. + * @returns the booted fixture. + */ +async function bootFixture( + args: string[], + options: { injectObjectForm?: boolean; withoutStartupRow?: boolean; slowWaitingImport?: 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 }) } +`) + // 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' +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')) + 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 } + globals.__observed = observed + globals.__runStartup = (ctx: Context) => runStartup(ctx, 'demoStartup', demoCommand(), demoPlan) + + const contributed: unknown[] = [] + 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), + }) + 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 } +} + +describe('runStartup', () => { + it('starts a waiting row only after the startup service arrives, with the flag value applied over its composed config', async () => { + const { observed } = await bootFixture(['--port', '8080']) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 8080, host: '127.0.0.1' } }]) + expect(observed.exits).toEqual([]) + }) + + it('starts the waiting row unchanged when the invocation carries no flags', async () => { + const { observed } = await bootFixture([]) + expect(observed.applied).toEqual([{ id: 'waiting', config: { port: 3080, host: '127.0.0.1' } }]) + }) + + 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 () => { + const { observed } = await bootFixture(['--help']) + expect(observed.out).toContain('Usage: demo') + expect(observed.applied).toEqual([]) + 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.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 plan: StartupPlan = () => { throw new Error('plan exploded') } + await expect(runStartup(ctx, 'demoStartup', demoCommand(), plan)).rejects.toThrow('plan exploded') + expect(observed.exits).toEqual([]) + }) + + it('rethrows a thrown value that is not an object at all', async () => { + const { ctx } = await bootFixture([], { withoutStartupRow: 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' } }, + ]) + }) + + 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('overrides a row that carries no composed config', () => { + expect(overrideConfig({ id: 'row', name: 'plugin' }, { port: 8080 })).toEqual({ config: { port: 8080 } }) + }) +}) + +describe('provideCmdline', () => { + it('hands the app a snapshot the caller cannot mutate afterwards', () => { + const ctx = new Context() + const args = ['--resume', 'abc'] + provideCmdline(ctx, { args, exit: () => {} }) + args.push('--tampered') + expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) + }) + + it('fails loud when a startup row runs without the launcher values', async () => { + const ctx = new Context() + await expect(runStartup(ctx, 'demoStartup', demoCommand())) + .rejects.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. + 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(exits).toEqual([]) + }) +}) diff --git a/packages/boot/cmdline/tsconfig.json b/packages/boot/cmdline/tsconfig.json new file mode 100644 index 0000000000..f4bcebf1e8 --- /dev/null +++ b/packages/boot/cmdline/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/include" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df45d792b2..3d890741ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1175,6 +1175,25 @@ importers: specifier: ^4.0.9 version: 4.0.9 + packages/boot/cmdline: + dependencies: + commander: + specifier: ^15.0.0 + version: 15.0.0 + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bundle/base: dependencies: '@deepseek-ai/cordis-plugin-hmr': diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 0fcc4c16aa..49ff148922 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -165,6 +165,9 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { ...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [], // web-react's store subpath ships its own bundle (single-entry builds; no shared chunk). ...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [], + // A surface bundle's startup row is its own bundle: the Loader imports it + // as a row module, so it cannot ride inside the package entry. + ...exportDefault(manifest, './startup') === './lib/startup.js' ? ['lib/startup.js'] : [], ...extras, // Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js — // browser-safe source channels rehomed off src so plain Node can import diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 2b962ca471..1ffc0384f3 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -146,6 +146,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' }, 'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' }, 'packages/boot/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' }, + 'packages/boot/cmdline': { kind: 'none', reason: 'Resolves the process command line before any session exists; configured rows own every model-visible consequence.' }, 'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, 'packages/interaction/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' }, 'packages/interaction/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 11f0ba7a88..32ae7df42d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -222,6 +222,7 @@ { "path": "./packages/bundle/headless" }, { "path": "./packages/bundle/web-app" }, { "path": "./packages/boot/app-boot" }, + { "path": "./packages/boot/cmdline" }, { "path": "./packages/scaffold/server" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/support/llm-replay" },