diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 101332555d..b4c933291e 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: dd29f7fc03a783079ea3194de99589c1f545be5b -README.zh.md: 60e7aa1ec1ea2fad7e3f3d97a0f6bf42355adffc +README.md: 86d890ebec7121a9f8431f52789b8346ba59deb2 +README.zh.md: 80b9a6d56bdb49f72d25f7485662a6814f5184a3 diff --git a/apps/cli/README.md b/apps/cli/README.md index dd29f7fc03..86d890ebec 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -9,15 +9,27 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin | Command | Purpose | |---|---| | `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | -| `dsh run [--profile ] [--patch ...] "task"` | Run one fresh persisted session directly over core, print the final answer, and exit; the profile defaults to `headless` and mounts no Web server. | -| `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). | +| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh web` | Alias of `--profile web`. | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. + +## App arguments + +The launcher parses only its own flags and hands everything after them to the booted profile, where that app's own startup row parses them ([`dsh-cmdline`](../../packages/ui/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: + +```sh +dsh --profile web --port 8080 # --port belongs to the web app +dsh --profile tui --resume # --resume belongs to the terminal app +dsh --profile headless "run the tests" +dsh --profile web --help # the web app's flags, not the launcher's +dsh --help # the launcher's own help +``` ## Profiles -A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays, then flag patches. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. +A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 60e7aa1ec1..80b9a6d56b 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -9,18 +9,30 @@ | 命令 | 用途 | |---|---| | `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | -| `dsh run [--profile ] [--patch ...] "task"` | 直接在 core 上运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`,且不挂载 Web server。 | -| `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 | +| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | +| `dsh web` | `--profile web` 的别名。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 + +## 应用参数 + +启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../packages/ui/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: + +```sh +dsh --profile web --port 8080 # --port belongs to the web app +dsh --profile tui --resume # --resume belongs to the terminal app +dsh --profile headless "run the tests" +dsh --profile web --help # the web app's flags, not the launcher's +dsh --help # the launcher's own help +``` ## Profile -profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay,最后是 flag patch。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 +profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 [CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。 ## 开发 -生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析约定。 +生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。 diff --git a/apps/cli/package.json b/apps/cli/package.json index a2695f59c9..4442006b0a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -27,6 +27,7 @@ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index ba0fd57bc6..9916a9eeb3 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 756fed1f1802600e82948ce8ca808706b2299660 -README.zh.md: edd20c7fc3a3103097aa5e3949418e373172cadb +README.md: 13c0d000eec045cc34f2b7eb5fe5ba9ac9ed557e +README.zh.md: 5392db3220013a50040bf59f212040e8d0291037 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 756fed1f18..13c0d000ee 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -2,17 +2,32 @@ English | [中文](README.zh.md) -This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. +This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. ## Profile boot -`dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), each `--patch ` overlay in argv order, and launcher flag patches. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. +`dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). -The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). On load, the exact installation-owned headless tuple (base + web-app + headless) normalizes to the shipped template; extra, missing, or reordered bundle lists are user-owned and remain untouched. Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. +The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. -Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile ""` command instead of reaching the row's raw required-field error. +### App arguments + +The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where that app's own startup row parses it ([`dsh-cmdline`](../../../packages/ui/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. + +A composition mounts once. A Loader row that injects `cmdlineArgs` parses this app's arguments and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the startup service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. + +Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. A profile with no active row injecting `cmdlineArgs` accepts no app arguments; it rejects them before mounting any row instead of silently ignoring them. + +The shipped apps own these command lines: + +| Profile | Arguments | +|---|---| +| `web` | `--host`, `--port`, `--dev`, `--workspace-root`, repeatable `--trusted-host` | +| `headless` | the task text, as the positional argument | + +A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. Inspect the composed tree without booting it: @@ -21,13 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. - -## One-shot run - -`dsh run [--profile ] [--patch ...] ` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row. - -The launcher patches the task text into the runner row. After Loader settlement, the runner reads the shared `ctx.agentDefaultModel` default, creates one fresh persisted Agent through `ctx.agents`, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs an app's startup row, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management @@ -43,12 +52,13 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, which owns them in its bundle's startup row. `--host`, `--port`, and `--workspace-root` override the composed values of the rows that carry them, repeatable `--trusted-host` adds authorities over the composed fence configuration, and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web dsh web --patch ./extra.cordis.yml dsh web --dump-config +dsh web --help ``` The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index edd20c7fc3..5392db3220 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -2,17 +2,32 @@ [English](README.md) | 中文 -本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 +本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 ## Profile 启动 -`dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、按 argv 顺序的各个 `--patch ` overlay,以及启动器 flag patch。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 +`dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、以及按 argv 顺序的各个 `--patch ` overlay。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 组合包名称先从 dsh 安装解析,再从 profile 目录解析。因此内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`)总是来自与正在运行的 `dsh` 相同的安装;树外组合包来自 profile 由 pnpm 管理的 `node_modules`。任何 patch 行中的裸插件 `name` 通过 profile 目录的 Node 父目录逐级查找解析,该查找可达到持续维护的安装后备目录 `$DSH_HOME/profiles/node_modules`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。 -`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。加载时,与安装所管理的 headless 元组(base + web-app + headless)完全一致的列表会规范化为随附模板;包含额外项、缺少项或调整过顺序的组合包列表由用户拥有,保持不变。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 +`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 -Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile ""`,而不会触发该行原始的必填字段错误。 +### 应用参数 + +启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,由该应用自己的启动行解析([`dsh-cmdline`](../../../packages/ui/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 + +一套组合只挂载一次。注入 `cmdlineArgs` 的 Loader 行解析本应用的参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖启动服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 + +启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。若 profile 中没有注入 `cmdlineArgs` 的活跃行,该 profile 不接受应用参数;启动器会在挂载任何行之前拒绝这些参数,而不是静默忽略。 + +随附的各应用持有这些命令行: + +| Profile | 参数 | +|---|---| +| `web` | `--host`、`--port`、`--dev`、`--workspace-root`、可重复的 `--trusted-host` | +| `headless` | 任务文本,作为位置参数 | + +一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 可在不启动的情况下检查组合出的配置树: @@ -21,13 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 - -## 一次性运行 - -`dsh run [--profile ] [--patch ...] ` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。 - -启动器把任务文本 patch 进运行器行。Loader 结算后,运行器读取共享的 `ctx.agentDefaultModel` 默认值,通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用的启动行,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。 ## 插件管理 @@ -43,12 +52,13 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由该应用在其组合包的启动行中持有。`--host`、`--port` 和 `--workspace-root` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 在组合出的围栏配置之上追加 authority,`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web dsh web --patch ./extra.cordis.yml dsh web --dump-config +dsh web --help ``` 生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 97f5222398..43d68a2c7c 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,31 +1,30 @@ /** - * Commander adapter for the `dsh` command-line entry. The default command - * boots a named profile (`--profile `), optionally with extra `--patch` - * overlays. `run` owns one-shot task execution, defaulting to the headless - * profile; `web` is a hardcoded alias for `--profile web` that adds the Web - * flag family; `plugin` manages a profile's plugin dependencies by forwarding - * to pnpm. Commander owns help, version, and parse errors. + * Commander adapter for the `dsh` command line. + * + * The launcher parses only what it owns — which profile to boot, which extra + * patch overlays to apply, and the config dumps — and hands **everything after + * its own flags** to the booted tree verbatim, where the booted app's startup row + * parses its own flag family and prints its own `--help` (see + * `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first + * token this parser does not recognize starts the inner arguments, so + * `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`, + * and `dsh --profile web -h` prints the web app's help, not this one's. + * + * `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's + * plugin dependencies by forwarding to pnpm. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** Boot a named profile. */ +/** Boot a named profile and hand it the invocation's inner arguments. */ interface ProfileInvocation { mode: 'profile' profile: string /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ patches: string[] -} - -/** Run one task through a profile mounting the headless runner. */ -interface RunInvocation { - mode: 'run' - profile: string - /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ - patches: string[] - /** Non-blank task text joined from the variadic positional arguments. */ - task: string + /** Everything after the launcher's own flags, verbatim, for the booted app's startup row. */ + args: string[] } /** Print a composed profile tree and exit without booting. */ @@ -37,21 +36,6 @@ interface DumpConfigInvocation { patches: string[] } -/** - * Browser UI: `dsh web` (alias of `--profile web`). Host and port remain - * unvalidated pass-throughs to the webserver schema; absent values leave the - * shipped web bundle values intact. - */ -interface WebInvocation { - mode: 'web' - patches: string[] - host?: string - port?: number - dev: boolean - /** Extra authorities for the /api browser-trust fence. */ - trustedHosts?: string[] -} - /** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */ interface PluginInvocation { mode: 'plugin' @@ -61,31 +45,63 @@ interface PluginInvocation { } /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation +export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation -/** Raw web-subcommand options straight from Commander. */ -interface WebOptions { +/** Launcher flags shared by the default command and the `web` alias. */ +interface BootOptions { patch?: string[] - host?: string - port?: string - dev?: boolean - trustedHost?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean } -/** Raw run-subcommand options straight from Commander. */ -interface RunOptions { - profile: string - patch?: string[] -} - /** * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never - * variadic — a variadic `--patch` would swallow a following positional task. + * variadic — a variadic `--patch` would swallow the inner arguments. */ const collect = (value: string, previous: string[] = []): string[] => [...previous, value] +/** The launcher's own help text; each app prints its own. */ +const HELP_EXAMPLES = ` +Examples: + dsh --profile web boot the web profile (same as: dsh web) + dsh --profile headless "run the tests" answer one task, print the result, and exit + dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay + dsh --profile tui --resume arguments after the launcher flags reach the app + dsh --profile web --help the web app's own flags and help + dsh plugin --profile tui add install a plugin into the tui profile +` + +/** + * Resolve a boot or dump invocation from the launcher flags and the leftover + * inner arguments. + * @param program - the command whose options were parsed (the root, or the `web` alias). + * @param profile - the profile these flags boot. + * @param options - the launcher flags commander collected. + * @param args - the leftover arguments, in argv order. + * @returns the resolved invocation. + */ +function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation { + const patches = options.patch ?? [] + if (patches.includes('')) program.error('error: --patch needs a path') + if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) { + return { mode: 'profile', profile, patches, args } + } + if (options.dumpConfig === true && options.dumpDefaultConfig === true) { + program.error('error: --dump-config and --dump-default-config are mutually exclusive') + } + // The dump is boot-free: it never runs the app's startup row, so it cannot + // show what that app's flags would decide, and printing a tree that differs + // from the same invocation's boot would mislead. + if (args.length > 0) { + program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`) + } + const defaultOnly = options.dumpDefaultConfig === true + if (defaultOnly && patches.length > 0) { + program.error('error: --dump-default-config prints the bundle layers and takes no --patch') + } + return { mode: 'dump-config', profile, defaultOnly, patches } +} + /** * Resolve argv into one invocation, or print and exit for help, version, or an * error. @@ -95,121 +111,61 @@ const collect = (value: string, previous: string[] = []): string[] => [...previo */ export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { let resolved: DshInvocation | undefined - const program = new Command() + // Annotated, not inferred: the actions below call back into `program`, and an + // inferred type would be circular through its own chain. + const program: Command = new Command() + program .name('dsh') .version(version, '-V, --version', 'output the version number') .description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.') - .addHelpText('after', ` -Examples: - dsh --profile web boot the web profile (same as: dsh web) - dsh run "run the tests" answer one task, print the result, and exit - dsh run --profile custom "run the tests" run one task through a custom one-shot profile - dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay - dsh plugin --profile tui add install a plugin into the tui profile - dsh web --port 8080 the web alias with its flag family -`) + .addHelpText('after', HELP_EXAMPLES) .exitOverride() + // The launcher's flags come first and end at the first token it does not + // know; everything from there on belongs to the booted app, including + // its -h. `dsh -h` with no profile still prints this help, below. + .helpOption(false) + .allowUnknownOption() + .passThroughOptions() .enablePositionalOptions() + .argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile --help)') .option('--profile ', 'the profile under $DSH_HOME/profiles to boot') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) .option('--dump-config', 'print the composed profile tree and exit') .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit') - .action((options: { - profile?: string - patch?: string[] - dumpConfig?: boolean - dumpDefaultConfig?: boolean - }) => { - const profile = options.profile ?? program.error('error: --profile is required') - if (profile === '') program.error('error: --profile needs a name') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - if (options.dumpConfig === true || options.dumpDefaultConfig === true) { - if (options.dumpConfig === true && options.dumpDefaultConfig === true) { - program.error('error: --dump-config and --dump-default-config are mutually exclusive') - } - const defaultOnly = options.dumpDefaultConfig === true - if (defaultOnly && patches.length > 0) { - program.error('error: --dump-default-config prints the bundle layers and takes no --patch') - } - resolved = { mode: 'dump-config', profile, defaultOnly, patches } - return + .action((args: string[], options: BootOptions & { profile?: string }) => { + // With the app owning -h, the launcher's own help is what a bare + // `dsh -h` (no profile to hand it to) must print. + if (options.profile === undefined) { + if (args.some(argument => argument === '-h' || argument === '--help')) program.help() + program.error('error: --profile is required') } - resolved = { mode: 'profile', profile, patches } + const profile = options.profile + if (profile === '') program.error('error: --profile needs a name') + resolved = resolveBoot(program, profile, options, args) }) /** Reject parent options supplied before a subcommand. */ const rejectParentOptions = (command: string): void => { - const parent = program.opts<{ - profile?: string - patch?: string[] - dumpConfig?: boolean - dumpDefaultConfig?: boolean - }>() + const parent = program.opts() if (parent.profile !== undefined || parent.patch !== undefined || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) { program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`) } } - const run = program.command('run').description('run one task through a profile mounting the headless runner') - run - .option('--profile ', 'one-shot profile under $DSH_HOME/profiles', 'headless') - .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) - .argument('', 'task text') - .action((task: string[], options: RunOptions) => { - rejectParentOptions('run') - const profile = options.profile - if (profile === '') program.error('error: --profile needs a name') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - const joined = task.join(' ') - if (joined.trim() === '') program.error('error: run needs a non-blank task') - resolved = { mode: 'run', profile, patches, task: joined } - }) - - const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port') + const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow') web + .helpOption(false) + .allowUnknownOption() + .passThroughOptions() + .enablePositionalOptions() + .argument('[args...]', 'arguments for the web app (see: dsh web --help)') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) - .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') - .option('--port ', 'listen port; pass 0 to let the OS pick a free one') - .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') - .action((options: WebOptions) => { + .action((args: string[], options: BootOptions) => { rejectParentOptions('web') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - if (options.dumpConfig === true || options.dumpDefaultConfig === true) { - if (options.dumpConfig === true && options.dumpDefaultConfig === true) { - program.error('error: --dump-config and --dump-default-config are mutually exclusive') - } - const defaultOnly = options.dumpDefaultConfig === true - if (defaultOnly && patches.length > 0) { - program.error('error: --dump-default-config prints the bundle layers and takes no --patch') - } - // The dump is boot-free and does not derive flag patches; silently - // dropping them would print a tree that differs from the same - // invocation's boot. - if (options.host !== undefined || options.port !== undefined || options.dev === true - || options.trustedHost !== undefined) { - program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)') - } - resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } - return - } - if (options.port !== undefined && !/^\d+$/.test(options.port)) { - program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) - } - resolved = { - mode: 'web', - patches, - ...options.host !== undefined && { host: options.host }, - ...options.port !== undefined && { port: Number(options.port) }, - dev: options.dev === true, - ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, - } + resolved = resolveBoot(web, 'web', options, args) }) const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory') diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index b332a64615..d0e8e9d138 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot' +import { loadEnv } from '@deepseek-ai/dsh-app-boot' import { parseDshArgs } from './args.ts' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit @@ -24,33 +24,19 @@ function readVersion(): string { return typeof manifest.version === 'string' ? manifest.version : '0.0.0' } +loadEnv('dsh') const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'profile': { const { runProfile } = await import('./profile-boot.ts') await runProfile({ - environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, + args: invocation.args, }) break } - case 'run': { - const { runProfile } = await import('./profile-boot.ts') - await runProfile({ - environment: loadLayeredEnv('dsh'), - profile: invocation.profile, - patchFiles: invocation.patches, - task: invocation.task, - }) - break - } - case 'web': { - const { runWeb } = await import('./web.ts') - await runWeb(invocation, loadLayeredEnv('dsh')) - break - } case 'plugin': { const { runPlugin } = await import('./plugin.ts') process.exit(runPlugin(invocation.profile, invocation.args)) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 8c37af124a..5acebe9b44 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -1,9 +1,13 @@ /** * Shared profile boot for every `dsh` surface: resolve the profile, stack its - * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own - * `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry - * switch), mount the tree over the profile's empty root config, keep the - * profile patch layer live, and wire fail-loud plus bounded shutdown. + * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's + * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the + * tree over the profile's empty root config, keep the profile patch layer + * live, and wire fail-loud plus bounded shutdown. + * + * App flags are not the launcher's business: the invocation's inner arguments + * are provided to the tree through `ctx.cmdlineArgs`, and the booted app's + * startup row parses them and configures its own rows. * @module @deepseek-ai/dsh/profile-boot */ @@ -12,7 +16,7 @@ import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { FiberState, type Context } from '@deepseek-ai/cordis' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { dshHomePath } from '@deepseek-ai/dsh-paths' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { boot, composeEntries, @@ -25,7 +29,7 @@ import { watchUserPatches, type Profile, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' /** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) @@ -33,6 +37,7 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im /** Harness-home directory holding locally authored agent presets. */ const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' +import { hasCmdlineConsumer, provideCmdline } from '@deepseek-ai/dsh-cmdline' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' import { resolveWindowsShellLayer } from './windows-shell.ts' @@ -55,7 +60,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ const TELEMETRY_ROW_ID = 'telemetry-otel' -/** The one-shot runner row a `dsh run` task requires and configures. */ +/** The one-shot runner row: its presence means this composition exits by itself. */ const HEADLESS_ROW_ID = 'headless-runner' /** The empty root entry list every profile tree patches over. */ @@ -104,9 +109,6 @@ export function prepareProfile(name: string, userLayer = true): Profile { return profile } -/** Read-only row index of a profile composition before launcher flag patches. */ -export type ProfileRows = ReadonlyMap - /** One profile's patch layers (application order) and the row index of its pre-flag composition. */ interface ComposedProfile { profile: Profile @@ -116,14 +118,13 @@ interface ComposedProfile { windowsShellPatches: PatchOptions[] /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ homePatches: PatchOptions[] - /** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */ - overlayAndFlags: PatchOptions[] + /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */ + overlays: PatchOptions[] /** - * id → row of the pre-flag composition (bundles + user layers + overlays), - * for flag merges and row checks. Flag patches must not insert rows the - * launcher consults here (they only override values and insert dev glue). + * id → row of the composed tree (bundles + user layers + overlays), for the + * launcher's own row checks. */ - rows: ProfileRows + rows: ReadonlyMap } /** The full patch stack of one composed profile, in application order. */ @@ -133,7 +134,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { ...composed.windowsShellPatches, ...composed.profile.patches, ...composed.homePatches, - ...composed.overlayAndFlags, + ...composed.overlays, ] } @@ -143,36 +144,28 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { * is Windows), the profile's user layer, the home-level user layer * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to * every profile, so it outranks the per-profile layer), `--patch` overlays, - * then flag patches derived from the composed rows, then the telemetry - * switch. + * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. - * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. * @returns the profile, its patch layers, and the composed row index. */ function composeProfile( name: string, patchFiles: readonly string[], - deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [], ): ComposedProfile { const profile = prepareProfile(name) const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? [] - const rows = new Map() + const rows = new Map() for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) } - const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] - // The agent-preset roots are an assembly fact of every dsh launcher, not a - // patch author's choice: the shipped set sits beside this app's config and - // the user's own under the Harness home. Resolved per boot ($DSH_HOME may - // differ per run) and only patched when the composed tree actually mounts - // the roster — a one-shot `dsh run` composes agents from the same roster - // `dsh web` offers. + const composedOverlays = [...overlays] + // Preset roots belong to every dsh composition that mounts the roster. if (rows.has('agent-presets')) { - overlayAndFlags.push({ + composedOverlays.push({ id: 'agent-presets', config: { ...(rows.get('agent-presets')?.config ?? {}) as Record, @@ -184,58 +177,55 @@ function composeProfile( }) } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) - if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) - return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows } + if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) + return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows } } /** Options for {@link runProfile}. */ export interface RunProfileOptions { + /** This run's frozen environment snapshot, provided before any entry mounts. */ + environment: EnvironmentSnapshot /** The profile name to boot. */ profile: string /** `--patch` overlay paths, in argv order. */ patchFiles: readonly string[] - /** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */ - deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[] - /** `dsh run` task text; requires the composition to mount the headless runner row. */ - task?: string - /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ - prepare?: (ctx: Context, rows: ProfileRows) => Promise | void - /** This run's frozen environment snapshot, provided to the tree before any entry mounts. */ - environment: EnvironmentSnapshot -} - -/** Re-throw setup failures unless this invocation's signal already owns shutdown. */ -function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void { - if (!signal.aborted) throw error + /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */ + args: readonly string[] + /** Host setup registered after Loader installation and before any config-tree entry mounts. */ + prepare?: (ctx: Context) => Promise | void } /** * Boot one profile invocation end to end and leave process lifetime to the - * mounted plugins (or to the one-shot runner when `task` is present). - * @param options - profile name, overlays, flag patches, and the optional task. + * mounted plugins (or to a one-shot runner the composition mounts). + * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments. * @returns the settled root context and the shutdown controller. */ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { - const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches) - if (options.task !== undefined) { - if (!composed.rows.has(HEADLESS_ROW_ID)) { - throw new Error( - `dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row ` - + '(the headless profile does)', - ) - } - composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } }) - } else if (composed.rows.has(HEADLESS_ROW_ID)) { - // The inverse misuse: a one-shot composition booted without its task - // would otherwise die in the runner row's schema with a raw "required" - // error naming no fix. + const composed = composeProfile(options.profile, options.patchFiles) + if (!hasCmdlineConsumer([...composed.rows.values()]) && options.args.length > 0) { throw new Error( - `dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: ` - + `dsh run --profile ${options.profile} ""`, + `${NAME}: profile ${JSON.stringify(options.profile)} takes no app arguments because no active row injects cmdlineArgs; ` + + `got ${options.args.map(argument => JSON.stringify(argument)).join(' ')}`, ) } + // A one-shot composition ends by itself, which changes what a signal means + // and makes watching the user's patch layer pointless. + const headlessRow = composed.rows.get(HEADLESS_ROW_ID) + const oneShot = headlessRow !== undefined && headlessRow.disabled !== true const app: { current?: Context } = {} + // Readiness for rows that publish it (the web URL line): a row can activate + // before concurrently mounted siblings finish or fail. + let bootSettled: () => void = () => {} + let bootFailed: (reason: unknown) => void = () => {} + const ready = new Promise((resolve, reject) => { + bootSettled = resolve + bootFailed = reject + }) + // Nothing awaits `ready` on a composition that publishes no readiness, and + // an unobserved rejection must not take the process down on its own. + ready.catch(() => {}) const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) const signalShutdown = new AbortController() const interrupt = (code: number): void => { @@ -243,9 +233,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con shutdown.interrupt(code) } // Signals own teardown throughout the startup window, not only after boot() - // settles: an inserted entry point can publish readiness before sibling rows + // settles: an inserted startup row can publish readiness before sibling rows // finish mounting. - process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) + process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) }) process.on('SIGINT', () => { interrupt(130) }) installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose() @@ -253,7 +243,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME) // Recomposition for the live user layers: bundle layers below, overlays - // and flag patches above, so a user edit can never displace them. BOTH + // above, so a user edit can never displace them. What an app's startup row + // resolved is not in here at all — it lives in that row's own service, which + // survives a recomposition. BOTH // user files are re-read per generation (the HMR watcher hands us only the // changed file's patches, which one of the reads duplicates — fresh reads // keep the two watchers from stitching in each other's stale copy). @@ -267,19 +259,27 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con ...composed.windowsShellPatches, ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], - ...composed.overlayAndFlags, + ...composed.overlays, ]) // One-shot runs exit through the runner; watching would only hold the // process open after its exit request. - const watchProfilePatch = options.task === undefined + const watchProfilePatch = !oneShot // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => { app.current = hostCtx - // Before any config-tree entry mounts, so a plugin that resolves a - // user-facing value at construction already sees this run's layers. + // Before any config-tree entry mounts, so plugins resolve all launch-time + // environment values from the same immutable provenance snapshot. hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment) - if (options.task !== undefined) { + // The command line is a launcher fact every app reads the same way: its + // own arguments, and the bounded exit its startup row requests after + // printing help or rejecting them. + provideCmdline(hostCtx, { + args: options.args, + exit: code => void shutdown.shutdown(code), + ready, + }) + if (oneShot) { const io: HeadlessIo = { stdout: process.stdout, stderr: process.stderr, @@ -287,9 +287,13 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } hostCtx.provide('headlessIo', io) } - await options.prepare?.(hostCtx, composed.rows) + await options.prepare?.(hostCtx) + }).catch((cause: unknown) => { + bootFailed(cause) + throw cause }) app.current = ctx + bootSettled() // A surface can dispose the whole tree while startup or this post-boot // watcher setup is still in flight. Loader presence and fiber state own // liveness; the local signal fact distinguishes that expected exit race @@ -323,7 +327,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con compose: composeLive, }) } catch (error) { - suppressSignalShutdownError(signalShutdown.signal, error) + if (!signalShutdown.signal.aborted) throw error } } return { ctx, shutdown } diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts deleted file mode 100644 index 8d056d100b..0000000000 --- a/apps/cli/src/web.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * `dsh web` — the browser-surface alias over the profile boot: `--profile web` - * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag - * becoming a patch over the composed profile - * tree. All web runtime glue (dist serving, prompt section, URL line) lives - * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives - * flag patches and the LAN-trust snapshot. - * @module @deepseek-ai/dsh/web - */ - -import { networkInterfaces } from 'node:os' -import { fileURLToPath } from 'node:url' -import type { Context } from '@deepseek-ai/cordis' -import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' -import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' -import { runProfile, type ProfileRows } from './profile-boot.ts' - -const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) - -/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */ -const ALL_INTERFACES_HOST = '0.0.0.0' - -/** - * Non-internal IPv4 interface addresses of this machine — the IP-literal - * authorities an all-interfaces bind is reachable by on the LAN. - * @returns the addresses in interface order (possibly empty). - */ -function lanIPv4Addresses(): string[] { - return Object.values(networkInterfaces()).flat() - .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - .map(iface => iface.address) -} - -/** - * One LAN-trust resolution for one invocation, sampled exactly once: the - * machine's LAN IP literals when the effective bind is all-interfaces, and - * the `trustedHosts` value built from them plus the explicit extras. The - * single sample is deliberate — display must advertise only addresses the - * fence was configured with, so the web-app row receives this same snapshot. - * Derived entries are port-less IP literals: DNS rebinding needs an - * attacker-controlled name, so an IP-literal Host is safe on any port, and - * the bound port may be OS-assigned, unknowable pre-boot. - * @param bindHost - the effective webserver bind host (CLI flag, else the composed row value). - * @param extra - `--trusted-host` values, in argv order. - * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). - */ -export function resolveLanTrust( - bindHost: string | undefined, - extra: readonly string[], -): { lanAddresses: string[]; trustedHosts: string[] } { - const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] - return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } -} - -/** The `dsh web` flag family, already parsed by the argument adapter. */ -export interface WebFlags { - patches: string[] - host?: string - port?: number - dev: boolean - trustedHosts?: string[] -} - -/** - * Derive the web alias's flag patches over an already-composed profile tree. - * Patches replace a row's whole config, so each patched row's composed values - * are re-read and merged under the overrides. - * @param rows - the composed row index from {@link composeProfile}. - * @param flags - the parsed flag family. - * @returns the flag patch list, in application order. - */ -function deriveWebFlagPatches( - rows: ProfileRows, - flags: WebFlags, -): PatchOptions[] { - const overrides = new Map>() - const put = (entryId: string, key: string, value: unknown): void => { - const bag = overrides.get(entryId) ?? {} - bag[key] = value - overrides.set(entryId, bag) - } - if (flags.host !== undefined) put('webserver', 'host', flags.host) - if (flags.port !== undefined) put('webserver', 'port', flags.port) - const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host - const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) - if (trustedHosts.length > 0) { - // Additive over the composed value: a cordis.patch.yml-configured fence - // authority must survive the derived LAN literals and flag extras — a - // silent drop of security-relevant fence configuration. - const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] - put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts]) - } - // mode and lanAddresses are launcher-derived on every boot (--dev also - // inserts the client-hmr row), never pass-throughs of composed values. - put('web-runtime', 'mode', flags.dev ? 'development' : 'production') - put('web-runtime', 'lanAddresses', lanAddresses) - // The agent-preset roots are patched by the shared profile boot: they are - // an assembly fact of every dsh launcher, and `dsh run` composes agents - // from the same roster this alias offers. - const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => { - const composed = rows.get(id) - if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`) - return { id, config: { ...(composed.config ?? {}) as Record, ...bag } } - }) - if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] }) - return patches -} - -/** - * Whether the composed Web runtime keeps its model- and shell-visible surface - * context. The bundle schema defaults the field to true, so only an explicit - * false suppresses both the bundle contributions and the launcher-owned - * source-checkout section. - * @param rows - the composed Web profile rows before launcher flag patches. - * @returns true unless the web-runtime row explicitly disables surface context. - */ -export function webSurfaceContextEnabled(rows: ProfileRows): boolean { - return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false -} - -/** - * Serve the browser UI from the web profile. Host/port flags are passed - * through only when given (absent, the composed profile values - * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on - * every boot. The URL line is printed by the web-app bundle's runtime row - * after Loader settlement. - * @param flags - the parsed `dsh web` flag family. - * @param environment - this run's frozen environment snapshot. - */ -export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise { - await runProfile({ - environment, - profile: 'web', - patchFiles: flags.patches, - deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags), - prepare: (ctx: Context, rows: ProfileRows) => { - if (!webSurfaceContextEnabled(rows)) return - ctx.inject(['systemPrompt'], (promptCtx) => { - addHarnessSourceSection(promptCtx, SOURCE_ROOT) - }) - }, - }) -} diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index e30739214d..ce0a5b2ba4 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -21,22 +21,28 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes profile boots, one-shot runs, and the web alias', () => { - expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] }) + it('routes profile boots and the web alias, handing the rest to the app', () => { + expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] }) expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml'])) - .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) - expect(parse(['run', 'run', 'the', 'tests'])) - .toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' }) - expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests'])) - .toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' }) - expect(parse(['run', '--', '--profile', 'is', 'task', 'text'])) - .toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' }) - expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) - expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) + .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] }) + expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] }) + expect(parse(['web', '--patch', 'web.yml'])) + .toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] }) + }) + + it('ends the launcher flags at the first token it does not own', () => { + // App flags, including its -h, and positionals reach the app verbatim. + expect(parse(['--profile', 'tui', '--resume', 'abc'])) + .toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] }) + expect(parse(['--profile', 'web', '-h'])) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] }) expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev'])) - .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] }) - expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) - .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '0.0.0.0', '--port', '8080', '--dev'] }) + expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) + .toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] }) + // Launcher flags placed after that boundary belong to the app too. + expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml'])) + .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] }) }) it('routes the plugin pnpm forwarder', () => { @@ -64,18 +70,12 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] }) }) - it('rejects missing profile, flags outside the current grammar, and contradictory inputs', () => { + it('rejects missing profile, removed flags, and contradictory inputs', () => { expect(exitCode([])).toBe(1) - expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile - expect(exitCode(['--config', 'c.yml'])).toBe(1) // outside the current grammar - expect(exitCode(['-p', 'task'])).toBe(1) // outside the current grammar - expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run` - expect(exitCode(['run'])).toBe(1) - expect(exitCode(['run', ''])).toBe(1) - expect(exitCode(['run', '--profile', '', 'task'])).toBe(1) - expect(exitCode(['run', '--patch=', 'task'])).toBe(1) - expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1) - expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1) + expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach + expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed + expect(exitCode(['-p', 'task'])).toBe(1) // removed + expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand expect(exitCode(['--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', '--patch='])).toBe(1) expect(exitCode(['--dump-config'])).toBe(1) @@ -87,21 +87,20 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1) expect(exitCode(['web', '--patch='])).toBe(1) - // Boot-free dumps derive no flag patches; silently dropping the flags - // would print a tree that differs from the same invocation's boot. + // A dump never runs the app's startup row, so it cannot show what that + // app's own flags would decide; printing a tree that differs from the same + // invocation's boot would mislead. expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1) - expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1) - // A non-numeric port fails at the flag, not deep in the webserver schema. - expect(exitCode(['web', '--port', 'abc'])).toBe(1) + expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1) expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward expect(exitCode(['plugin', '--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1) }) - it('exits 0 for help and version', () => { + it('keeps its own help for an invocation with no app to hand it to', () => { expect(exitCode(['--help'])).toBe(0) - expect(exitCode(['run', '--help'])).toBe(0) + expect(exitCode(['-h'])).toBe(0) expect(exitCode(['--version'])).toBe(0) }) }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 128fbbd42c..c8fc8e8f64 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -193,8 +193,105 @@ function createEnvironmentProbeProfile(home: string, project: string): void { ].join('\n')) } +interface StartupFixture { + home: string + ready: string + echo: string + /** An always-running row's echo, used to observe that a user patch reload landed. */ + witness: string +} + +/** + * A custom profile whose bundle owns a command line: a startup row whose + * `cmdlineArgs` injection identifies it to the launcher, and a row that reads + * what it resolved through a `!!js` config expression. Both plugin modules resolve + * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module + * fallback, exactly as an installed out-of-tree bundle does. + */ +function createStartupFixture(): StartupFixture { + const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-')) + const profileDir = join(home, 'profiles', 'startup') + // Written straight into the installed location: a row module resolves its + // own imports from where it is installed, and only inside the profile does + // Node's parent walk reach the installation fallback these plugins need. + const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle') + mkdirSync(bundleDir, { recursive: true }) + writeFileSync(join(bundleDir, 'startup.mjs'), [ + "import { Command } from 'commander'", + "import { runStartup } from '@deepseek-ai/dsh-cmdline'", + "export const name = 'fixture-startup'", + "export const inject = ['cmdlineArgs']", + 'export function apply(ctx) {', + " const program = new Command().name('fixture').option('--generation ', 'echoed generation')", + " return runStartup(ctx, 'fixtureStartup', program, parsed => ({ generation: parsed.opts().generation }))", + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'waiting.mjs'), [ + "import { writeFileSync } from 'node:fs'", + "import { join } from 'node:path'", + "export const name = 'startup-fixture'", + 'export function apply(ctx, config = {}) {', + ' const heartbeat = setInterval(() => {}, 1000)', + " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", + " writeFileSync(process.env.RAW_READY_FILE, 'ready')", + ' ctx.effect(() => () => { clearInterval(heartbeat) })', + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'witness.mjs'), [ + "import { writeFileSync } from 'node:fs'", + "import { join } from 'node:path'", + "export const name = 'reload-witness'", + 'export function apply(ctx, config = {}) {', + " writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))", + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'cordis.patch.yml'), [ + '- insert:', + ' - id: startup-fixture', + ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`, + ' inject: [fixtureStartup]', + ' config:', + // The flag the startup row resolved wins over the value written beside it. + " generation: !!js ctx.get('fixtureStartup')?.generation ?? 'bundle-default'", + ' - id: fixture-startup', + ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`, + ' inject: [cmdlineArgs]', + ' - id: reload-witness', + ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`, + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({ + name: 'dsh-startup-bundle', + version: '0.0.0', + type: 'module', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-startup', + private: true, + dependencies: {}, + dsh: { profile: { bundles: ['dsh-startup-bundle'] } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n') + return { home, ready: join(home, 'ready'), echo: join(home, 'config-echo'), witness: join(home, 'witness') } +} + +function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { + return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], { + cwd: fixture.home, + input: '', + reject: false, + timeout: 25_000, + killSignal: 'SIGKILL', + env: { DSH_HOME: fixture.home, RAW_READY_FILE: fixture.ready }, + }) +} + describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { - it('requires --profile and rejects inputs outside the current grammar', async () => { + it('requires --profile and rejects removed commands', async () => { const bare = await runBuiltBin() expect(bare.code).toBe(1) expect(bare.stdout).toBe('') @@ -202,46 +299,63 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const help = await runBuiltBin(['--help']) expect(help.code).toBe(0) expect(help.stdout).toContain('dsh --profile web') - expect(help.stdout).toContain('dsh run "run the tests"') expect(help.stdout).toContain('dsh plugin --profile') expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu) - for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) { - const result = await runBuiltBin(outsideGrammar) + for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) { + const result = await runBuiltBin(removed) expect(result.code).toBe(1) } }, 30_000) - it('prints run help without initializing the selected profile', async () => { - const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-')) - const home = join(parent, 'not-created') + it('routes help and usage errors without activating startup-dependent rows', async () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-')) try { - const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home }) - expect(result.code).toBe(0) - expect(result.stderr).toBe('') - expect(result.stdout).toContain('Usage: dsh run [options] ') - expect(existsSync(home)).toBe(false) - } finally { - rmSync(parent, { recursive: true, force: true }) - } - }) + const web = await runBuiltBin(['--profile', 'web', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(web.code).toBe(0) + expect(web.stderr).toBe('') + expect(web.stdout).toContain('Usage: dsh --profile web') + expect(web.stdout).toContain('--port ') + expect(web.stdout).not.toContain('dsh web: http://') - it('runs the default headless profile through the published run command', async () => { - const apiKey = 'built-dsh-run-key' + const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(headlessHelp.code).toBe(0) + expect(headlessHelp.stderr).toBe('') + expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless') + + const missingTask = await runBuiltBin(['--profile', 'headless'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(missingTask.code).toBe(1) + expect(missingTask.stderr).toContain('a task is required') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + + it('runs the headless profile through its app-owned task positional', async () => { + const apiKey = 'built-dsh-headless-key' const server = await startMockLlmServer({ sequence: ['success'], apiKey, - successText: 'published dsh run reached the mock', + successText: 'published headless profile reached the mock', }) - const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-')) + const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-')) try { - const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], { + const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: apiKey, DEEPSEEK_BASE_URL: server.baseURL, }) expect(result.code, result.stderr).toBe(0) - expect(result.stdout).toBe('published dsh run reached the mock') + expect(result.stdout).toBe('published headless profile reached the mock') expect(result.stderr).toBe('') expect(server.requests.length).toBeGreaterThan(0) expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) @@ -317,9 +431,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }, 30_000) it('reports a patch-overlay boot failure without hanging', async () => { - // An HMR main-watcher initial scan that refreshes the include - // mid-initial-apply deadlocks the failing apply's rollback against the - // refresh drain: dsh exits 13 with no diagnostic instead of settling + // The HMR main watcher's initial scan once refreshed the include + // mid-initial-apply, deadlocking the failing apply's rollback against the + // refresh drain: dsh exited 13 with no diagnostic instead of settling // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)). const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-')) try { @@ -336,6 +450,18 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('rejects arguments when no active row injects the profile command line', async () => { + const fixture = createProfileLifecycleFixture() + try { + const result = await runBuiltBin(['--profile', 'lifecycle', '--help'], { DSH_HOME: fixture.home }) + expect(result.code).toBe(1) + expect(result.stderr).toContain('takes no app arguments because no active row injects cmdlineArgs') + expect(existsSync(fixture.ready)).toBe(false) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + it('applies a custom profile bundle and disposes it on a startup-time signal', async () => { const fixture = createProfileLifecycleFixture() const child = startProfileLifecycle(fixture) @@ -404,6 +530,83 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('hands the app arguments to the profile, which applies them before its rows start', async () => { + const fixture = createStartupFixture() + const child = startStartupProfile(fixture, ['--generation', 'flagged']) + try { + await waitForFile(fixture.ready) + // The waiting row started once, already carrying the flag value: the + // launcher never saw --generation, and the app resolved it first. + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + child.kill('SIGTERM') + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it('starts a waiting row on its composed value when the invocation carries no app arguments', async () => { + const fixture = createStartupFixture() + const child = startStartupProfile(fixture, []) + try { + await waitForFile(fixture.ready) + expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default') + child.kill('SIGTERM') + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it('keeps the app arguments across a user patch reload', async () => { + // A live edit recomposes every row while the startup service remains + // active, so each config expression reads the same invocation value (a + // served port does not move back to its composed fallback). + const fixture = createStartupFixture() + const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml') + const child = startStartupProfile(fixture, ['--generation', 'flagged']) + try { + // Both rows: the waiting one carries the flag value, and the witness is + // what a reload will re-mount. They start independently, so neither + // marker implies the other. + await waitForFile(fixture.ready) + await waitForFile(fixture.witness) + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + // An edit to an unrelated row: the witness re-mounts, which is how this + // test knows the whole tree was recomposed. + rmSync(fixture.witness) + writeFileSync(profilePatch, [ + '- id: reload-witness', + ' config:', + ' generation: reloaded', + '', + ].join('\n')) + await waitForFile(fixture.witness) + expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded') + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + child.kill('SIGTERM') + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it("prints the app's own help, starts none of its rows, and exits", async () => { + const fixture = createStartupFixture() + try { + const result = await startStartupProfile(fixture, ['--help']) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Usage: fixture') + expect(result.stdout).toContain('--generation') + expect(existsSync(fixture.ready)).toBe(false) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + it('anchors a relative add spec to the invoking directory, not the profile', async () => { // `dsh plugin --profile x add .` from a plugin checkout must install THAT // checkout — pnpm's cwd is the profile directory, so an un-anchored `.` @@ -490,20 +693,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") }, 30_000) - it('prints a headless profile with no Host, HTTP, or browser rows', async () => { - const { stdout, code, stderr } = await runBuiltBin( - ['--profile', 'headless', '--dump-default-config'], - { DSH_HOME: home }, - ) - expect(code).toBe(0) - expect(stderr).toBe('') - expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-default-model'") - expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-host-") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-") - }, 30_000) - it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home }) diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/apps/cli/tests/trusted-hosts.spec.ts deleted file mode 100644 index 5647d01536..0000000000 --- a/apps/cli/tests/trusted-hosts.spec.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ - -import { describe, expect, it, vi } from 'vitest' -import { resolveLanTrust, webSurfaceContextEnabled } from '../src/web.ts' - -vi.mock('node:os', () => ({ - networkInterfaces: () => ({ - lo0: [ - { family: 'IPv4', internal: true, address: '127.0.0.1' }, - ], - en0: [ - { family: 'IPv6', internal: false, address: 'fe80::1' }, - { family: 'IPv4', internal: false, address: '192.168.1.5' }, - ], - en1: [ - { family: 'IPv4', internal: false, address: '10.0.0.7' }, - ], - utun0: undefined, - }), -})) - -describe('resolveLanTrust', () => { - it('samples non-internal IPv4 addresses once for an all-interfaces bind: trust and display share them', () => { - const { lanAddresses, trustedHosts } = resolveLanTrust('0.0.0.0', ['harness.internal:3080']) - expect(lanAddresses).toEqual(['192.168.1.5', '10.0.0.7']) - expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) - }) - - it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { - expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) - expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) - }) -}) - -describe('webSurfaceContextEnabled', () => { - it('defaults to enabled and honors an explicit complete-prompt disable', () => { - expect(webSurfaceContextEnabled(new Map())).toBe(true) - expect(webSurfaceContextEnabled(new Map([ - ['web-runtime', { config: { mode: 'production' } }], - ]))).toBe(true) - expect(webSurfaceContextEnabled(new Map([ - ['web-runtime', { config: { surfaceContext: false } }], - ]))).toBe(false) - }) -}) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index d830e8fba6..a288a5aa95 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../packages/boot/app-boot" }, + { + "path": "../../packages/ui/cmdline" + }, { "path": "../../packages/bundle/base" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a05f336f3e..5ae88ddf96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../packages/client/ui-agent-preset + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline '@deepseek-ai/dsh-command-compact': specifier: workspace:^ version: link:../../packages/compact/command-compact