diff --git a/.changeset/deepseek-install-pnpm-path.md b/.changeset/deepseek-install-pnpm-path.md new file mode 100644 index 00000000..48ce0d8f --- /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` 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 5a86af0e..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", - "darwin": "npm install -g @deepseek-ai/dsh" + "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 3075e36f..0a29a74c 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1300,8 +1300,14 @@ 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). + // ⚠️ 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 b815a761..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'; +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 9e32f0b0..71345763 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 && (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/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..159f778f --- /dev/null +++ b/test/deepseek-install-profile-path.test.ts @@ -0,0 +1,76 @@ +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('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", () => { + const src = readFileSync(resolve(root, 'src/web/routes/system-routes.ts'), 'utf8'); + expect(src).toMatch(/env: \{ \.\.\.process\.env, PATH: \[dir, process\.env\.PATH\]/); + }); +});