refactor(cli)!: namespace the profile and bundle manifests under dsh.profile and dsh.bundle

A profile manifest and a bundle manifest are different kinds and shared one
flat `dsh` section: `dsh.plugins` listed bundles (not plugins) and `dsh.patch`
declared a bundle's layer. Each kind now names its role — a bundle declares
`dsh.bundle.patch`, a profile declares `dsh.profile.bundles` — so a
package.json states which role it plays and the list name matches its contents.

`DEFAULT_PROFILE_PLUGINS` becomes `DEFAULT_PROFILE_BUNDLES`, and
`DshManifestSection` splits into `DshBundleManifest`/`DshProfileManifest`.
Pre-release: no compatibility shim; turtle-ui moved with it (bd5ff10).
This commit is contained in:
Turtle
2026-08-06 17:28:30 +08:00
parent 52d7515936
commit 62d0f26fd6
36 changed files with 203 additions and 156 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 apps/cli/README.md
README.md: bfff1408f001dd10e665d1c56944f778e87aea56
README.zh.md: 2d585f7e0654cbe58fbdd2f33e3d7b77f154a487
README.md: f50f26ec5de54e094e17221f7cd355483483754e
README.zh.md: 242e64a0c42064b9f0b7621e665e85fe44523fe5

View File

@@ -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 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.
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays, then flag patches. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher.

View File

@@ -17,7 +17,7 @@
## Profile
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` 可在不启动的情况下检查组合后的配置树。
profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest元数据清单`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay最后是 flag patch。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base``@deepseek-ai/dsh-web-app``@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析pnpm 把树外插件安装在后者。使用 `--dump-default-config``--dump-config` 可在不启动的情况下检查组合后的配置树。
[CLI命令行界面行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。

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 apps/cli/reference/README.md
README.md: 25c74bc6020aec409381796e129873bbdc937436
README.zh.md: fd29f8f6a29d5858e6c9b6b66e21d5b92733480c
README.md: 62275139809364d80296433e804ade3d447f37e6
README.zh.md: e508caaf05622c0c177eaacd070978368ff84af3

View File

@@ -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`, 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.
`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.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), each `--patch <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).
@@ -25,7 +25,7 @@ dsh --profile web --patch ./extra.yml --dump-config
## Plugin management
`dsh plugin --profile <name> <args...>` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `<args...>` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.plugins` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "patch": "./cordis.patch.yml" }` joins the layer stack (so an `update` that gains the declaration activates it), a patch-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack.
`dsh plugin --profile <name> <args...>` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `<args...>` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack.
```sh
dsh plugin --profile tui add github:deepseek-harness/turtle-ui

View File

@@ -6,7 +6,7 @@
## Profile 启动
`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 <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合profile manifest元数据清单`dsh.profile.bundles` 列表所列的各个组合包 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`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。
@@ -25,7 +25,7 @@ dsh --profile web --patch ./extra.yml --dump-config
## 插件管理
`dsh plugin --profile <name> <args...>` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `<args...>` 转发给 `pnpm``add``remove``why``update` 及其他所有 pnpm 子命令都照常可用pnpm 必须在 PATH 上。相对路径 spec`.``../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout而不是 profile。每次成功运行后`dsh.plugins` 都会与已安装状态对齐:每个解析到 manifest 中声明了 `"dsh": { "patch": "./cordis.patch.yml" }` 的包的依赖加入层栈(因此让包获得该声明的 `update` 会将其激活),没有 patch 的依赖保持为普通依赖并给出一次性警告,已移除的依赖则退出层栈。
`dsh plugin --profile <name> <args...>` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `<args...>` 转发给 `pnpm``add``remove``why``update` 及其他所有 pnpm 子命令都照常可用pnpm 必须在 PATH 上。相对路径 spec`.``../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout而不是 profile。每次成功运行后`dsh.profile.bundles` 都会与已安装状态对齐:每个解析到 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的包的依赖加入层栈(因此让包获得该声明的 `update` 会将其激活),没有组合包声明的依赖保持为普通依赖并给出一次性警告,已移除的依赖则退出层栈。
```sh
dsh plugin --profile tui add github:deepseek-harness/turtle-ui

View File

@@ -1,12 +1,12 @@
/**
* `dsh plugin --profile <name> <args...>` — profile plugin management as a
* thin pnpm forwarder: initialize the profile on first use, run
* `pnpm <args...>` in the profile directory, then reconcile the `dsh.plugins`
* bundle-layer list against the installed state (a dependency resolving to a
* package that declares `dsh.patch` joins the layer stack; a removed or
* patch-less dependency leaves it). Reconciling by installed state, not by
* dependency diff, means `update` activates a package that gained its
* `dsh.patch` in a newer version.
* `pnpm <args...>` in the profile directory, then reconcile the
* `dsh.profile.bundles` layer list against the installed state (a dependency
* resolving to a package that declares `dsh.bundle` joins the layer stack; a
* removed or bundle-less dependency leaves it). Reconciling by installed
* state, not by dependency diff, means `update` activates a package that
* gained its `dsh.bundle` declaration in a newer version.
* @module @deepseek-ai/dsh/plugin
*/
@@ -14,7 +14,7 @@ import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import {
DEFAULT_PROFILE_PLUGINS,
DEFAULT_PROFILE_BUNDLES,
initProfile,
PROFILE_TEMPLATES,
readProfileManifest,
@@ -31,7 +31,7 @@ const NAME = 'dsh'
* Whether a resolved dependency exports a profile patch, i.e. is a bundle.
* @param packageName - the dependency's package name.
* @param profileDir - the profile directory (resolution anchor).
* @returns true when the package manifest declares `dsh.patch`.
* @returns true when the package manifest declares `dsh.bundle`.
*/
function exportsPatch(packageName: string, profileDir: string): boolean {
let dir: string
@@ -41,25 +41,26 @@ function exportsPatch(packageName: string, profileDir: string): boolean {
return false // pnpm reported success yet the package is unresolvable — treat as plain
}
const manifest = readProfileManifest(NAME, dir)
return manifest.dsh?.patch !== undefined
return manifest.dsh?.bundle?.patch !== undefined
}
/**
* Reconcile `dsh.plugins` against the installed state: pnpm has already
* written the real installed names (so a git/path/tarball/alias spec on the
* command line reconciles by its true package name) and materialized the
* packages. A dependency that resolves to a `dsh.patch`-declaring package
* joins the layer stack (appended in dependency order); a dependency-listed
* name that no longer does — removed, or the installed version dropped the
* declaration — leaves it. In-box bundles from the profile template are not
* dependencies and are never touched. Warns once per newly-added patch-less
* dependency (a plain library is fine; the warning is orientation).
* Reconcile `dsh.profile.bundles` against the installed state: pnpm has
* already written the real installed names (so a git/path/tarball/alias spec
* on the command line reconciles by its true package name) and materialized
* the packages. A dependency that resolves to a `dsh.bundle`-declaring
* package joins the layer stack (appended in dependency order); a
* dependency-listed name that no longer does — removed, or the installed
* version dropped the declaration — leaves it. In-box bundles from the
* profile template are not dependencies and are never touched. Warns once
* per newly-added bundle-less dependency (a plain library is fine; the
* warning is orientation).
*/
function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
const after = readProfileManifest(NAME, profileDir)
const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
const dependencies = Object.keys(after.dependencies ?? {})
const plugins = after.dsh?.plugins ?? []
const plugins = after.dsh?.profile?.bundles ?? []
let changed = false
for (const packageName of dependencies) {
const isBundle = exportsPatch(packageName, profileDir)
@@ -68,7 +69,7 @@ function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
changed = true
} else if (!isBundle && !beforeDeps.has(packageName)) {
process.stderr.write(
`${NAME}: warning: ${packageName} declares no dsh.patch — installed as a plain dependency, not a profile layer `
`${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
+ '(a later update that gains one activates it automatically)\n',
)
}
@@ -85,7 +86,7 @@ function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
}
}
if (!changed) return
after.dsh = { ...after.dsh, plugins }
after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } }
writeProfileManifest(profileDir, after)
}
@@ -119,7 +120,7 @@ function anchorPathSpec(argument: string, cwd: string): string {
export function runPlugin(profile: string, args: readonly string[]): number {
const dir = resolveProfileDir(profile)
if (!existsSync(join(dir, 'package.json'))) {
initProfile(dir, PROFILE_TEMPLATES[profile] ?? DEFAULT_PROFILE_PLUGINS)
initProfile(dir, PROFILE_TEMPLATES[profile] ?? DEFAULT_PROFILE_BUNDLES)
process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
}
const before = readProfileManifest(NAME, dir)

View File

@@ -1,6 +1,6 @@
/**
* Shared profile boot for every `dsh` surface: resolve the profile, stack its
* patch layers (bundle layers in `dsh.plugins` order, the profile's own
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own
* `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry
* switch), mount the tree over the profile's empty root config, keep the
* profile patch layer live, and wire fail-loud plus bounded shutdown.
@@ -21,7 +21,7 @@ import {
loadOverlayPatches,
loadProfile,
PROFILE_PATCH_FILENAME,
watchPersonalPatches,
watchUserPatches,
type Profile,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -51,7 +51,7 @@ const HEADLESS_ROW_ID = 'headless-runner'
/** The empty root entry list every profile tree patches over. */
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
# each bundle in package.json's dsh.plugins, then cordis.patch.yml, then any
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
# --patch overlays. Edit cordis.patch.yml, not this file.
[]
`
@@ -119,7 +119,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
/**
* Load `name` and compose its effective patch stack: bundle layers in
* `dsh.plugins` order, the profile's user layer, the home-level user layer
* `dsh.profile.bundles` 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
@@ -251,12 +251,12 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
}
await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } })
}
await watchPersonalPatches(ctx, {
await watchUserPatches(ctx, {
binName: NAME,
filename: composed.profile.patchPath,
compose: composeLive,
})
await watchPersonalPatches(ctx, {
await watchUserPatches(ctx, {
binName: NAME,
filename: homePatchPath(),
compose: composeLive,

View File

@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
async function runBuiltBin(
args: readonly string[] = [],
@@ -43,7 +44,7 @@ interface ProfileLifecycleFixture {
/**
* A minimal custom profile: one lifecycle-marker plugin bundle listed in
* dsh.plugins, no dsh-base — proving out-of-box composition machinery without
* dsh.profile.bundles, no dsh-base — proving out-of-box composition machinery without
* booting the entire product tree.
*/
function createProfileLifecycleFixture(): ProfileLifecycleFixture {
@@ -86,7 +87,7 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
name: 'dsh-lifecycle-bundle',
version: '0.0.0',
type: 'module',
dsh: { patch: './cordis.patch.yml' },
dsh: { bundle: { patch: './cordis.patch.yml' } },
}, undefined, 2))
const profileDir = join(home, 'profiles', 'lifecycle')
mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
@@ -94,7 +95,7 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
name: 'dsh-profile-lifecycle',
private: true,
dependencies: {},
dsh: { plugins: ['dsh-lifecycle-bundle'] },
dsh: { profile: { bundles: ['dsh-lifecycle-bundle'] } },
}, undefined, 2))
// Hand-place the "installed" bundle where profile resolution finds it.
writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
@@ -154,6 +155,26 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}
}, 30_000)
it('reports a patch-overlay boot failure without hanging', async () => {
// The HMR main watcher's initial scan once refreshed the include
// mid-initial-apply, deadlocking the failing apply's rollback against the
// refresh drain: dsh exited 13 with no diagnostic instead of settling
// ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
try {
const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
DSH_HOME: home,
DEEPSEEK_API_KEY: 'keyless-invalid-config',
DSH_TELEMETRY_DISABLED: '1',
})
expect(result.code).toBe(1)
expect(result.stdout).toBe('')
expect(result.stderr).toContain('llm-pi-ai')
} finally {
rmSync(home, { recursive: true, force: true })
}
}, 30_000)
it('applies a custom profile bundle and disposes it on a startup-time signal', async () => {
const fixture = createProfileLifecycleFixture()
const child = startProfileLifecycle(fixture)
@@ -232,7 +253,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
writeFileSync(join(checkout, 'package.json'), JSON.stringify({
name: 'anchored-bundle',
version: '1.0.0',
dsh: { patch: './cordis.patch.yml' },
dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n')
const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
@@ -246,20 +267,20 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(result.exitCode).toBe(0)
const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as {
dependencies: Record<string, string>
dsh: { plugins: string[] }
dsh: { profile: { bundles: string[] } }
}
expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle'])
expect(manifest.dsh.plugins).toContain('anchored-bundle')
expect(manifest.dsh.profile.bundles).toContain('anchored-bundle')
} finally {
rmSync(home, { recursive: true, force: true })
rmSync(checkout, { recursive: true, force: true })
}
}, 90_000)
it('activates a dependency that gained dsh.patch in a later update', async () => {
it('activates a dependency that gained dsh.bundle in a later update', async () => {
// Reconcile runs against the INSTALLED state on every successful pnpm
// run, so `update` (not only `add`) activates a package whose newer
// version declares dsh.patch. Simulated without a registry: hand-place
// version declares dsh.bundle. Simulated without a registry: hand-place
// the installed package, flip its manifest, and run a benign pnpm verb.
const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
try {
@@ -270,24 +291,24 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
name: 'dsh-profile-up',
private: true,
dependencies: { 'late-bundle': 'file:./late-bundle' },
dsh: { plugins: ['@deepseek-ai/dsh-base'] },
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
}))
writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
// v1: no dsh manifest — a plain dependency.
writeFileSync(join(installed, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '1.0.0' }))
const first = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
expect(first.code).toBe(0)
let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { plugins: string[] } }
expect(manifest.dsh.plugins).toEqual(['@deepseek-ai/dsh-base'])
// v2: the installed package now declares dsh.patch (an update landed).
let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
// v2: the installed package now declares dsh.bundle (an update landed).
writeFileSync(join(installed, 'package.json'), JSON.stringify({
name: 'late-bundle', version: '2.0.0', dsh: { patch: './cordis.patch.yml' },
name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
writeFileSync(join(installed, 'cordis.patch.yml'), '[]\n')
const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
expect(second.code).toBe(0)
manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { plugins: string[] } }
expect(manifest.dsh.plugins).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
} finally {
rmSync(home, { recursive: true, force: true })
}

View File

@@ -73,7 +73,7 @@ async function runHeadlessPtySmoke(): Promise<string> {
name: 'dsh-profile-headless',
private: true,
dependencies: {},
dsh: { plugins: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'] },
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless'] } },
}, undefined, 2))
await writeFile(join(profileDir, 'cordis.patch.yml'), [
'- insert:',