From b7be8db80b1e098d6afac3a37600d98f5f16fd28 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:27:54 +0800 Subject: [PATCH 1/2] fix(deepseek): find pnpm when installing the terminal profile, and install it with dsh --- .changeset/deepseek-install-pnpm-path.md | 5 +++++ config/clis.stock.json | 4 ++-- src/config/cli-registry/stock.ts | 6 ++++-- src/utils/deepseek-cli-resolver.ts | 2 +- src/web/public/session-ui.js | 2 +- src/web/routes/system-routes.ts | 7 +++++-- test/deepseek-install-profile-path.test.ts | 18 ++++++++++++++++++ 7 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 .changeset/deepseek-install-pnpm-path.md create mode 100644 test/deepseek-install-profile-path.test.ts diff --git a/.changeset/deepseek-install-pnpm-path.md b/.changeset/deepseek-install-pnpm-path.md new file mode 100644 index 00000000..412d1264 --- /dev/null +++ b/.changeset/deepseek-install-pnpm-path.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +Installing DeepSeek from CLI management no longer leaves it "not installed". The `dsh` terminal profile install ran `dsh plugin`, which needs `pnpm` on its PATH, but the server's own PATH usually lacks the user prefix (`~/.local/bin`) where CLI management puts `dsh`. The profile install now puts `dsh`'s directory first on PATH, and the DeepSeek install command installs `pnpm` alongside `dsh`. diff --git a/config/clis.stock.json b/config/clis.stock.json index 5a86af0e..d1d3e609 100644 --- a/config/clis.stock.json +++ b/config/clis.stock.json @@ -238,8 +238,8 @@ }, "install": { "command": { - "linux": "npm install -g @deepseek-ai/dsh", - "darwin": "npm install -g @deepseek-ai/dsh" + "linux": "npm install -g @deepseek-ai/dsh pnpm", + "darwin": "npm install -g @deepseek-ai/dsh pnpm" }, "npmPackage": "@deepseek-ai/dsh", "docsUrl": "https://github.com/deepseek-ai/deepseek-harness", diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index ae497207..6a0b0af6 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1259,8 +1259,10 @@ const DEEPSEEK: CliEntry = { launcherTargetParam: 'profile', install: { command: { - linux: 'npm install -g @deepseek-ai/dsh', - darwin: 'npm install -g @deepseek-ai/dsh', + // pnpm rides along: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the + // terminal profile this CLI needs before it can run cannot be installed without it (#352). + linux: 'npm install -g @deepseek-ai/dsh pnpm', + darwin: 'npm install -g @deepseek-ai/dsh pnpm', }, npmPackage: '@deepseek-ai/dsh', docsUrl: 'https://github.com/deepseek-ai/deepseek-harness', diff --git a/src/utils/deepseek-cli-resolver.ts b/src/utils/deepseek-cli-resolver.ts index b815a761..81a69584 100644 --- a/src/utils/deepseek-cli-resolver.ts +++ b/src/utils/deepseek-cli-resolver.ts @@ -84,7 +84,7 @@ export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0 */ export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i; -const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'; +const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh pnpm'; /** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */ export function resolveDshHome(): string { diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 3ec17fe0..91771570 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -92,7 +92,7 @@ const RUN_MODE_LAUNCH = { }, deepseek: { label: 'DeepSeek', - installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh', + installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh pnpm', // The two-part availability check is deliberate. `dsh` being installed is // not enough — DeepSeek ships no terminal front door, so a box can have a // perfect binary and nothing a pane can run. diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index e2251c24..91f49b04 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -617,13 +617,16 @@ export function registerSystemRoutes( // synchronous-spawn precedent this endpoint is modelled on. detached: true, // Inherit the environment: this needs a HOME to resolve $DSH_HOME - // against, and a PATH carrying `pnpm`. ⚠️ `dsh plugin` does NOT bundle a + // against, and a PATH carrying `pnpm` (see the PATH below). ⚠️ `dsh plugin` does NOT bundle a // package manager — it `spawnSync`s a literal `pnpm` with no npm // fallback, so on a host without one this exits 127 and dsh's own // stderr ("pnpm not found on PATH") is what reaches the caller through // the OPERATION_FAILED detail below. That is the same missing // dependency that broke the docker agent image in issue #352. - env: process.env, + // PATH leads with the directory `dsh` itself resolved to: CLI management installs + // `dsh` and `pnpm` into the user prefix (`~/.local/bin`), which a service's own PATH + // usually lacks, so `dsh plugin` could not find the `pnpm` sitting next to it. + env: { ...process.env, PATH: [dir, process.env.PATH].filter(Boolean).join(':') }, }); } catch (err) { resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false }); diff --git a/test/deepseek-install-profile-path.test.ts b/test/deepseek-install-profile-path.test.ts new file mode 100644 index 00000000..db88264d --- /dev/null +++ b/test/deepseek-install-profile-path.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +const root = resolve(import.meta.dirname, '..'); + +describe('DeepSeek install', () => { + it('installs pnpm with dsh, since `dsh plugin` spawns a literal pnpm', () => { + const ds = STOCK_CLIS.find((c) => (c.id as string) === 'deepseek')!; + expect(ds.discovery.install.command?.linux).toBe('npm install -g @deepseek-ai/dsh pnpm'); + }); + + it("puts dsh's own directory first on the profile install's PATH", () => { + const src = readFileSync(resolve(root, 'src/web/routes/system-routes.ts'), 'utf8'); + expect(src).toMatch(/env: \{ \.\.\.process\.env, PATH: \[dir, process\.env\.PATH\]/); + }); +}); From 688bdfb97042f4f8bec413f330a84b6207065c7f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 16 Oct 2026 01:37:41 +0200 Subject: [PATCH 2/2] fix(#616): install pnpm only when none is on PATH The DeepSeek install command asked npm for dsh and pnpm in one go. On a machine that already has pnpm through corepack (`corepack enable` puts a pnpm shim in node's own bin directory, which is npm's global bin for nvm and Homebrew installs), `npm install -g pnpm` refuses to overwrite that shim with EEXIST, and because both packages were in the same command the whole install failed: dsh was not installed either. Measured with npm 10 against a scratch prefix holding a corepack-style shim. On master the same machine installed dsh fine and the profile install used the existing pnpm. The command is now `npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)`, so pnpm is installed only when the install environment has none, and a failed dsh install still fails the command. The two hints that embed it follow (the launch-table drift test requires that), and the catalogue is regenerated; install.sh withholds the command for a launcher-only CLI, so its block is unchanged. The test now runs the real stock string through /bin/sh with a fake npm on a PATH it controls, for the three cases: no pnpm, an existing pnpm, and a failing dsh install. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/deepseek-install-pnpm-path.md | 2 +- config/clis.stock.json | 4 +- src/config/cli-registry/stock.ts | 8 ++- src/utils/deepseek-cli-resolver.ts | 3 +- src/web/public/session-ui.js | 2 +- test/deepseek-install-profile-path.test.ts | 70 ++++++++++++++++++++-- 6 files changed, 76 insertions(+), 13 deletions(-) diff --git a/.changeset/deepseek-install-pnpm-path.md b/.changeset/deepseek-install-pnpm-path.md index 412d1264..48ce0d8f 100644 --- a/.changeset/deepseek-install-pnpm-path.md +++ b/.changeset/deepseek-install-pnpm-path.md @@ -2,4 +2,4 @@ 'aicodeman': patch --- -Installing DeepSeek from CLI management no longer leaves it "not installed". The `dsh` terminal profile install ran `dsh plugin`, which needs `pnpm` on its PATH, but the server's own PATH usually lacks the user prefix (`~/.local/bin`) where CLI management puts `dsh`. The profile install now puts `dsh`'s directory first on PATH, and the DeepSeek install command installs `pnpm` alongside `dsh`. +Installing DeepSeek from CLI management no longer leaves it "not installed". The `dsh` terminal profile install ran `dsh plugin`, which needs `pnpm` on its PATH, but the server's own PATH usually lacks the user prefix (`~/.local/bin`) where CLI management puts `dsh`. The profile install now puts `dsh`'s directory first on PATH, and the DeepSeek install command installs `pnpm` alongside `dsh` when none is on PATH (an existing one, such as a corepack shim, is left alone: `npm install -g pnpm` refuses to overwrite it). diff --git a/config/clis.stock.json b/config/clis.stock.json index d1d3e609..c213976b 100644 --- a/config/clis.stock.json +++ b/config/clis.stock.json @@ -238,8 +238,8 @@ }, "install": { "command": { - "linux": "npm install -g @deepseek-ai/dsh pnpm", - "darwin": "npm install -g @deepseek-ai/dsh pnpm" + "linux": "npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)", + "darwin": "npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)" }, "npmPackage": "@deepseek-ai/dsh", "docsUrl": "https://github.com/deepseek-ai/deepseek-harness", diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 6a0b0af6..98146d5c 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1261,8 +1261,12 @@ const DEEPSEEK: CliEntry = { command: { // pnpm rides along: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the // terminal profile this CLI needs before it can run cannot be installed without it (#352). - linux: 'npm install -g @deepseek-ai/dsh pnpm', - darwin: 'npm install -g @deepseek-ai/dsh pnpm', + // ⚠️ Only when none is on PATH. `corepack enable` puts a `pnpm` shim in node's own bin + // directory, which is npm's global bin for nvm and Homebrew, and `npm install -g pnpm` + // refuses to overwrite it (EEXIST). Asked for in the same command as dsh, that failure + // took dsh down with it, on exactly the machines that already had a working pnpm. + linux: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)', + darwin: 'npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)', }, npmPackage: '@deepseek-ai/dsh', docsUrl: 'https://github.com/deepseek-ai/deepseek-harness', diff --git a/src/utils/deepseek-cli-resolver.ts b/src/utils/deepseek-cli-resolver.ts index 81a69584..498967aa 100644 --- a/src/utils/deepseek-cli-resolver.ts +++ b/src/utils/deepseek-cli-resolver.ts @@ -84,7 +84,8 @@ export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0 */ export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i; -const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh pnpm'; +const DEEPSEEK_NOT_FOUND = + 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)'; /** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */ export function resolveDshHome(): string { diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 91771570..7b5f5eeb 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -92,7 +92,7 @@ const RUN_MODE_LAUNCH = { }, deepseek: { label: 'DeepSeek', - installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh pnpm', + installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh && (command -v pnpm >/dev/null || npm install -g pnpm)', // The two-part availability check is deliberate. `dsh` being installed is // not enough — DeepSeek ships no terminal front door, so a box can have a // perfect binary and nothing a pane can run. diff --git a/test/deepseek-install-profile-path.test.ts b/test/deepseek-install-profile-path.test.ts index db88264d..159f778f 100644 --- a/test/deepseek-install-profile-path.test.ts +++ b/test/deepseek-install-profile-path.test.ts @@ -1,14 +1,72 @@ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { describe, it, expect, afterEach } from 'vitest'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; const root = resolve(import.meta.dirname, '..'); +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function deepseekInstallCommand(platform: 'linux' | 'darwin'): string { + const ds = STOCK_CLIS.find((c) => (c.id as string) === 'deepseek')!; + return ds.discovery.install.command![platform]!; +} + +/** + * Run the stock install command through `/bin/sh -c` (what CLI management does, `shell: true`) + * with a fake `npm` that records each call, on a PATH that holds only the fake bins. So this + * exercises the real string, not a copy of it. + */ +function runInstall(opts: { pnpmOnPath: boolean; npmFails?: boolean }): { status: number | null; npmCalls: string[] } { + const dir = mkdtempSync(join(tmpdir(), 'dsh-install-')); + dirs.push(dir); + const npmBin = join(dir, 'npm-bin'); + const pnpmBin = join(dir, 'pnpm-bin'); + mkdirSync(npmBin); + mkdirSync(pnpmBin); + const log = join(dir, 'npm.log'); + writeFileSync(log, ''); + writeFileSync(join(npmBin, 'npm'), '#!/bin/sh\necho "$*" >> "$NPM_LOG"\nexit "${NPM_EXIT:-0}"\n'); + chmodSync(join(npmBin, 'npm'), 0o755); + writeFileSync(join(pnpmBin, 'pnpm'), '#!/bin/sh\nexit 0\n'); + chmodSync(join(pnpmBin, 'pnpm'), 0o755); + const result = spawnSync('/bin/sh', ['-c', deepseekInstallCommand('linux')], { + env: { + PATH: opts.pnpmOnPath ? `${npmBin}:${pnpmBin}` : npmBin, + NPM_LOG: log, + NPM_EXIT: opts.npmFails ? '1' : '0', + }, + encoding: 'utf-8', + }); + const npmCalls = readFileSync(log, 'utf-8').split('\n').filter(Boolean); + return { status: result.status, npmCalls }; +} describe('DeepSeek install', () => { - it('installs pnpm with dsh, since `dsh plugin` spawns a literal pnpm', () => { - const ds = STOCK_CLIS.find((c) => (c.id as string) === 'deepseek')!; - expect(ds.discovery.install.command?.linux).toBe('npm install -g @deepseek-ai/dsh pnpm'); + it('is the same command on linux and darwin', () => { + expect(deepseekInstallCommand('darwin')).toBe(deepseekInstallCommand('linux')); + }); + + it('installs pnpm with dsh when none is on PATH, since `dsh plugin` spawns a literal pnpm', () => { + const { status, npmCalls } = runInstall({ pnpmOnPath: false }); + expect(status).toBe(0); + expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh', 'install -g pnpm']); + }); + + it('leaves an existing pnpm alone (a corepack shim makes `npm install -g pnpm` fail with EEXIST)', () => { + const { status, npmCalls } = runInstall({ pnpmOnPath: true }); + expect(status).toBe(0); + expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh']); + }); + + it('reports a failed dsh install and does not go on to pnpm', () => { + const { status, npmCalls } = runInstall({ pnpmOnPath: false, npmFails: true }); + expect(status).not.toBe(0); + expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh']); }); it("puts dsh's own directory first on the profile install's PATH", () => {