From 52d7515936a3ed663bc336c512cbf1f2f51d38bd Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 6 Aug 2026 12:16:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(cli):=20plugin=20UX=20=E2=80=94=20anchor=20?= =?UTF-8?q?relative=20specs,=20reconcile=20by=20installed=20state,=20guide?= =?UTF-8?q?=20blocked=20git=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Relative path specs (., ../plugin, file:/link: forms) anchor to the invoking directory before forwarding: pnpm's cwd is the profile dir, so a bare 'add .' from a plugin checkout used to self-link the profile (exit 0, nothing installed). Bare paths stay bare and prefixed specs keep their prefix, preserving pnpm's link-vs-copy semantics. - dsh.plugins reconciles against the INSTALLED state on every successful pnpm run, not the dependency diff: an update whose new version gains dsh.patch activates the layer; a version that drops it (or a removal) deactivates it. Template bundles are never touched. - A failed pnpm run now names the profile directory, and a git-spec failure explains pnpm >=10's prepare-script block with a pointer at the profile's pnpm-workspace.yaml allowBuilds (turtle-ui's prepare-based git install is the reference consumer); reference README documents all three. --- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 4 +- apps/cli/reference/README.zh.md | 4 +- apps/cli/src/plugin.ts | 94 +++++++++++++++++++++-------- apps/cli/tests/built-bin.e2e.ts | 71 ++++++++++++++++++++++ 5 files changed, 149 insertions(+), 28 deletions(-) diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 369aa71271..f962567ca0 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 583ee093119eb01ff7b37a6aced7b1d9d8cedc92 -README.zh.md: 452dee18ec94e05bcff269a5f24fe0d455c6fe96 +README.md: 25c74bc6020aec409381796e129873bbdc937436 +README.zh.md: fd29f8f6a29d5858e6c9b6b66e21d5b92733480c diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 583ee09311..25c74bc602 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -25,7 +25,7 @@ dsh --profile web --patch ./extra.yml --dump-config ## Plugin management -`dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` verbatim 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. After a successful `add`, a package whose manifest declares `"dsh": { "patch": "./cordis.patch.yml" }` is appended to `dsh.plugins` (last layer); a package without that declaration stays a plain dependency and prints a warning. `remove` drops the package from `dsh.plugins`. +`dsh plugin --profile ` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `` 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. ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui @@ -33,6 +33,8 @@ dsh plugin --profile tui remove turtle-ui dsh --profile tui ``` +Git-hosted plugins that ship sources build during install through their `prepare` script, which pnpm ≥10 blocks until the consumer allows it: the first `add` fails with pnpm's `allowBuilds` hint (and a dsh pointer at the profile's `pnpm-workspace.yaml`); copy the printed key there and re-run. Installing a built tarball or a local checkout needs no allowance. + ## Web alias `dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 452dee18ec..fd29f8f6a2 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -25,7 +25,7 @@ dsh --profile web --patch ./extra.yml --dump-config ## 插件管理 -`dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 原样转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。`add` 成功后,manifest 中声明 `"dsh": { "patch": "./cordis.patch.yml" }` 的包会被追加到 `dsh.plugins`(最后一层);没有该声明的包保持为普通依赖并打印警告。`remove` 把包从 `dsh.plugins` 中移除。 +`dsh plugin --profile ` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `` 转发给 `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 的依赖保持为普通依赖并给出一次性警告,已移除的依赖则退出层栈。 ```sh dsh plugin --profile tui add github:deepseek-harness/turtle-ui @@ -33,6 +33,8 @@ dsh plugin --profile tui remove turtle-ui dsh --profile tui ``` +Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构建,而 pnpm ≥10 在消费方允许之前会阻止该脚本:首次 `add` 会失败并给出 pnpm 的 `allowBuilds` 提示(以及 dsh 指向该 profile 的 `pnpm-workspace.yaml` 的指引);把打印出的键复制到那里并重新运行即可。安装已构建的 tarball 或本地 checkout 不需要任何允许。 + ## Web 别名 `dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 diff --git a/apps/cli/src/plugin.ts b/apps/cli/src/plugin.ts index 80592ee80a..4370f86557 100644 --- a/apps/cli/src/plugin.ts +++ b/apps/cli/src/plugin.ts @@ -2,15 +2,17 @@ * `dsh plugin --profile ` — profile plugin management as a * thin pnpm forwarder: initialize the profile on first use, run * `pnpm ` in the profile directory, then reconcile the `dsh.plugins` - * bundle-layer list from the manifest's dependency diff (a package exporting - * a `dsh.patch` joins the layer stack; one without only warns — it is a plain - * library dependency; a removed dependency leaves the stack). + * 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. * @module @deepseek-ai/dsh/plugin */ import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { DEFAULT_PROFILE_PLUGINS, initProfile, @@ -43,44 +45,75 @@ function exportsPatch(packageName: string, profileDir: string): boolean { } /** - * Reconcile `dsh.plugins` against the manifest's dependency diff: 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. Added bundle - * dependencies append (in dependency order); removed dependencies drop. + * 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). */ function reconcilePlugins(before: ProfileManifest, profileDir: string): void { const after = readProfileManifest(NAME, profileDir) const beforeDeps = new Set(Object.keys(before.dependencies ?? {})) - const afterDeps = Object.keys(after.dependencies ?? {}) + const dependencies = Object.keys(after.dependencies ?? {}) const plugins = after.dsh?.plugins ?? [] let changed = false - for (const packageName of afterDeps) { - if (beforeDeps.has(packageName) || plugins.includes(packageName)) continue - if (!exportsPatch(packageName, profileDir)) { + for (const packageName of dependencies) { + const isBundle = exportsPatch(packageName, profileDir) + if (isBundle && !plugins.includes(packageName)) { + plugins.push(packageName) + 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 ` - + '(if it gains one later, add it to dsh.plugins in the profile\'s package.json)\n', + + '(a later update that gains one activates it automatically)\n', ) - continue } - plugins.push(packageName) - changed = true } - const afterSet = new Set(afterDeps) - for (const packageName of beforeDeps) { - if (afterSet.has(packageName) || !plugins.includes(packageName)) continue - plugins.splice(plugins.indexOf(packageName), 1) - changed = true + const dependencySet = new Set(dependencies) + for (const packageName of [...plugins]) { + // Only dependency-managed entries are subject to removal; template + // bundles (dsh-base and friends) are not dependencies. + const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName) + const stillBundle = dependencySet.has(packageName) && exportsPatch(packageName, profileDir) + if (wasDependency && !stillBundle) { + plugins.splice(plugins.indexOf(packageName), 1) + changed = true + } } if (!changed) return after.dsh = { ...after.dsh, plugins } writeProfileManifest(profileDir, after) } +/** + * Rewrite relative filesystem specs against the user's invoking directory. + * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin` + * (or their `file:`/`link:` forms) would silently resolve inside the profile + * — `add .` from a plugin checkout would self-link the profile. Absolute + * specs, registry names, and every other pnpm argument pass through + * untouched. + * @param argument - one pnpm argument, verbatim from argv. + * @param cwd - the directory `dsh` was invoked from. + * @returns the argument with a relative path spec anchored to `cwd`. + */ +function anchorPathSpec(argument: string, cwd: string): string { + const match = /^(?(?:file|link):)?(?\.{1,2}(?:[/\\].*)?)$/.exec(argument) + if (match?.groups?.path === undefined) return argument + // A bare path stays bare and a prefixed spec keeps its prefix: pnpm's + // link-vs-copy semantics differ between `file:` and a plain directory + // path, and the anchor must not change which one the user asked for. + const prefix = match.groups.prefix ?? '' + return `${prefix}${resolve(cwd, match.groups.path)}` +} + /** * Run one `dsh plugin` invocation: init if needed, forward to pnpm, reconcile. * @param profile - the profile name. - * @param args - pnpm arguments, verbatim. + * @param args - pnpm arguments with relative path specs anchored to the invoking directory. * @returns the pnpm exit code. */ export function runPlugin(profile: string, args: readonly string[]): number { @@ -92,7 +125,7 @@ export function runPlugin(profile: string, args: readonly string[]): number { const before = readProfileManifest(NAME, dir) // Windows resolves pnpm through its .cmd shim, which spawn() refuses // without a shell since the CVE-2024-27980 hardening. - const result = spawnSync('pnpm', [...args], { + const result = spawnSync('pnpm', args.map(argument => anchorPathSpec(argument, process.cwd())), { cwd: dir, stdio: 'inherit', shell: process.platform === 'win32', @@ -106,6 +139,19 @@ export function runPlugin(profile: string, args: readonly string[]): number { throw result.error } const exitCode = result.status ?? 1 - if (exitCode === 0) reconcilePlugins(before, dir) + if (exitCode === 0) { + reconcilePlugins(before, dir) + } else { + // pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH + // one; the profile owns it, and the commonest failure here is pnpm ≥10 + // blocking a git dependency's prepare (build) script until allowlisted. + process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`) + if (args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) { + process.stderr.write( + `${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — ` + + `add the exact key pnpm printed above under allowBuilds in ${join(dir, 'pnpm-workspace.yaml')}, then re-run\n`, + ) + } + } return exitCode } diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index de82654b7f..8e49206119 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -222,6 +222,77 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('anchors a relative add spec to the invoking directory, not the profile', async () => { + // `dsh plugin --profile x add .` from a plugin checkout must install THAT + // checkout — pnpm's cwd is the profile directory, so an un-anchored `.` + // would self-link the profile. + const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-anchor-')) + const checkout = mkdtempSync(join(tmpdir(), 'dsh-plugin-checkout-')) + try { + writeFileSync(join(checkout, 'package.json'), JSON.stringify({ + name: 'anchored-bundle', + version: '1.0.0', + dsh: { patch: './cordis.patch.yml' }, + })) + writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n') + const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], { + cwd: checkout, + input: '', + timeout: 60_000, + killSignal: 'SIGKILL', + reject: false, + env: { DSH_HOME: home }, + }) + expect(result.exitCode).toBe(0) + const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as { + dependencies: Record + dsh: { plugins: string[] } + } + expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle']) + expect(manifest.dsh.plugins).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 () => { + // 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 + // the installed package, flip its manifest, and run a benign pnpm verb. + const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-')) + try { + const profileDir = join(home, 'profiles', 'up') + const installed = join(profileDir, 'node_modules', 'late-bundle') + mkdirSync(installed, { recursive: true }) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-up', + private: true, + dependencies: { 'late-bundle': 'file:./late-bundle' }, + dsh: { plugins: ['@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). + writeFileSync(join(installed, 'package.json'), JSON.stringify({ + name: 'late-bundle', version: '2.0.0', dsh: { 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']) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + describe('config dump', () => { let home: string beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })