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", () => {