diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index e4e9dfb93a..0406274fbe 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: 1fa8cda2b34b58cc7a28b722872520b68a9b7009 -2026-07-20-dsh-cli-personal-config.zh.md: e70b8914cf005e0a2e54ba2b29d3b7def84b00db +2026-07-20-dsh-cli-personal-config.md: 10f16a1cbabdd8cd383c59ad8e09787c02d0109a +2026-07-20-dsh-cli-personal-config.zh.md: 22435efbec8ea661c546ffd0c1aa9bb0ff2ebbb2 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 1fa8cda2b3..10f16a1cba 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -10,6 +10,8 @@ A developer's own preferences — which provider and model the TUI uses, persona ## Decision +The entry modes and the personal file's name and location below are superseded by the [profile plugin bundles decision](../architecture/2026-08-05-profile-plugin-bundles.md): `dsh` boots profiles, and the personal layer became the per-profile and home-level `cordis.patch.yml`. What survives unchanged is this note's substance — the Harness home as the machine-level layer's root, patch semantics over a shipped composition, and fail-loud parsing. + Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443): **The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` is the product-assembly tier over `packages/*` libraries. One bin dispatches the default interactive TUI, `-p`/`--prompt` headless turns, and the `web` surface. The TUI boots `examples/tui-agent/cordis.yml` (or `--config`) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the app with tsx's ESM hook; the [source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) owns that contract. `pnpm run demo:tui` runs the same entry. @@ -46,4 +48,4 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` pins parsing, startup application, exact-path add/failure/recovery/removal, last-good rollback, failure broadcast, and preservation of app-owned patches. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real dsh bin with no overlay, a personal environment and UI patch, a config-only cached repository skill, and invalid personal YAML. Test launchers isolate `$DSH_HOME`, so a developer's real overlay cannot leak into fixtures. +`packages/ui/app-boot/tests/user-patches.spec.ts` pins parsing, startup application, exact-path add/failure/recovery/removal, last-good rollback, failure broadcast, and preservation of app-owned patches. `apps/cli/tests/built-bin.e2e.ts` boots the real dsh bin over a profile and exercises the live patch layer end to end. Test launchers isolate `$DSH_HOME`, so a developer's real overlay cannot leak into fixtures. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index e70b8914cf..22435efbec 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -10,6 +10,8 @@ Status: implemented ## Decision +下文的各入口模式,以及个人文件的名称与位置,已被 [profile 插件组合包决策](../architecture/2026-08-05-profile-plugin-bundles.md)取代:`dsh` 启动 profile,个人层变成逐 profile 与 home 级的 `cordis.patch.yml`。保留不变的是本笔记的实质:以 Harness home 作为机器级层的根目录、在随附组合之上使用 patch 语义,以及解析时的大声失败。 + 两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: **`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,并使用 tsx 的 ESM hook 运行应用;该契约由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护。`pnpm run demo:tui` 运行同一入口。 @@ -46,4 +48,4 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Testing -`packages/ui/app-boot/tests/personal-config.spec.ts` 固定解析、启动时应用、确切路径的新增/失败/恢复/移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 启动真实 dsh bin,覆盖无 overlay、个人环境与 UI patch、纯配置的缓存 repository skill,以及无效个人 YAML。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture。 +`packages/ui/app-boot/tests/user-patches.spec.ts` 固定解析、启动时应用、确切路径的新增/失败/恢复/移除、最后可用状态回滚、失败广播以及应用自有 patch 的保留。`apps/cli/tests/built-bin.e2e.ts` 启动真实 dsh bin 并基于 profile 端到端验证实时 patch 层。测试启动器会隔离 `$DSH_HOME`,因此开发者的真实 overlay 不会泄漏进 fixture。 diff --git a/apps/cli/tests/fixtures/raw-invalid-provider.cordis.yml b/apps/cli/tests/fixtures/invalid-provider.cordis.yml similarity index 58% rename from apps/cli/tests/fixtures/raw-invalid-provider.cordis.yml rename to apps/cli/tests/fixtures/invalid-provider.cordis.yml index 159d300aba..88534a1680 100644 --- a/apps/cli/tests/fixtures/raw-invalid-provider.cordis.yml +++ b/apps/cli/tests/fixtures/invalid-provider.cordis.yml @@ -1,4 +1,4 @@ -# Invalid raw overlay used to prove boot failures settle and exit. +# Invalid `--patch` overlay used to prove boot failures settle and exit. - id: llm-pi-ai config: diff --git a/apps/cli/tests/fixtures/raw-overlay.cordis.yml b/apps/cli/tests/fixtures/raw-overlay.cordis.yml deleted file mode 100644 index f205972450..0000000000 --- a/apps/cli/tests/fixtures/raw-overlay.cordis.yml +++ /dev/null @@ -1,12 +0,0 @@ -# Raw CLI overlay used by the built config-dump acceptance test. - -- id: agent-loop - config: - agents: - - id: configured - provider: configured-provider - model: configured-model - -- id: absent-row - config: - value: unmatched diff --git a/apps/web/tests/pin-browse-picker.overlay.yml b/apps/web/tests/pin-browse-picker.overlay.yml index 266c35e94b..d48dfa3538 100644 --- a/apps/web/tests/pin-browse-picker.overlay.yml +++ b/apps/web/tests/pin-browse-picker.overlay.yml @@ -1,4 +1,4 @@ -# Loader overlay for the W5 real-host smoke (`dsh web --config`): pin the +# Loader overlay for the W5 real-host smoke (`dsh web --patch`): pin the # in-browser directory picker. The shipped row is `-auto`, which resolves to # the native OS chooser on a loopback bind with a local display — an # interaction a Playwright page cannot drive, so the resolved backend would diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index f689a9cd36..f89035cfcc 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/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 examples/mcp-memory/README.md -README.md: f60bef4c4a44a3c0fb87bec0f7952069566393b5 -README.zh.md: 476e1fbb0b9f22864cc66d8f5d505a0d59e296ae +README.md: 023e6aefce0e78cbbf52620426376e1dd0a6b8cf +README.zh.md: 44ace680cd583f41903437a69c62e30817308ba2 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index f60bef4c4a..023e6aefce 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -42,7 +42,7 @@ dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" Replace `memorix.cordis.yml` in the URL with either of the other filenames to select it. Review a downloaded overlay before running it: Cordis configuration can contain executable `!!js` expressions. -To keep the selection in personal configuration, merge the chosen file's single `insert` patch into `$DSH_HOME/config.yaml` (normally `~/.dsh/config.yaml`). Do not copy over an existing file: it may already contain unrelated personal patches. +To keep the selection across runs, merge the chosen file's single `insert` patch into a user patch layer — `$DSH_HOME/profiles//cordis.patch.yml` for one profile, or `$DSH_HOME/cordis.patch.yml` for every profile on the machine. Do not copy over an existing file: it may already contain unrelated user patches. ## Provider setup diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 476e1fbb0b..44ace680cd 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -42,7 +42,7 @@ dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" 若要选择另外任一配置,请将 URL 中的 `memorix.cordis.yml` 替换为对应文件名。运行下载的 overlay 前,请先审阅其内容:Cordis 配置可以包含可执行的 `!!js` 表达式。 -如果要把所选配置保存在个人配置中,请将对应文件中的单个 `insert` patch 合并到 `$DSH_HOME/config.yaml`(通常是 `~/.dsh/config.yaml`)。不要覆盖已有文件,其中可能已经包含无关的个人 patch。 +如果要跨次运行保留所选配置,请将对应文件中的单个 `insert` patch 合并到用户 patch 层:只对一个 profile 生效则写入 `$DSH_HOME/profiles//cordis.patch.yml`,对本机所有 profile 生效则写入 `$DSH_HOME/cordis.patch.yml`。不要覆盖已有文件,其中可能已经包含无关的用户 patch。 ## 提供方设置 diff --git a/packages/cordis/repository-plugin/README.i18n.yaml b/packages/cordis/repository-plugin/README.i18n.yaml index ea7a1305b3..daf43fa018 100644 --- a/packages/cordis/repository-plugin/README.i18n.yaml +++ b/packages/cordis/repository-plugin/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/cordis/repository-plugin/README.md -README.md: 2555876734ddcab7b0bc0f25780010bef0c97a01 -README.zh.md: 41341f7d7ed92ac071dde274a0328784bc2888d4 +README.md: 33cd763d7dbe21b72f9e604b7b2e313081cf656f +README.zh.md: 903dfbe601cc76acb0c1e87453dc03ef0321409b diff --git a/packages/cordis/repository-plugin/README.md b/packages/cordis/repository-plugin/README.md index 2555876734..33cd763d7d 100644 --- a/packages/cordis/repository-plugin/README.md +++ b/packages/cordis/repository-plugin/README.md @@ -30,7 +30,7 @@ Place an ordinary package in the repository's `.dsh-plugin` directory: ## Standalone app configuration -The shipped `dsh` base used by raw-config, Web, and headless modes contains an empty `repository-plugins` row. A Web or headless user enables exact GitHub generations by replacing that row's config in `$DSH_HOME/config.yaml` (default `~/.dsh/config.yaml`); a raw-config deployment patches the same row in its explicit overlay: +The shipped `dsh-base` bundle every profile starts from contains an empty `repository-plugins` row. A user enables exact GitHub generations by replacing that row's config in a user patch layer — `$DSH_HOME/profiles//cordis.patch.yml`, or the home-level `$DSH_HOME/cordis.patch.yml` shared by every profile; a `--patch` overlay patches the same row for one run: ```yaml - id: repository-plugins @@ -43,7 +43,7 @@ The shipped `dsh` base used by raw-config, Web, and headless modes contains an e Each source must use `github:owner/repository#`. Omitting `&path:` selects `/.dsh-plugin`; an explicit path is absolute within the repository and must end in `.dsh-plugin`. A commit ref gives the clearest immutable identity, while tags and branches remain accepted exact config values. `cacheDir` may override the default `$DSH_HOME/cache/repository-plugins` cache root. -Web watches `config.yaml` through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. Headless reads the file only at startup, and raw-config mode reads only its explicit overlay. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). +Long-lived surfaces watch both `cordis.patch.yml` layers through Cordis HMR. A valid source-list change installs and swaps the complete repository Plugin generation; a failed fetch, prepare, import, or Plugin application keeps the last good tree and broadcasts `hmr/config-update-failed(filename, error)`. One-shot runs read the layers only at startup, and a `--patch` overlay is never watched. An identical source string permanently reuses its prepared cache entry, so selecting changed code requires a ref, path, or other source-config change. App integration rationale: [config-only repository Plugins Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md). ## Preparation diff --git a/packages/cordis/repository-plugin/README.zh.md b/packages/cordis/repository-plugin/README.zh.md index 41341f7d7e..903dfbe601 100644 --- a/packages/cordis/repository-plugin/README.zh.md +++ b/packages/cordis/repository-plugin/README.zh.md @@ -30,7 +30,7 @@ ## 独立应用配置 -随附 `dsh` 中供原始配置、Web 与无头模式使用的基础配置包含一个空 `repository-plugins` 配置项。Web 或无头用户可在 `$DSH_HOME/config.yaml`(默认 `~/.dsh/config.yaml`)中替换该配置项的配置,以启用精确指定的 GitHub generation;原始配置部署则在显式 overlay 中 patch 同一配置项: +每个 profile 都以之为起点的随附 `dsh-base` 组合包包含一个空 `repository-plugins` 配置项。用户可在用户 patch 层中替换该配置项的配置来启用精确指定的 GitHub generation:写入 `$DSH_HOME/profiles//cordis.patch.yml`,或写入各 profile 共享的 home 级 `$DSH_HOME/cordis.patch.yml`;`--patch` overlay 则只为单次运行 patch 同一配置项: ```yaml - id: repository-plugins @@ -43,7 +43,7 @@ 每个源都必须采用 `github:owner/repository#`。省略 `&path:` 时选择 `/.dsh-plugin`;显式路径是仓库内的绝对路径,并且必须以 `.dsh-plugin` 结尾。commit ref 提供最清晰的不可变身份;tag 和 branch 仍可作为精确配置值使用。`cacheDir` 可覆盖默认缓存根 `$DSH_HOME/cache/repository-plugins`。 -Web 通过 Cordis HMR(热模块替换)监视 `config.yaml`。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。无头模式只在启动时读取该文件,原始配置模式只读取其显式 overlay。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 +长期运行的 surface 通过 Cordis HMR(热模块替换)监视两个 `cordis.patch.yml` 层。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。一次性运行只在启动时读取这些层,`--patch` overlay 则从不被监视。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 ## 准备阶段 diff --git a/packages/credentials/credentials-local/README.i18n.yaml b/packages/credentials/credentials-local/README.i18n.yaml index 6575a13867..9087d22f51 100644 --- a/packages/credentials/credentials-local/README.i18n.yaml +++ b/packages/credentials/credentials-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md -README.md: 02b883958faf8b695a3a2abf2df77790cc2fca86 -README.zh.md: a279336f59ca9525ebeb918dd23fb0b7682443d5 +README.md: 7d541d42efc942310e9a4066a9edadedb608ef0b +README.zh.md: 6a3d697551c8607bbab59a81c84eb725bf6191eb diff --git a/packages/credentials/credentials-local/README.md b/packages/credentials/credentials-local/README.md index 02b883958f..7d541d42ef 100644 --- a/packages/credentials/credentials-local/README.md +++ b/packages/credentials/credentials-local/README.md @@ -32,7 +32,7 @@ External edits publish `credentials/updated` per changed reference after the sna ## Security boundary -The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given. +The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, and the shipped `workspace-write` file policy confines mutations rather than reads, so they can read this file exactly like any other file the user owns; no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Harness-home layers](../../ui/app-boot/README.md#profiles)), so reaching the value takes a deliberate read of a path the agent was not given. That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package. diff --git a/packages/credentials/credentials-local/README.zh.md b/packages/credentials/credentials-local/README.zh.md index a279336f59..6a3d697551 100644 --- a/packages/credentials/credentials-local/README.zh.md +++ b/packages/credentials/credentials-local/README.zh.md @@ -32,7 +32,7 @@ dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一 ## 安全边界 -文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。 +文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,而已交付的 `workspace-write` 文件策略限制的是修改而非读取,因此它们读这个文件与读该用户拥有的任何其他文件毫无二致;也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的 Harness home 各层](../../ui/app-boot/README.md#profiles)),因此要拿到这个值,需要刻意去读一条并未交给 agent(智能体)的路径。 这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串提供方——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本提供方并列。 diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index 1e88954f69..45eab9ea7d 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -341,11 +341,11 @@ describe('include refresh with overlay patches', () => { describe('include patches layered over one base', () => { it('lets a later patch configure or disable a row an earlier patch inserted', async () => { - // The surface/`--config`/personal composition: `dsh` includes one shared - // base and applies each source as its own patch list at the SAME include + // The bundle/user-layer/`--patch` composition: `dsh` includes one root + // and applies each source as its own patch list at the SAME include // level, because patches never cross an include boundary. A later layer // must therefore be able to reach a row an earlier layer inserted, or - // surface-only rows would be invisible to the user's personal config. + // bundle-only rows would be invisible to the user's patch layer. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-')) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n') @@ -355,30 +355,30 @@ describe('include patches layered over one base', () => { ' config:', ' path: ./base.yml', ' patches:', - // Layer 1 (a surface overlay): patch a base row and add two of its own. + // Layer 1 (a bundle layer): patch a base row and add two of its own. ' - id: shared', ' config:', - ' value: surface', + ' value: bundle', ' - insert:', - ' - id: surface-kept', + ' - id: bundle-kept', ' name: ./noop.mjs', ' config:', - ' value: surface-default', - ' - id: surface-dropped', + ' value: bundle-default', + ' - id: bundle-dropped', ' name: ./noop.mjs', // Layer 2 (the user): reconfigure one inserted row and disable the other. - ' - id: surface-kept', + ' - id: bundle-kept', ' config:', - ' value: personal', - ' - id: surface-dropped', + ' value: user', + ' - id: bundle-dropped', ' disabled: true', '', ].join('\n')) const ctx = await boot(NAME, join(dir, 'cordis.yml')) try { - expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' }) - expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' }) - const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped') + expect(entryConfig(ctx, 'shared')).toEqual({ value: 'bundle' }) + expect(entryConfig(ctx, 'bundle-kept')).toEqual({ value: 'user' }) + const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'bundle-dropped') expect(dropped?.options.disabled).toBe(true) expect(dropped?.fiber).toBeUndefined() } finally { diff --git a/packages/ui/app-boot/tests/personal-config.spec.ts b/packages/ui/app-boot/tests/user-patches.spec.ts similarity index 80% rename from packages/ui/app-boot/tests/personal-config.spec.ts rename to packages/ui/app-boot/tests/user-patches.spec.ts index ad224daeb2..333385ee50 100644 --- a/packages/ui/app-boot/tests/personal-config.spec.ts +++ b/packages/ui/app-boot/tests/user-patches.spec.ts @@ -17,12 +17,12 @@ import { boot, loadOptionalPatches, PROFILE_PATCH_FILENAME, - watchPersonalPatches, + watchUserPatches, } from '../src/index.ts' const NAME = 'dsh-test-bin' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-')) +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-')) async function eventually(test: () => boolean, message: string): Promise { const deadline = Date.now() + 10_000 @@ -39,15 +39,15 @@ describe('loadOptionalPatches', () => { delete process.env.DSH_HOME }) - it('returns undefined when no personal patches file exists', () => { + it('returns undefined when no user patch file exists', () => { expect(loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))).toBeUndefined() }) it('parses a patch list and preserves !!js expressions as loader expression nodes', () => { const dir = tmp() writeFileSync(join(dir, PROFILE_PATCH_FILENAME), [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", + '- id: agent-loop', + " name: '@deepseek-ai/dsh-agent-loop'", ' config:', ' model: !!js process.env.DSH_SPEC_MODEL', '- insert:', @@ -58,13 +58,13 @@ describe('loadOptionalPatches', () => { const patches = loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)) expect(patches).toHaveLength(2) expect(patches?.[0]).toMatchObject({ - id: 'tui-agent', + id: 'agent-loop', config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } }, }) expect(patches?.[1]?.insert).toHaveLength(1) }) - it('fails loud on an unreadable file (a present personal config is never skipped)', () => { + it('fails loud on an unreadable file (a present user patch layer is never skipped)', () => { const dir = tmp() mkdirSync(join(dir, PROFILE_PATCH_FILENAME)) // a directory: present, unreadable as a file expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))) @@ -92,7 +92,7 @@ describe('loadOptionalPatches', () => { }) }) -describe('boot with personal patches', () => { +describe('boot with user patches', () => { function writeTree(dir: string): string { writeFileSync(join(dir, 'noop.mjs'), [ 'export const name = "noop"', @@ -111,31 +111,31 @@ describe('boot with personal patches', () => { it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { const dir = tmp() - const personal = tmp() - writeFileSync(join(personal, PROFILE_PATCH_FILENAME), [ + const userDir = tmp() + writeFileSync(join(userDir, PROFILE_PATCH_FILENAME), [ '- id: noop', ' name: ./noop.mjs', ' config:', - ' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC', + ' value: !!js process.env.DSH_APP_BOOT_USER_SPEC', '- insert:', - ' - id: personal-extra', + ' - id: user-extra', ' name: ./noop.mjs', '', ].join('\n')) - process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value' - const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(personal, PROFILE_PATCH_FILENAME))) + process.env['DSH_APP_BOOT_USER_SPEC'] = 'user-value' + const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(userDir, PROFILE_PATCH_FILENAME))) try { const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop') // The mounted plugin received the interpolated environment value. - expect(noop?.fiber?.config).toEqual({ value: 'personal-value' }) - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true) + expect(noop?.fiber?.config).toEqual({ value: 'user-value' }) + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'user-extra')).toBe(true) } finally { await ctx.fiber.dispose() - delete process.env['DSH_APP_BOOT_PERSONAL_SPEC'] + delete process.env['DSH_APP_BOOT_USER_SPEC'] } }) - it('mounts no patch layer for an absent or empty personal overlay', async () => { + it('mounts no patch layer for an absent or empty user layer', async () => { const dir = tmp() const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))) try { @@ -155,8 +155,8 @@ describe('boot with personal patches', () => { it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => { const dir = tmp() - const personal = tmp() - const filename = join(personal, PROFILE_PATCH_FILENAME) + const userDir = tmp() + const filename = join(userDir, PROFILE_PATCH_FILENAME) const basePatches = [{ id: 'noop', config: { value: 'generated' } }] const ctx = await boot(NAME, writeTree(dir), basePatches) await ctx.plugin(Timer) @@ -165,14 +165,14 @@ describe('boot with personal patches', () => { ctx.on('hmr/config-update-failed', (failedFilename, error) => { failures.push({ filename: failedFilename, error }) }) - const dispose = await watchPersonalPatches(ctx, { + const dispose = await watchUserPatches(ctx, { binName: NAME, filename, - compose: personalPatches => [...basePatches, ...personalPatches], + compose: userPatches => [...basePatches, ...userPatches], }) try { writeFileSync(filename, '- id: noop\n config:\n value: live\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch addition was not applied') writeFileSync(filename, '- id: noop\n config:\n fail: true\n') await eventually(() => failures.length === 1, 'failed candidate was not broadcast') @@ -192,17 +192,17 @@ describe('boot with personal patches', () => { await settleChokidarChangeThrottle() unlinkSync(filename) - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch') expect(failures).toHaveLength(2) await settleChokidarChangeThrottle() - // Default compose: the personal overlay IS the whole patch list, so a + // Default compose: the user layer IS the whole patch list, so a // fresh generation replaces the app-owned layer instead of stacking on it. await dispose() - const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, filename }) + const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename }) try { writeFileSync(filename, '- id: noop\n config:\n value: identity\n') - await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied') + await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user patch was not applied') } finally { await disposeDefault() } @@ -215,7 +215,7 @@ describe('boot with personal patches', () => { it('fails loud when the exact watcher lacks HMR or a root Include', async () => { const dir = tmp() const withoutHmr = await boot(NAME, writeTree(dir)) - await expect(watchPersonalPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the Cordis HMR service') + await expect(watchUserPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the Cordis HMR service') await withoutHmr.fiber.dispose() const withoutInclude = new Context() @@ -223,7 +223,7 @@ describe('boot with personal patches', () => { await withoutInclude.plugin(Loader) await withoutInclude.plugin(Timer) await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - await expect(watchPersonalPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry') + await expect(watchUserPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry') await withoutInclude.fiber.dispose() }) @@ -238,7 +238,7 @@ describe('boot with personal patches', () => { try { const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' }) ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) }) - const dispose = await watchPersonalPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) }) + const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) }) await expect(dispose()).resolves.toBeUndefined() } finally { await ctx.fiber.dispose() @@ -252,9 +252,9 @@ describe('boot with personal patches', () => { try { await ctx.plugin(Timer) await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 }) - const dispose = await watchPersonalPatches(ctx, { binName: NAME, filename }) - // Same personal path registered twice: HMR refuses; not a teardown race. - await expect(watchPersonalPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered') + const dispose = await watchUserPatches(ctx, { binName: NAME, filename }) + // Same user-layer path registered twice: HMR refuses; not a teardown race. + await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered') await dispose() } finally { await ctx.fiber.dispose() diff --git a/scripts/install.sh b/scripts/install.sh index b70652451e..5c9d892f73 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -46,7 +46,7 @@ # DSH_MASTER master clone directory (default: $DSH_SOURCE/master) # DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) # DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) -# DSH_HOME Harness home holding the personal config (default: ~/.dsh) +# DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh) # FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript # entrypoint; keep this POSIX shell file as the curl/source bootstrap. set -eu diff --git a/vendor/README.md b/vendor/README.md index c59a86ccca..9fa97413c2 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -41,8 +41,8 @@ Keep this log exhaustive — every divergence from upstream must be listed. 9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/ui/app-boot/tests/hmr-config.spec.ts`. 10. **`loader/src/repository.ts`, `loader/tsdown.config.ts`, and the `@cordisjs/plugin-loader/repository` export**: the Node-only `RepositoryCache` installs one exact dependency specifier through the bundled `pnpm@11.7.0`, single-flights callers, and atomically publishes only a prepared package plus marker under the specifier hash. The subpath stays out of the browser-reachable Loader entry. Identical specifiers permanently reuse that entry; callers change the ref/specifier for another generation. The isolated workspace permits dependency build scripts because a configured repository is executable code, while the child drops ambient credential-shaped variables. Covered by `packages/ui/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git prepare run through the bundled pnpm. 11. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. -12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/config/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. -13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a personal config present at registration must apply once. Covered by the raw invalid-provider built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +12. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. +13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. ## Sync procedure diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 2484d0152a..00864cd865 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -215,7 +215,7 @@ class Hmr extends Service { // the scan-triggered refresh waits on that apply — a teardown deadlock // that strands boot without a diagnostic. Only events after the scan // matter here; `registerConfig` keeps its own initial scan because a - // personal config present at registration must apply once. + // user patch layer present at registration must apply once. ignoreInitial: true, }) diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 4a9fd6be86..26b9305c52 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -85,10 +85,10 @@ export function applyEntryPatches( data.push(...insert) } // Index what this patch added so a LATER patch in the same list can - // target it. Patch lists compose one layer per source (surface overlay, - // then `--config`, then the user's), and a layer must be able to - // configure or disable a row an earlier layer inserted; without this, - // inserted rows were silently unpatchable. + // target it. Patch lists compose one layer per source (each bundle + // layer, then the user's, then `--patch` overlays), and a layer must be + // able to configure or disable a row an earlier layer inserted; without + // this, inserted rows were silently unpatchable. buildMap(insert) continue }