fix: retire leftovers of the removed $DSH_HOME/config.yaml personal overlay

The profile rework left references to the old entry modes behind. Renames
the user patch-layer API and its spec file (watchPersonalPatches ->
watchUserPatches, personal-config.spec.ts -> user-patches.spec.ts) and
retargets the prose that still named `config.yaml`, `--config`, raw-config
mode, and surface overlays: repository-plugin and mcp-memory READMEs, the
credentials-local anchor into app-boot, vendor manifest items 12-13, the
vendored include/hmr comments, and install.sh.

Restores the boot-failure guard the rework dropped with raw mode: the
built-bin case now boots `--profile web --patch <invalid>` and asserts the
settled diagnostic and exit 1, so the HMR initial-scan deadlock stays
covered; its orphaned raw fixture is renamed and the unused one deleted.
The superseded personal-config Agent Note and its superseding profile note
are now cross-linked.
This commit is contained in:
Turtle
2026-08-06 17:28:51 +08:00
parent 20acfdb71d
commit ef30572e63
21 changed files with 79 additions and 87 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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。

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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/<name>/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

View File

@@ -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/<name>/cordis.patch.yml`,对本机所有 profile 生效则写入 `$DSH_HOME/cordis.patch.yml`。不要覆盖已有文件,其中可能已经包含无关的用户 patch。
## 提供方设置

View File

@@ -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

View File

@@ -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/<name>/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#<ref>`. 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

View File

@@ -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/<name>/cordis.patch.yml`,或写入各 profile 共享的 home 级 `$DSH_HOME/cordis.patch.yml``--patch` overlay 则只为单次运行 patch 同一配置项:
```yaml
- id: repository-plugins
@@ -43,7 +43,7 @@
每个源都必须采用 `github:owner/repository#<ref>`。省略 `&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)。
## 准备阶段

View File

@@ -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

View File

@@ -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.

View File

@@ -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 钥匙串提供方——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本提供方并列。

View File

@@ -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 {

View File

@@ -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<void> {
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()

View File

@@ -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

4
vendor/README.md vendored
View File

@@ -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

View File

@@ -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,
})

View File

@@ -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
}