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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-16 01:37:41 +02:00
1 parent b7be8db80b
commit 688bdfb970
6 files changed
+76 -13

No files matched your search

+1 -1
View File
@@ -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).
+2 -2
View File
@@ -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",
+6 -2
View File
@@ -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',
+2 -1
View File
@@ -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 {
+1 -1
View File
@@ -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.
+64 -6
View File
@@ -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", () => {