Merge pull request #6170 from deepseek-harness/turtle/rescue-profile-overwrite-2aaffdd2f31b
Make --from-default-profile replace an existing custom profile
This commit is contained in:
commit
56c5cf84a1
27 files changed
+298
-109
No files matched your search
@@ -9,8 +9,8 @@
|
||||
en: 504dd5f06475d707
|
||||
zh: 6557fd5649f48944
|
||||
/agent-note-profile-plugin-bundles-replace-the-fixed-surface-overlays/decision:
|
||||
en: 4c58d379cd59d879
|
||||
zh: a6b6322fe82d7b6a
|
||||
en: 1077d88affd90a59
|
||||
zh: 73b07bf6dcb130ba
|
||||
/agent-note-profile-plugin-bundles-replace-the-fixed-surface-overlays/alternatives-considered:
|
||||
en: d906d7a421c6b5c0
|
||||
zh: b230cd58c1b7ac79
|
||||
|
||||
@@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y
|
||||
|
||||
Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](../../archived/architecture/2026-08-06-app-owned-command-line.md).
|
||||
|
||||
The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. A new, non-shipped target can use `--from-default-profile <template>` to copy one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher claims the complete target directory exclusively, so existing state and concurrent creators fail without modification. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes a base-backed profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
|
||||
The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. A non-shipped target can use `--from-default-profile <template>` to replace itself with a copy of one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher assembles the copy in a staging directory beside the target and renames it into place, so repeated rescue runs reset the profile; a failed rename restores the previous directory. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes a base-backed profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
|
||||
|
||||
Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory, so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them. Bare plugin names in patch rows use the [immutable runtime resolution](2026-09-09-profile-resolution-generations.md), which applies installation-first and ordered-bundle rules in memory and expands dependencies from real package directories.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](../../archived/architecture/2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。
|
||||
|
||||
默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。新的非内置目标可以使用 `--from-default-profile <template>`,在启动或配置 dump 之前复制一个默认模板的 bundle 列表与 patch 重载策略。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系。launcher 会以独占方式领取完整的目标目录,因此既有状态和并发创建者都会在不作修改的情况下失败。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
|
||||
默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。非内置目标可以使用 `--from-default-profile <template>`,在启动或配置 dump 之前,用一个默认模板的 bundle 列表与 patch 重载策略的副本替换自身。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系。launcher 在目标旁的暂存目录中组装副本并重命名到目标位置,因此重复执行 rescue 会重置该 profile;重命名失败时会恢复原有目录。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
|
||||
|
||||
解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析,因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们。patch 行中的裸插件名称使用[不可变 runtime resolution](2026-09-09-profile-resolution-generations.zh.md),在内存中应用安装优先与有序 bundle 规则,并从包的真实目录展开依赖。
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
en: ebdc75fa0da18801
|
||||
zh: 5c6cd2554b8f8a98
|
||||
/deepseek-ai-dsh/entry-modes:
|
||||
en: c4811e2012a5e919
|
||||
zh: e6d20ec8238c2638
|
||||
en: a1e035a813554366
|
||||
zh: b87053214afe71b6
|
||||
/deepseek-ai-dsh/app-arguments:
|
||||
en: cc03e6b75206661b
|
||||
zh: 99e41f4143198352
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ The `dsh` command is the sole supported Node application launcher: profiles are
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dsh <name>` / `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
|
||||
| `dsh --profile <name> --from-default-profile <template>` | Create a new custom profile from a shipped template, then boot it. |
|
||||
| `dsh --profile <name> --from-default-profile <template>` | Replace a custom profile with a fresh copy of a shipped template, then boot it. |
|
||||
| `dsh --profile acp` | Serve automation clients over ACP stdio until disconnect. |
|
||||
| `dsh --profile headless "job"` | Run one fresh persisted session, print the final answer, and exit. |
|
||||
| `dsh --profile sdk` | Serve SDK clients over JSON-RPC stdio until shutdown or disconnect. |
|
||||
@@ -17,7 +17,7 @@ The `dsh` command is the sole supported Node application launcher: profiles are
|
||||
| `dsh web` | Boot the Web profile. |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
|
||||
|
||||
The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates. Create another profile at an unused, non-shipped name with `--from-default-profile`, or initialize a base-backed profile through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot and config-dump requests for it. The npm CLI also rejects its plugin-management requests; the [Desktop-installed command](../desktop/README.md#bundled-command-runtime) can manage the initialized Desktop profile using that installation’s runtime.
|
||||
The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates. Create or reset another profile at a non-shipped name with `--from-default-profile`, or initialize a base-backed profile through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot and config-dump requests for it. The npm CLI also rejects its plugin-management requests; the [Desktop-installed command](../desktop/README.md#bundled-command-runtime) can manage the initialized Desktop profile using that installation’s runtime.
|
||||
|
||||
## App arguments
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `dsh <name>` / `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
|
||||
| `dsh --profile <name> --from-default-profile <template>` | 从随附模板创建新的自定义 profile,然后启动它。 |
|
||||
| `dsh --profile <name> --from-default-profile <template>` | 用随附模板的全新副本替换自定义 profile,然后启动它。 |
|
||||
| `dsh --profile acp` | 通过 ACP stdio 为自动化客户端提供服务,直至断开连接。 |
|
||||
| `dsh --profile headless "job"` | 运行一个全新的持久化会话,打印最终答案并退出。 |
|
||||
| `dsh --profile sdk` | 通过 JSON-RPC stdio 为 SDK 客户端提供服务,直至关闭或断开连接。 |
|
||||
@@ -17,7 +17,7 @@
|
||||
| `dsh web` | 启动 Web profile。 |
|
||||
| `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
|
||||
|
||||
运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化。使用 `--from-default-profile` 可以基于这些模板之一,在尚未使用的非内置名称处创建其他 profile;通过 `dsh plugin` 则可以初始化一个以 base 为基础的 profile。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI(命令行界面)会拒绝针对它的启动和配置 dump 请求。npm CLI 也会拒绝其插件管理请求;[Desktop 内置命令](../desktop/README.zh.md#bundled-command-runtime)可以使用该安装的运行时管理已初始化的 Desktop profile。
|
||||
运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化。使用 `--from-default-profile` 可以基于这些模板之一,在非内置名称处创建或重置其他 profile;通过 `dsh plugin` 则可以初始化一个以 base 为基础的 profile。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI(命令行界面)会拒绝针对它的启动和配置 dump 请求。npm CLI 也会拒绝其插件管理请求;[Desktop 内置命令](../desktop/README.zh.md#bundled-command-runtime)可以使用该安装的运行时管理已初始化的 Desktop profile。
|
||||
|
||||
## 应用参数
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
en: 5a4f43756dae900e
|
||||
zh: d58c479481cfa5fe
|
||||
/dsh-cli-behavior-reference/profile-boot:
|
||||
en: e3a159750f5f9ed3
|
||||
zh: e02d9004fcc239f9
|
||||
en: b474a6fc3c98837d
|
||||
zh: da9219f91177b6c8
|
||||
/dsh-cli-behavior-reference/profile-boot/app-arguments:
|
||||
en: 3d2f4cf6a0afc3cd
|
||||
zh: 9fe7e8f6a4acb596
|
||||
en: 60b4e6837299427e
|
||||
zh: 88eac566a2e63881
|
||||
/dsh-cli-behavior-reference/profile-boot/config-schema-dump:
|
||||
en: a356f8a68f3b3685
|
||||
zh: c718713b5d709295
|
||||
|
||||
@@ -14,9 +14,9 @@ Bundle names resolve from the dsh installation first, then from the profile dire
|
||||
|
||||
The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loudly with a hint to run `dsh plugin --profile <name> add <package>`.
|
||||
|
||||
`dsh --profile <name> --from-default-profile <template>` initializes a new custom target from one of those five shipped templates before boot. The target name cannot be a shipped profile name, and its complete profile directory must not exist. The launcher claims that directory exclusively, so residual files and another concurrent creator are rejected without modification. It copies the template's current bundle list into a new manifest with empty dependencies and an empty user patch. It does not read the local profile named by `<template>`, copy its dependencies or patch, or persist an inheritance field; later template-list changes do not rewrite the new profile. The in-box bundles named by that copied list still resolve from the current dsh installation. A successful initialization adds no launcher output.
|
||||
`dsh --profile <name> --from-default-profile <template>` replaces the custom target with a fresh copy of one of those five shipped templates before boot. The target name cannot be a shipped profile name in any letter case. The launcher assembles the new profile in a staging directory beside the target and moves it into place, then deletes any previous target directory, including its installed dependencies and user patch. A failed move restores the previous directory. If restoring also fails, or the process stops between the two renames, the previous directory remains at `$DSH_HOME/profiles/.<name>.staging-*/previous` for manual recovery. When several processes replace the same target concurrently, at least one succeeds and the target holds one complete copy. The copy holds the template's current bundle list in a new manifest with empty dependencies and an empty user patch. It does not read the local profile named by `<template>`, copy its dependencies or patch, or persist an inheritance field; later template-list changes do not rewrite the new profile. The in-box bundles named by that copied list still resolve from the current dsh installation. A successful replacement adds no launcher output.
|
||||
|
||||
An existing profile rejects `--from-default-profile` without changing or booting it; omit the option to use it. A residual target directory is also preserved and requires a different profile name. An unknown template or a shipped target name fails before creating the target. Unknown-template diagnostics name the valid templates. Initialization is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk and the retry omits the creation option. All three config-dump modes accept the option and initialize the target without booting it.
|
||||
Omit the option to boot the profile without resetting it. An unknown template or a shipped target name fails before the target changes. Unknown-template diagnostics name the valid templates. Replacement is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk. All three config-dump modes accept the option and replace the target without booting it.
|
||||
|
||||
```sh
|
||||
dsh rescue --from-default-profile web
|
||||
@@ -25,7 +25,7 @@ dsh rescue
|
||||
|
||||
### App arguments
|
||||
|
||||
The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh rescue --from-default-profile web --no-open` therefore initializes before handing `--no-open` to Web, `dsh --profile web --port 8080` reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
|
||||
The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh rescue --from-default-profile web --no-open` therefore resets rescue before handing `--no-open` to Web, `dsh --profile web --port 8080` reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
|
||||
|
||||
A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. With HMR enabled, a patch-file edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
|
||||
`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
|
||||
|
||||
`dsh --profile <name> --from-default-profile <template>` 会在启动前,从上述五个随附模板之一初始化新的自定义目标。目标名称不能是随附 profile 名称,并且完整的目标 profile 目录必须不存在。launcher 会以独占方式领取该目录,因此残留文件和另一个并发创建者都会在不作修改的情况下被拒绝。它把模板当前的组合包列表复制进一份依赖为空、用户 patch 为空的新 manifest。它不会读取 `<template>` 指定的本地同名 profile,不会复制其依赖或 patch,也不会持久化继承字段;模板列表之后的变化不会改写新 profile。复制列表中指名的内置组合包仍从当前 dsh 安装目录解析。初始化成功不会增加 launcher 输出。
|
||||
`dsh --profile <name> --from-default-profile <template>` 会在启动前,用上述五个随附模板之一的全新副本替换自定义目标。目标名称不能是任何大小写形式的随附 profile 名称。launcher 先在目标旁边的暂存目录中组装新 profile 并移动到目标位置,再删除原有的目标目录,包括其中已安装的依赖和用户 patch。移动失败时会恢复原有目录。如果恢复也失败,或进程在两次重命名之间停止,原有目录会保留在 `$DSH_HOME/profiles/.<name>.staging-*/previous`,供手动恢复。多个进程并发替换同一目标时,至少有一个会成功,且目标中只保留一份完整副本。该副本把模板当前的组合包列表写进一份依赖为空、用户 patch 为空的新 manifest。它不会读取 `<template>` 指定的本地同名 profile,不会复制其依赖或 patch,也不会持久化继承字段;模板列表之后的变化不会改写新 profile。复制列表中指名的内置组合包仍从当前 dsh 安装目录解析。替换成功不会增加 launcher 输出。
|
||||
|
||||
profile 已经存在时,`--from-default-profile` 会被拒绝,且不会修改或启动它;去掉该选项即可使用它。残留的目标目录同样会被原样保留,此时必须改用另一个 profile 名称。未知模板或随附目标名称会在创建目标之前失败;未知模板的诊断会列出有效模板。初始化在组合包解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上,重试时需要去掉创建选项。三种配置 dump 模式都接受该选项,并在不启动应用的情况下初始化目标。
|
||||
去掉该选项即可在不重置的情况下启动该 profile。未知模板或随附目标名称会在目标被改动之前失败;未知模板的诊断会列出有效模板。替换在组合包解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上。三种配置 dump 模式都接受该选项,并在不启动应用的情况下替换目标。
|
||||
|
||||
```sh
|
||||
dsh rescue --from-default-profile web
|
||||
@@ -27,7 +27,7 @@ dsh rescue
|
||||
|
||||
### 应用参数
|
||||
|
||||
启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh rescue --from-default-profile web --no-open` 会先初始化,再把 `--no-open` 交给 Web;`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。
|
||||
启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh rescue --from-default-profile web --no-open` 会先重置 rescue,再把 `--no-open` 交给 Web;`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。
|
||||
|
||||
每套组合只会挂载一次。普通插件注入 `cmdlineArgs`,解析所属应用的参数,并将解析结果作为服务提供。每个从 flag 取值的配置行都会注入该服务;Loader 会等到服务激活后,再对该行的配置求值(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 的优先级高于配置行中写明的值。要维持这一优先级,配置行必须保留该表达式;如果用户 patch 用字面量替换整个 `config`,也会随之移除运行时读取。帮助参数和被拒绝的参数都会请求退出:参数被拒绝时以非零状态退出,显示帮助时以 0 退出;依赖该提供方服务的配置行不会激活。启用 HMR 时,编辑 patch 文件会根据仍在运行的服务重新计算表达式,因此不会重置当前正在使用的端口。
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Command, CommanderError, InvalidArgumentError } from 'commander'
|
||||
interface ProfileInvocation {
|
||||
mode: 'profile'
|
||||
profile: string
|
||||
/** Shipped template used once to initialize a missing profile. */
|
||||
/** Shipped template that replaces the named profile. */
|
||||
fromDefaultProfile?: string | undefined
|
||||
/** Extra patch-list overlays applied after the profile's own layer, in argv order. */
|
||||
patches: string[]
|
||||
@@ -33,7 +33,7 @@ interface ProfileInvocation {
|
||||
interface DumpConfigInvocation {
|
||||
mode: 'dump-config'
|
||||
profile: string
|
||||
/** Shipped template used once to initialize a missing profile. */
|
||||
/** Shipped template that replaces the named profile. */
|
||||
fromDefaultProfile?: string | undefined
|
||||
/** Omit the profile's user layer and --patch overlays; print bundle layers only. */
|
||||
defaultOnly: boolean
|
||||
@@ -44,7 +44,7 @@ interface DumpConfigInvocation {
|
||||
interface DumpConfigSchemaInvocation {
|
||||
mode: 'dump-config-schema'
|
||||
profile: string
|
||||
/** Shipped template used once to initialize a missing profile. */
|
||||
/** Shipped template that replaces the named profile. */
|
||||
fromDefaultProfile?: string | undefined
|
||||
patches: string[]
|
||||
}
|
||||
@@ -91,7 +91,7 @@ const HELP_EXAMPLES = `
|
||||
Examples:
|
||||
dsh web boot the web profile (same as: dsh --profile web)
|
||||
dsh rescue --from-default-profile web
|
||||
create rescue from the shipped web template, then boot it
|
||||
recreate rescue from the shipped web template, then boot it
|
||||
dsh headless "run the tests" answer one task, print the result, and exit
|
||||
dsh tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh tui --resume <session> arguments after the launcher flags reach the app
|
||||
@@ -166,7 +166,7 @@ export function parseDshArgs(argv: readonly string[], version: string, manageDes
|
||||
.enablePositionalOptions()
|
||||
.argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile <name> --help)')
|
||||
.option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot', selectProfile)
|
||||
.option('--from-default-profile <name>', 'initialize a new custom profile from a shipped profile template')
|
||||
.option('--from-default-profile <name>', 'replace a custom profile with a fresh copy of a shipped profile template')
|
||||
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
|
||||
.option('--dump-config', 'print the composed profile tree and exit')
|
||||
.option('--dump-config-schema', 'print JSON Schema for profile entries and patches without mounting')
|
||||
|
||||
@@ -19,7 +19,7 @@ const NAME = 'dsh'
|
||||
* During schema generation, ordinary stdout writes are redirected to stderr.
|
||||
* @param profile - the profile name.
|
||||
* @param patches - overlay paths, in argv order.
|
||||
* @param fromDefaultProfile - shipped template used once to initialize a missing profile.
|
||||
* @param fromDefaultProfile - shipped template that replaces the profile before loading it.
|
||||
* @returns after collection and output, with diagnostics also written to stderr.
|
||||
*/
|
||||
export async function runDumpConfigSchema(
|
||||
|
||||
@@ -27,7 +27,7 @@ const NAME = 'dsh'
|
||||
* (the recovery diagnostic for a broken `cordis.patch.yml`, which is then
|
||||
* never parsed).
|
||||
* @param patches - `--patch` overlay paths, in argv order.
|
||||
* @param fromDefaultProfile - shipped template used once to initialize a missing profile.
|
||||
* @param fromDefaultProfile - shipped template that replaces the profile before loading it.
|
||||
*/
|
||||
export function runDumpConfig(
|
||||
profile: string,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* @module @deepseek-ai/dsh/profile-boot
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { FiberState, type Context } from '@deepseek-ai/cordis'
|
||||
@@ -88,15 +88,22 @@ const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tre
|
||||
export const PROFILE_ROOT_FILENAME = 'cordis.yml'
|
||||
|
||||
/**
|
||||
* Initialize a missing profile from one shipped template. This copies only
|
||||
* the template's bundle list; local state from the
|
||||
* Replace the named custom profile with a fresh copy of one shipped template.
|
||||
* This copies only the template's bundle list; local state from the
|
||||
* same-named shipped profile is not read, and no inheritance metadata is
|
||||
* persisted. Shipped profile names are reserved, and the target directory is
|
||||
* claimed exclusively so existing or concurrent state is never reused.
|
||||
* @param name - the new profile name.
|
||||
* persisted. The new profile is assembled in a `.<name>.staging-*` directory
|
||||
* under the profiles directory and moved into place; only then is any previous
|
||||
* target directory, including its dependencies and user patch, deleted. A
|
||||
* failed move restores the previous directory; when restoring also fails, it
|
||||
* stays in the staging directory. Shipped profile names, compared without
|
||||
* case, are reserved. Concurrent replacements leave one complete copy; each
|
||||
* one either succeeds or reports the concurrent recreation.
|
||||
* @param name - the profile name to create or replace.
|
||||
* @param fromDefaultProfile - shipped profile template to copy.
|
||||
* @param home - Harness home containing the profile directory.
|
||||
* @throws when the template is unknown, the target name is shipped, or the target directory exists.
|
||||
* @throws when the template is unknown, the target name is shipped, another process recreates the target
|
||||
* between the two renames, or a file-system operation fails; a restore failure is an `AggregateError`
|
||||
* that names the staging path holding the previous profile.
|
||||
*/
|
||||
export function initializeProfileFromDefault(
|
||||
name: string,
|
||||
@@ -113,42 +120,62 @@ export function initializeProfileFromDefault(
|
||||
`${NAME}: unknown default profile ${JSON.stringify(fromDefaultProfile)}; expected one of ${expected}`,
|
||||
)
|
||||
}
|
||||
if (Object.hasOwn(PROFILE_TEMPLATES, name)) {
|
||||
// Case-insensitive file systems resolve a case variant such as `Web` to the shipped directory.
|
||||
const shipped = Object.keys(PROFILE_TEMPLATES).find(shippedName => shippedName.toLowerCase() === name.toLowerCase())
|
||||
if (shipped !== undefined) {
|
||||
throw new Error(
|
||||
`${NAME}: profile ${JSON.stringify(name)} is shipped and cannot be a custom profile target; `
|
||||
`${NAME}: profile ${JSON.stringify(name)} names shipped profile ${JSON.stringify(shipped)} and cannot be a custom profile target; `
|
||||
+ 'omit --from-default-profile to use it',
|
||||
)
|
||||
}
|
||||
mkdirSync(dirname(dir), { recursive: true })
|
||||
// The staging directory sits beside the target so both renames stay on one filesystem.
|
||||
const staging = mkdtempSync(join(dirname(dir), `.${name}.staging-`))
|
||||
const previous = join(staging, 'previous')
|
||||
try {
|
||||
mkdirSync(dir)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
||||
const manifestPath = join(dir, 'package.json')
|
||||
if (existsSync(manifestPath)) {
|
||||
throw new Error(
|
||||
`${NAME}: profile ${JSON.stringify(name)} already exists at ${manifestPath}; `
|
||||
+ 'omit --from-default-profile to use it',
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`${NAME}: profile directory ${dir} already exists; choose an unused profile name`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
initProfile(dir, template.bundles)
|
||||
} catch (error) {
|
||||
const fresh = join(staging, 'new', name)
|
||||
initProfile(fresh, template.bundles)
|
||||
let moved = false
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, cleanupError],
|
||||
`${NAME}: profile initialization failed and ${dir} could not be removed`,
|
||||
)
|
||||
renameSync(dir, previous)
|
||||
moved = true
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
try {
|
||||
renameSync(fresh, dir)
|
||||
} catch (error) {
|
||||
// Windows reports EPERM instead of EEXIST or ENOTEMPTY, so the target's state identifies a concurrent winner.
|
||||
if (existsSync(dir)) {
|
||||
// The concurrent replacement supersedes the previous profile.
|
||||
rmSync(previous, { recursive: true, force: true })
|
||||
throw new Error(`${NAME}: profile ${JSON.stringify(name)} was recreated concurrently at ${dir}`, { cause: error })
|
||||
}
|
||||
if (moved) {
|
||||
try {
|
||||
renameSync(previous, dir)
|
||||
} catch (restoreError) {
|
||||
throw new AggregateError(
|
||||
[error, restoreError],
|
||||
`${NAME}: profile replacement failed and ${dir} could not be restored from ${previous}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
// A previous profile that could not be restored stays in staging at the reported path.
|
||||
if (!existsSync(previous)) rmSync(staging, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
try {
|
||||
rmSync(staging, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
// The replacement is complete; leftover files only occupy space.
|
||||
process.stderr.write(`${NAME}: replaced profile ${JSON.stringify(name)} but could not remove ${staging}: ${String(error)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a resolved profile for `name` and (re)write the empty root config. The
|
||||
* root is always rewritten: the whole composition is patch layers, and the
|
||||
@@ -160,9 +187,9 @@ export function initializeProfileFromDefault(
|
||||
* the identical base).
|
||||
* @param name - the profile name.
|
||||
* @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
|
||||
* @param fromDefaultProfile - shipped template used once to initialize a missing profile.
|
||||
* @param fromDefaultProfile - shipped template that replaces the profile before loading it.
|
||||
* @returns the loaded profile.
|
||||
* @throws when explicit initialization names an unknown template or an existing profile.
|
||||
* @throws when explicit initialization names an unknown template or a shipped target.
|
||||
*/
|
||||
export function prepareProfile(name: string, userLayer = true, fromDefaultProfile?: string): Profile {
|
||||
if (fromDefaultProfile !== undefined) initializeProfileFromDefault(name, fromDefaultProfile)
|
||||
@@ -190,7 +217,7 @@ interface ComposedProfile {
|
||||
* then the telemetry switch.
|
||||
* @param name - the profile name.
|
||||
* @param patchFiles - `--patch` overlay paths, in argv order.
|
||||
* @param fromDefaultProfile - shipped template for a missing named profile.
|
||||
* @param fromDefaultProfile - shipped template that replaces the named profile.
|
||||
* @param resolvedProfile - application-owned profile and installation.
|
||||
* @returns the profile and its patch layers.
|
||||
*/
|
||||
@@ -224,7 +251,7 @@ export interface RunProfileOptions {
|
||||
profile: string
|
||||
/** Loaded application profile; bypasses named profile initialization when supplied. */
|
||||
resolvedProfile?: ResolvedProfileRuntime | undefined
|
||||
/** Shipped template used once to initialize a missing profile. */
|
||||
/** Shipped template that replaces the named profile before boot. */
|
||||
fromDefaultProfile?: string | undefined
|
||||
/** `--patch` overlay paths, in argv order. */
|
||||
patchFiles: readonly string[]
|
||||
|
||||
@@ -666,7 +666,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('creates a custom profile from the shipped web template before booting it', async () => {
|
||||
it('creates and then replaces a custom profile from the shipped web template before booting it', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-from-default-profile-'))
|
||||
try {
|
||||
const created = await runBuiltBin(
|
||||
@@ -689,14 +689,14 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('[]')
|
||||
expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
|
||||
|
||||
writeFileSync(join(dir, 'cordis.patch.yml'), '- id: local-change\n disabled: true\n')
|
||||
const repeated = await runBuiltBin(
|
||||
['rescue', '--from-default-profile', 'web', '--help'],
|
||||
{ DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
|
||||
)
|
||||
expect(repeated.code).toBe(1)
|
||||
expect(repeated.stdout).toBe('')
|
||||
expect(repeated.stderr).toContain('profile "rescue" already exists')
|
||||
expect(repeated.stderr).toContain('omit --from-default-profile to use it')
|
||||
expect(repeated.code).toBe(0)
|
||||
expect(repeated.stderr).toBe('')
|
||||
expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).not.toContain('local-change')
|
||||
|
||||
const reopened = await runBuiltBin(
|
||||
['rescue', '--help'],
|
||||
|
||||
@@ -11,8 +11,8 @@ Arguments:
|
||||
Options:
|
||||
-V, --version output the version number
|
||||
--profile <name> the profile under $DSH_HOME/profiles to boot
|
||||
--from-default-profile <name> initialize a new custom profile from a shipped
|
||||
profile template
|
||||
--from-default-profile <name> replace a custom profile with a fresh copy of a
|
||||
shipped profile template
|
||||
--patch <path> extra patch-list overlay applied after the
|
||||
profile layer (repeatable)
|
||||
--dump-config print the composed profile tree and exit
|
||||
@@ -24,7 +24,7 @@ Options:
|
||||
Examples:
|
||||
dsh web boot the web profile (same as: dsh --profile web)
|
||||
dsh rescue --from-default-profile web
|
||||
create rescue from the shipped web template, then boot it
|
||||
recreate rescue from the shipped web template, then boot it
|
||||
dsh headless "run the tests" answer one task, print the result, and exit
|
||||
dsh tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh tui --resume <session> arguments after the launcher flags reach the app
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** One-time custom-profile initialization from shipped templates. */
|
||||
/** Custom-profile replacement from shipped templates. */
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
initProfile,
|
||||
@@ -12,10 +12,32 @@ import {
|
||||
resolveProfileDir,
|
||||
writeProfileManifest,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { execa } from 'execa'
|
||||
import { initializeProfileFromDefault } from '../src/profile-boot.ts'
|
||||
|
||||
/** Replaces one `renameSync` call when it returns true; the real rename runs otherwise. */
|
||||
const renameHook = vi.hoisted(() => ({ current: undefined as ((from: string, to: string) => boolean) | undefined }))
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const fs = await importOriginal<typeof import('node:fs')>()
|
||||
return {
|
||||
...fs,
|
||||
renameSync(from: string, to: string): void {
|
||||
if (renameHook.current?.(from, to) !== true) fs.renameSync(from, to)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
renameHook.current = undefined
|
||||
})
|
||||
|
||||
/** A rename failure whose code does not identify a concurrent target. */
|
||||
function renameFailure(): NodeJS.ErrnoException {
|
||||
return Object.assign(new Error('rename failed'), { code: 'EIO' })
|
||||
}
|
||||
|
||||
const childEntry = fileURLToPath(new URL('./fixtures/initialize-profile-from-default.ts', import.meta.url))
|
||||
const tsxLoader = import.meta.resolve('tsx/esm')
|
||||
const CHILD_TIMEOUT_MS = 30_000
|
||||
@@ -80,36 +102,116 @@ describe('initializeProfileFromDefault', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an existing target without changing its files', () => {
|
||||
it('replaces an existing target with a fresh template copy', () => {
|
||||
withHome((home) => {
|
||||
const dir = resolveProfileDir('rescue', home)
|
||||
initProfile(dir, ['existing-bundle'])
|
||||
const manifest = readProfileManifest('test', dir)
|
||||
manifest.dependencies = { 'existing-bundle': '1.0.0' }
|
||||
writeProfileManifest(dir, manifest)
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: existing\n disabled: true\n')
|
||||
const paths = ['package.json', PROFILE_PATCH_FILENAME, 'pnpm-workspace.yaml'].map(file => join(dir, file))
|
||||
const before = paths.map(path => readFileSync(path))
|
||||
mkdirSync(join(dir, 'node_modules', 'existing-bundle'), { recursive: true })
|
||||
|
||||
expect(() => {
|
||||
initializeProfileFromDefault('rescue', 'web', home)
|
||||
initializeProfileFromDefault('rescue', 'web', home)
|
||||
|
||||
expect(readProfileManifest('test', dir)).toEqual({
|
||||
name: 'dsh-profile-rescue',
|
||||
private: true,
|
||||
dependencies: {},
|
||||
dsh: { profile: { bundles: [...PROFILE_TEMPLATES.web!.bundles] } },
|
||||
})
|
||||
.toThrow('profile "rescue" already exists')
|
||||
expect(paths.map(path => readFileSync(path))).toEqual(before)
|
||||
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).not.toContain('existing')
|
||||
expect(existsSync(join(dir, 'node_modules'))).toBe(false)
|
||||
expect(readdirSync(dirname(dir))).toEqual(['rescue'])
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a residual target directory without changing its contents', () => {
|
||||
it('replaces a residual target directory', () => {
|
||||
withHome((home) => {
|
||||
const dir = resolveProfileDir('rescue', home)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const residual = join(dir, PROFILE_PATCH_FILENAME)
|
||||
writeFileSync(residual, '- id: residual\n disabled: true\n')
|
||||
const before = readFileSync(residual)
|
||||
writeFileSync(join(dir, 'residual'), '')
|
||||
|
||||
initializeProfileFromDefault('rescue', 'web', home)
|
||||
|
||||
expect(existsSync(join(dir, 'residual'))).toBe(false)
|
||||
expect(readProfileManifest('test', dir).dsh?.profile).toEqual(PROFILE_TEMPLATES.web)
|
||||
})
|
||||
})
|
||||
|
||||
it('restores the previous target when the fresh copy cannot be moved into place', () => {
|
||||
withHome((home) => {
|
||||
const dir = resolveProfileDir('rescue', home)
|
||||
initProfile(dir, ['existing-bundle'])
|
||||
const before = readFileSync(join(dir, 'package.json'))
|
||||
renameHook.current = (from, to) => {
|
||||
if (to === dir && from.endsWith(join('new', 'rescue'))) throw renameFailure()
|
||||
return false
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
initializeProfileFromDefault('rescue', 'web', home)
|
||||
})
|
||||
.toThrow('profile directory')
|
||||
expect(readFileSync(residual)).toEqual(before)
|
||||
expect(existsSync(join(dir, 'package.json'))).toBe(false)
|
||||
}).toThrow('rename failed')
|
||||
expect(readFileSync(join(dir, 'package.json'))).toEqual(before)
|
||||
expect(readdirSync(dirname(dir))).toEqual(['rescue'])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the previous target in staging when restoring it also fails', () => {
|
||||
withHome((home) => {
|
||||
const dir = resolveProfileDir('rescue', home)
|
||||
initProfile(dir, ['existing-bundle'])
|
||||
const before = readFileSync(join(dir, 'package.json'))
|
||||
renameHook.current = (_from, to) => {
|
||||
if (to === dir) throw renameFailure()
|
||||
return false
|
||||
}
|
||||
|
||||
let failure: unknown
|
||||
try {
|
||||
initializeProfileFromDefault('rescue', 'web', home)
|
||||
} catch (error) {
|
||||
failure = error
|
||||
}
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
const [staging] = readdirSync(dirname(dir))
|
||||
expect(staging).toMatch(/^\.rescue\.staging-/)
|
||||
const previous = join(dirname(dir), staging!, 'previous')
|
||||
expect((failure as AggregateError).message).toContain(previous)
|
||||
expect(readFileSync(join(previous, 'package.json'))).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a target recreated between the two renames and keeps that copy', () => {
|
||||
withHome((home) => {
|
||||
const dir = resolveProfileDir('rescue', home)
|
||||
initProfile(dir, ['existing-bundle'])
|
||||
renameHook.current = (_from, to) => {
|
||||
if (to !== dir) return false
|
||||
renameHook.current = undefined
|
||||
initProfile(dir, ['concurrent-bundle'])
|
||||
// The real rename now fails with the platform's code for an occupied target.
|
||||
return false
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
initializeProfileFromDefault('rescue', 'web', home)
|
||||
}).toThrow('profile "rescue" was recreated concurrently')
|
||||
expect(readProfileManifest('test', dir).dsh?.profile).toEqual({ bundles: ['concurrent-bundle'] })
|
||||
expect(readdirSync(dirname(dir))).toEqual(['rescue'])
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a case variant of a shipped profile without changing it', () => {
|
||||
withHome((home) => {
|
||||
const dir = resolveProfileDir('web', home)
|
||||
initProfile(dir, ['existing-bundle'])
|
||||
const before = readFileSync(join(dir, 'package.json'))
|
||||
|
||||
expect(() => {
|
||||
initializeProfileFromDefault('Web', 'headless', home)
|
||||
}).toThrow('profile "Web" names shipped profile "web"')
|
||||
expect(readFileSync(join(dir, 'package.json'))).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,7 +220,7 @@ describe('initializeProfileFromDefault', () => {
|
||||
expect(() => {
|
||||
initializeProfileFromDefault(name, 'web', home)
|
||||
})
|
||||
.toThrow(`profile ${JSON.stringify(name)} is shipped`)
|
||||
.toThrow(`names shipped profile ${JSON.stringify(name)}`)
|
||||
expect(existsSync(resolveProfileDir(name, home))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -133,7 +235,7 @@ describe('initializeProfileFromDefault', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('allows only one of two synchronized processes to create the target', async () => {
|
||||
it('leaves one complete template copy after two synchronized processes replace the target', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-profile-from-default-race-'))
|
||||
const gate = join(home, 'start')
|
||||
const ready = [join(home, 'ready-1'), join(home, 'ready-2')]
|
||||
@@ -146,9 +248,13 @@ describe('initializeProfileFromDefault', () => {
|
||||
await Promise.all(ready.map(waitForFile))
|
||||
writeFileSync(gate, '')
|
||||
const results = await Promise.all(children)
|
||||
expect(results.map(result => result.exitCode).sort()).toEqual([0, 1])
|
||||
expect(readProfileManifest('test', resolveProfileDir('rescue', home)).dsh?.profile)
|
||||
.toEqual(PROFILE_TEMPLATES.web)
|
||||
expect(results.map(result => result.exitCode)).toContain(0)
|
||||
for (const result of results.filter(result => result.exitCode !== 0)) {
|
||||
expect(result.stderr).toContain('was recreated concurrently')
|
||||
}
|
||||
const dir = resolveProfileDir('rescue', home)
|
||||
expect(readProfileManifest('test', dir).dsh?.profile).toEqual(PROFILE_TEMPLATES.web)
|
||||
expect(readdirSync(dirname(dir))).toEqual(['rescue'])
|
||||
} finally {
|
||||
for (const child of children) child.kill('SIGKILL')
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
|
||||
+3
-3
@@ -36,8 +36,8 @@ Arguments:
|
||||
Options:
|
||||
-V, --version output the version number
|
||||
--profile <name> the profile under $DSH_HOME/profiles to boot
|
||||
--from-default-profile <name> initialize a new custom profile from a shipped
|
||||
profile template
|
||||
--from-default-profile <name> replace a custom profile with a fresh copy of a
|
||||
shipped profile template
|
||||
--patch <path> extra patch-list overlay applied after the
|
||||
profile layer (repeatable)
|
||||
--dump-config print the composed profile tree and exit
|
||||
@@ -49,7 +49,7 @@ Options:
|
||||
Examples:
|
||||
dsh web boot the web profile (same as: dsh --profile web)
|
||||
dsh rescue --from-default-profile web
|
||||
create rescue from the shipped web template, then boot it
|
||||
recreate rescue from the shipped web template, then boot it
|
||||
dsh headless "run the tests" answer one task, print the result, and exit
|
||||
dsh tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh tui --resume <session> arguments after the launcher flags reach the app
|
||||
|
||||
+3
-3
@@ -38,8 +38,8 @@ Arguments:
|
||||
Options:
|
||||
-V, --version output the version number
|
||||
--profile <name> the profile under $DSH_HOME/profiles to boot
|
||||
--from-default-profile <name> initialize a new custom profile from a shipped
|
||||
profile template
|
||||
--from-default-profile <name> replace a custom profile with a fresh copy of a
|
||||
shipped profile template
|
||||
--patch <path> extra patch-list overlay applied after the
|
||||
profile layer (repeatable)
|
||||
--dump-config print the composed profile tree and exit
|
||||
@@ -51,7 +51,7 @@ Options:
|
||||
Examples:
|
||||
dsh web boot the web profile (same as: dsh --profile web)
|
||||
dsh rescue --from-default-profile web
|
||||
create rescue from the shipped web template, then boot it
|
||||
recreate rescue from the shipped web template, then boot it
|
||||
dsh headless "run the tests" answer one task, print the result, and exit
|
||||
dsh tui --patch ./extra.yml boot a custom profile with one extra overlay
|
||||
dsh tui --resume <session> arguments after the launcher flags reach the app
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/from-default-profile-replaces-target/guide.md
|
||||
/:
|
||||
en: 17654c5360fcf3d1
|
||||
zh: 03277764a031a4e7
|
||||
/from-default-profile-replaces-an-existing-profile:
|
||||
en: 8290bb3fe4ba89bc
|
||||
zh: b653e97a607c8897
|
||||
/from-default-profile-replaces-an-existing-profile/change:
|
||||
en: 0a8e90115d24ce6d
|
||||
zh: fdc51cffdaf85d53
|
||||
/from-default-profile-replaces-an-existing-profile/migration:
|
||||
en: 34ffdb02d05f4489
|
||||
zh: 51663f79fe7f94bc
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "`--from-default-profile` now replaces an existing custom profile instead of refusing it."
|
||||
---
|
||||
|
||||
# `--from-default-profile` replaces an existing profile
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`dsh --profile <name> --from-default-profile <template>` used to fail with `profile "<name>" already exists` when the target profile existed, and refused a residual target directory. It now replaces the target directory with a fresh copy of the template every time, then boots it or, with a config-dump flag, prints the composition. The previous directory is deleted, including its `package.json` dependencies, installed `node_modules`, and `cordis.patch.yml`. Shipped profile names, in any letter case, are still rejected as targets.
|
||||
|
||||
This affects anyone who repeats the command, such as `dsh rescue --from-default-profile web`, in scripts or shortcuts and expects later runs to keep the profile's plugins or patch.
|
||||
|
||||
## Migration
|
||||
|
||||
1. To boot an existing profile without resetting it, drop the option: run `dsh rescue` instead of `dsh rescue --from-default-profile web`.
|
||||
2. To keep local changes across a reset, copy `$DSH_HOME/profiles/<name>/cordis.patch.yml` before running the command, then reinstall the profile's plugins with `dsh plugin --profile <name> add <package>` and restore the patch.
|
||||
3. Confirm the migration: after `dsh rescue --from-default-profile web --help`, `$DSH_HOME/profiles/rescue/package.json` lists no dependencies.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "`--from-default-profile` 现在会替换已存在的自定义 profile,而不是拒绝执行。"
|
||||
---
|
||||
|
||||
# `--from-default-profile` 会替换已存在的 profile
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
此前目标 profile 已存在时,`dsh --profile <name> --from-default-profile <template>` 会以 `profile "<name>" already exists` 失败,残留的目标目录也会被拒绝。现在每次执行都会用模板的全新副本替换目标目录,然后启动它;带配置 dump flag 时则打印组合结果。原有目录会被删除,包括 `package.json` 中的依赖、已安装的 `node_modules` 和 `cordis.patch.yml`。任何大小写形式的随附 profile 名称仍不能作为目标。
|
||||
|
||||
受影响的是在脚本或快捷方式中重复执行该命令(例如 `dsh rescue --from-default-profile web`),并期望后续执行保留该 profile 插件或 patch 的用户。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 要在不重置的情况下启动已存在的 profile,去掉该选项:运行 `dsh rescue`,而不是 `dsh rescue --from-default-profile web`。
|
||||
2. 要在重置后保留本地修改,先复制 `$DSH_HOME/profiles/<name>/cordis.patch.yml`,执行命令后用 `dsh plugin --profile <name> add <package>` 重新安装该 profile 的插件,再恢复该 patch。
|
||||
3. 确认迁移结果:执行 `dsh rescue --from-default-profile web --help` 后,`$DSH_HOME/profiles/rescue/package.json` 中没有任何依赖。
|
||||
@@ -12,8 +12,8 @@
|
||||
en: 1c59ba70e2d643ea
|
||||
zh: 403667f567bcc77c
|
||||
/package-and-install-a-plugin/two-concepts-two-manifests/the-profile-manifest:
|
||||
en: e2bd00d8de99da67
|
||||
zh: e70b17787bdb53a5
|
||||
en: 00c4218e1af87e9d
|
||||
zh: bd49e41ae376606c
|
||||
/package-and-install-a-plugin/install-into-a-profile:
|
||||
en: 78baf6471bd6e82b
|
||||
zh: 0453a7d35fd57399
|
||||
|
||||
@@ -70,7 +70,7 @@ A profile directory holds two files:
|
||||
- `package.json` — the profile's out-of-tree plugin dependencies (managed by pnpm) plus the `dsh.profile` manifest with its ordered `bundles` list.
|
||||
- `cordis.patch.yml` — the user's own patch layer, applied after every bundle layer.
|
||||
|
||||
You never write a profile manifest by hand: `dsh --profile <name> --from-default-profile <template>` can create one from a shipped application template, while `dsh plugin` creates a base-backed profile and maintains its installed bundle list. The [CLI behavior reference](../../../../apps/cli/reference/README.md#profile-boot) owns the creation rules; the next section shows the plugin path.
|
||||
You never write a profile manifest by hand: `dsh --profile <name> --from-default-profile <template>` can create or reset one from a shipped application template, while `dsh plugin` creates a base-backed profile and maintains its installed bundle list. The [CLI behavior reference](../../../../apps/cli/reference/README.md#profile-boot) owns the creation rules; the next section shows the plugin path.
|
||||
|
||||
## Install into a profile
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ profile 目录包含两个文件:
|
||||
- `package.json` — profile 的树外插件依赖(由 pnpm 管理),加上 `dsh.profile` manifest 及其有序的 `bundles` 列表。
|
||||
- `cordis.patch.yml` — 用户自己的 patch 层,在每个组合包层之后应用。
|
||||
|
||||
profile manifest 从不需要手写:`dsh --profile <name> --from-default-profile <template>` 可以从随附应用模板创建 profile,`dsh plugin` 则创建一个以 base 为基础的 profile,并维护其中已安装的 bundle 列表。创建规则以 [CLI(命令行界面)行为参考](../../../../apps/cli/reference/README.zh.md#profile-boot)为准;下一节展示插件路径。
|
||||
profile manifest 从不需要手写:`dsh --profile <name> --from-default-profile <template>` 可以从随附应用模板创建或重置 profile,`dsh plugin` 则创建一个以 base 为基础的 profile,并维护其中已安装的 bundle 列表。创建规则以 [CLI(命令行界面)行为参考](../../../../apps/cli/reference/README.zh.md#profile-boot)为准;下一节展示插件路径。
|
||||
|
||||
## 安装进 profile
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 4a424cd401556f20
|
||||
zh: 0b34dd18e0db6cea
|
||||
/deepseek-ai-dsh-app-boot/use-this-package/profiles:
|
||||
en: d50b0fdaf62353dd
|
||||
zh: 0fccb6968602e5de
|
||||
en: 8cc455f3711b5ab3
|
||||
zh: e3f1847bc3fcd2cf
|
||||
/deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration:
|
||||
en: 646a0ccdd7881ad1
|
||||
zh: e3696acb1573dd7b
|
||||
|
||||
@@ -47,7 +47,7 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
|
||||
|
||||
Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data.
|
||||
|
||||
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures skip that bundle without changing its selection; the loaded profile lists each skip in `skippedBundles`, and the launcher prints them once per start with `reportSkippedBundles`. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
|
||||
A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates or replaces a custom profile at a non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures skip that bundle without changing its selection; the loaded profile lists each skip in `skippedBundles`, and the launcher prints them once per start with `reportSkippedBundles`. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
|
||||
|
||||
Before a profile imports a plugin, DSH checks its `peerDependencies` on `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` against the single runtime version returned by `getDshRuntimeVersion()`. Every declared range must match; prereleases participate in range matching. Source-workspace `workspace:^`, `workspace:~`, and `workspace:*` refer to that same runtime. Missing DSH peers impose no constraint; invalid ranges are incompatible. These checks use peer declarations, not `engines.dsh`, and are not a sandbox against malicious package code.
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
|
||||
|
||||
Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 将 `DshPackageManifest` 适配为包身份可选的 `ProfileManifest`,因为本地 profile 无需发布版本。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
|
||||
|
||||
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。组合包的 `dsh.bundle.patch` 指定一个 patch 文件或一个有序的文件列表;`bundlePatchFiles` 校验该声明,`bundlePatchPaths` 把它解析为绝对路径;该层按此顺序拼接各文件的 patch 列表。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会跳过该组合包,不改变其选择状态;加载结果在 `skippedBundles` 中列出每个被跳过的组合包,启动器每次启动时通过 `reportSkippedBundles` 输出一次。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
|
||||
profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。组合包的 `dsh.bundle.patch` 指定一个 patch 文件或一个有序的文件列表;`bundlePatchFiles` 校验该声明,`bundlePatchPaths` 把它解析为绝对路径;该层按此顺序拼接各文件的 patch 列表。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在非内置名称处创建或替换自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会跳过该组合包,不改变其选择状态;加载结果在 `skippedBundles` 中列出每个被跳过的组合包,启动器每次启动时通过 `reportSkippedBundles` 输出一次。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
|
||||
|
||||
profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-*` 的依赖,与 `getDshRuntimeVersion()` 返回的唯一运行时版本比较。每个声明的版本范围都必须匹配;预发布版本参与范围匹配。源码工作区的 `workspace:^`、`workspace:~` 和 `workspace:*` 指向同一个运行时。未声明 DSH peer 时不施加版本约束;无效范围视为不兼容。这些检查使用 peer 声明,而不是 `engines.dsh`,也不是防范恶意包代码的沙箱。
|
||||
|
||||
|
||||
Reference in new issue
Block a user