mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(cmdline): hand the launcher's remaining arguments to the app it boots
A launcher provides three values before the tree mounts: ctx.cmdlineArgs (get() is the whole interface) carrying everything after its own flags, ctx.appExit for a bounded exit, and ctx.appPatches for decisions a later recomposition must keep. An app's startup row injects cmdlineArgs and calls runStartup() with its own commander program. Rows the app configures inject its startup service, so they wait until the startup row has resolved their values and provided it; --help prints, disables those rows, and exits without the app ever starting. A changed row is recycled — disabled, then re-enabled with its new values — because a row's config is resolved when the Loader creates its fiber, while the row is still waiting. Recycling never touches inject: an inject update restarts the row from its unwrapped callback and loses the plugin's own static injections. A mount still in flight is allowed to settle first, so the disable has a fiber to dispose instead of racing one into existence.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)。
|
||||
|
||||
6
packages/boot/cmdline/README.i18n.yaml
Normal file
6
packages/boot/cmdline/README.i18n.yaml
Normal file
@@ -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
|
||||
72
packages/boot/cmdline/README.md
Normal file
72
packages/boot/cmdline/README.md
Normal file
@@ -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<void> {
|
||||
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.
|
||||
72
packages/boot/cmdline/README.zh.md
Normal file
72
packages/boot/cmdline/README.zh.md
Normal file
@@ -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<void> {
|
||||
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` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。
|
||||
- **启动服务没有声明所有者**:各行点名它,由启动行提供它;两者之间没有静态关联,因此交付了等待中的行却缺少对应启动行的组合包会在结算时失败(出现指向该服务的待处理条目),而不是在加载时失败。
|
||||
42
packages/boot/cmdline/package.json
Normal file
42
packages/boot/cmdline/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
311
packages/boot/cmdline/src/index.ts
Normal file
311
packages/boot/cmdline/src/index.ts
Normal file
@@ -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: [<startup service>]` in the bundle
|
||||
* patch and therefore waits until the startup plugin provides that service;
|
||||
* `--help` prints, disables exactly those rows, and requests exit, so the app
|
||||
* never starts.
|
||||
* @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<Partial<EntryOptions>, '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<string, RowChange>
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<string, RowChange>
|
||||
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<void> {
|
||||
await entry.refresh()
|
||||
await entry.update({ disabled: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge flag overrides over a waiting row's composed config.
|
||||
*
|
||||
* A row's composed config is what the bundle patches and the user's own layers
|
||||
* agreed on; a flag replaces exactly the keys it names and leaves the rest of
|
||||
* that agreement intact.
|
||||
* @param options - the waiting row's composed options.
|
||||
* @param overrides - the values this invocation's flags decided, by config key.
|
||||
* @returns the change to put in a {@link StartupPlan}'s map.
|
||||
*/
|
||||
export function overrideConfig(options: EntryOptions, overrides: Record<string, unknown>): RowChange {
|
||||
return { config: { ...(options.config ?? {}) as Record<string, unknown>, ...overrides } }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
34
packages/boot/cmdline/src/invariant.ts
Normal file
34
packages/boot/cmdline/src/invariant.ts
Normal file
@@ -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))
|
||||
268
packages/boot/cmdline/tests/cmdline.spec.ts
Normal file
268
packages/boot/cmdline/tests/cmdline.spec.ts
Normal file
@@ -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<string, unknown> }[]
|
||||
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<void>)[] = []
|
||||
|
||||
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 <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<Fixture> {
|
||||
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<void> }
|
||||
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<string, RowChange>([['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([])
|
||||
})
|
||||
})
|
||||
24
packages/boot/cmdline/tsconfig.json
Normal file
24
packages/boot/cmdline/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -146,6 +146,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'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.' },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user