feat(cli): restore the home-level user patch layer as $DSH_HOME/cordis.patch.yml
The old $DSH_HOME/config.yaml personal overlay returns under the profile scheme's filename: machine-local preferences that apply to every profile, loaded after the profile's own cordis.patch.yml (so the home layer outranks it) and before --patch overlays and flag patches. Both user layers are hot-reloaded on long-lived surfaces and shown in --dump-config with their own provenance labels; the built-bin e2e covers the home layer landing live.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||
README.md: fe9ed6ef3e76c477d5e74f1e8d70c047365397d7
|
||||
README.zh.md: eae23a6f1a389d1c928e23188e3e6d4e5fb1dc3f
|
||||
README.md: bfff1408f001dd10e665d1c56944f778e87aea56
|
||||
README.zh.md: 2d585f7e0654cbe58fbdd2f33e3d7b77f154a487
|
||||
|
||||
@@ -17,7 +17,7 @@ The invoking directory is the default workspace root. The `web` and `headless` p
|
||||
|
||||
## Profiles
|
||||
|
||||
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the ordered `dsh.plugins` bundle 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.plugins` order, then `cordis.patch.yml`, then `--patch` overlays, then flag patches. Bundles named in `dsh.plugins` 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 ordered `dsh.plugins` bundle 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.plugins` 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.plugins` 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.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
## Profile
|
||||
|
||||
profile 目录包含一个 `package.json`(树外插件依赖,加上有序的 `dsh.plugins` 组合包列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.plugins` 顺序应用各组合包的 patch,然后是 `cordis.patch.yml`,然后是 `--patch` overlay,最后是 flag patch。`dsh.plugins` 中列出的组合包先从 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`(树外插件依赖,加上有序的 `dsh.plugins` 组合包列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.plugins` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay,最后是 flag patch。`dsh.plugins` 中列出的组合包先从 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、关闭行为、部署默认值和源码启动器。
|
||||
|
||||
|
||||
@@ -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: 3caf6a513bb1a5a74f18523c45703967f0e8f016
|
||||
README.zh.md: 323fe9d5c7a1b3eca6e3e8b7acf26f576e78041e
|
||||
README.md: 583ee093119eb01ff7b37a6aced7b1d9d8cedc92
|
||||
README.zh.md: 452dee18ec94e05bcff269a5f24fe0d455c6fe96
|
||||
|
||||
@@ -6,7 +6,7 @@ This reference defines the profile, web-alias, plugin-management, and config-dum
|
||||
|
||||
## Profile boot
|
||||
|
||||
`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.plugins` list, the profile's own `cordis.patch.yml`, each `--patch <path>` 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 <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.plugins` 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 <path>` 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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -21,7 +21,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` and `--patch` overlays. Both print provenance comments per layer; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
|
||||
`--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 provenance comments per layer; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr.
|
||||
|
||||
## Plugin management
|
||||
|
||||
@@ -47,7 +47,7 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
|
||||
|
||||
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
|
||||
|
||||
All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid `cordis.patch.yml` edits and reapply them transactionally; one-shot runs read the file once at startup.
|
||||
All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid edits of both `cordis.patch.yml` layers (profile and home) and reapply them transactionally; one-shot runs read the files once at startup.
|
||||
|
||||
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Profile 启动
|
||||
|
||||
`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.plugins` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、按 argv 顺序的各个 `--patch <path>` overlay,以及启动器 flag patch。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
|
||||
`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.plugins` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、按 argv 顺序的各个 `--patch <path>` overlay,以及启动器 flag patch。后应用的层按行胜出;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`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。
|
||||
|
||||
@@ -21,7 +21,7 @@ dsh --profile web --dump-default-config
|
||||
dsh --profile web --patch ./extra.yml --dump-config
|
||||
```
|
||||
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml` 和 `--patch` overlay。两者都会按层打印来源注释;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。
|
||||
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会按层打印来源注释;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。
|
||||
|
||||
## 插件管理
|
||||
|
||||
@@ -47,7 +47,7 @@ dsh web --dump-config
|
||||
|
||||
进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。
|
||||
|
||||
所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视有效的 `cordis.patch.yml` 编辑并以事务方式重新应用;一次性运行只在启动时读取该文件一次。
|
||||
所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性运行只在启动时读取这些文件一次。
|
||||
|
||||
新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import {
|
||||
loadOptionalPatches,
|
||||
loadOverlayPatches,
|
||||
renderConfigDump,
|
||||
type ConfigDumpLayer,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts'
|
||||
import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
@@ -36,6 +37,11 @@ export function runDumpConfig(profile: string, defaultOnly: boolean, patches: re
|
||||
if (existsSync(loaded.patchPath)) {
|
||||
layers.push({ label: loaded.patchPath, patches: loaded.patches })
|
||||
}
|
||||
const homePatchFile = homePatchPath()
|
||||
const homePatches = loadOptionalPatches(NAME, homePatchFile)
|
||||
if (homePatches !== undefined) {
|
||||
layers.push({ label: homePatchFile, patches: homePatches })
|
||||
}
|
||||
for (const file of patches) {
|
||||
const absolute = resolve(file)
|
||||
layers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) })
|
||||
|
||||
@@ -17,16 +17,29 @@ import {
|
||||
composeEntries,
|
||||
healProfilesModuleFallback,
|
||||
installFailLoud,
|
||||
loadOptionalPatches,
|
||||
loadOverlayPatches,
|
||||
loadProfile,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
watchPersonalPatches,
|
||||
type Profile,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
|
||||
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
/**
|
||||
* The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied
|
||||
* over every profile's own layer. Resolved per call, not at module load:
|
||||
* `$DSH_HOME` may be set by the test or launcher after import.
|
||||
* @returns the absolute patch-file path.
|
||||
*/
|
||||
export function homePatchPath(): string {
|
||||
return join(resolveDshHome(), PROFILE_PATCH_FILENAME)
|
||||
}
|
||||
|
||||
/** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
|
||||
export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))
|
||||
|
||||
@@ -85,12 +98,14 @@ export function prepareProfile(name: string, userLayer = true): Profile {
|
||||
/** One profile's patch layers (application order) and the row index of its pre-flag composition. */
|
||||
interface ComposedProfile {
|
||||
profile: Profile
|
||||
/** Bundle layers concatenated — the part below the user layer on a live reload. */
|
||||
/** Bundle layers concatenated — the part below the user layers on a live reload. */
|
||||
bundlePatches: PatchOptions[]
|
||||
/** Layers above the user layer on a live reload: --patch overlays, flag patches, the telemetry switch. */
|
||||
/** 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[]
|
||||
/**
|
||||
* id → row of the pre-flag composition (bundles + user layer + overlays),
|
||||
* 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).
|
||||
*/
|
||||
@@ -99,13 +114,16 @@ interface ComposedProfile {
|
||||
|
||||
/** The full patch stack of one composed profile, in application order. */
|
||||
function allPatches(composed: ComposedProfile): PatchOptions[] {
|
||||
return [...composed.bundlePatches, ...composed.profile.patches, ...composed.overlayAndFlags]
|
||||
return [...composed.bundlePatches, ...composed.profile.patches, ...composed.homePatches, ...composed.overlayAndFlags]
|
||||
}
|
||||
|
||||
/**
|
||||
* Load `name` and compose its effective patch stack: bundle layers in
|
||||
* `dsh.plugins` order, the profile's user layer, `--patch` overlays, then
|
||||
* flag patches derived from the composed rows, then the telemetry switch.
|
||||
* `dsh.plugins` order, 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.
|
||||
* @param name - the profile name.
|
||||
* @param patchFiles - `--patch` overlay paths, in argv order.
|
||||
* @param deriveFlagPatches - launcher hook turning composed rows into flag patches.
|
||||
@@ -117,16 +135,17 @@ function composeProfile(
|
||||
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 rows = new Map<string, { name?: string; config?: unknown }>()
|
||||
for (const row of composeEntries([bundlePatches, profile.patches, overlays])) {
|
||||
for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
|
||||
if (typeof row.id === 'string') rows.set(row.id, row)
|
||||
}
|
||||
const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)]
|
||||
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
|
||||
if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch)
|
||||
return { profile, bundlePatches, overlayAndFlags, rows }
|
||||
return { profile, bundlePatches, homePatches, overlayAndFlags, rows }
|
||||
}
|
||||
|
||||
/** Options for {@link runProfile}. */
|
||||
@@ -181,16 +200,20 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
})
|
||||
|
||||
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
|
||||
// Recomposition for the live profile layer: bundle layers below, overlays
|
||||
// and flag patches above, so a profile edit can never displace them.
|
||||
// Recomposition for the live user layers: bundle layers below, overlays
|
||||
// and flag patches above, so a user edit can never displace them. 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).
|
||||
// Fresh clones per generation: the include pushes `insert` rows into the
|
||||
// mounted tree BY REFERENCE and later id-targeted patches mutate those
|
||||
// objects in place. Reusing one parsed patch object across applications
|
||||
// would bake a user override into the bundle's in-memory insert row, so
|
||||
// removing the override could never revert the row to the bundle default.
|
||||
const composeLive = (profilePatches: PatchOptions[]): PatchOptions[] => structuredClone([
|
||||
const composeLive = (): PatchOptions[] => structuredClone([
|
||||
...composed.bundlePatches,
|
||||
...profilePatches,
|
||||
...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
|
||||
...loadOptionalPatches(NAME, homePatchPath()) ?? [],
|
||||
...composed.overlayAndFlags,
|
||||
])
|
||||
// One-shot runs exit through the runner; watching would only hold the
|
||||
@@ -233,6 +256,11 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
filename: composed.profile.patchPath,
|
||||
compose: composeLive,
|
||||
})
|
||||
await watchPersonalPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename: homePatchPath(),
|
||||
compose: composeLive,
|
||||
})
|
||||
}
|
||||
return { ctx, shutdown }
|
||||
}
|
||||
|
||||
@@ -200,6 +200,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
writeFileSync(profilePatch, '[]\n')
|
||||
await waitForFile(fixture.ready)
|
||||
expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
|
||||
// The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
|
||||
// and outranks the per-profile layer.
|
||||
rmSync(fixture.ready)
|
||||
writeFileSync(join(fixture.home, 'cordis.patch.yml'), [
|
||||
'- id: profile-lifecycle-fixture',
|
||||
' config:',
|
||||
' generation: home',
|
||||
'',
|
||||
].join('\n'))
|
||||
await waitForFile(fixture.ready)
|
||||
expect(readFileSync(configFile, 'utf8')).toBe('home')
|
||||
child.kill('SIGTERM')
|
||||
const result = await child
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
Reference in New Issue
Block a user