Files
Alex Newman b72d8d0d43 fix(install): find bun/uv where their installers actually place them (#4038)
Detection only looked under os.homedir(), so env-directed installs
(BUN_INSTALL, UV_INSTALL_DIR, XDG_BIN_HOME, LOCALAPPDATA) reported
"executable not found" after a successful install. Honour the installer
env vars and pass bun paths through execFile instead of a shell.

Rehost of #3532.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
2026-09-11 09:31:42 +00:00

249 lines
9.6 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, chmodSync } from 'fs';
import { spawnSync } from 'child_process';
import { join } from 'path';
import { tmpdir } from 'os';
import {
readInstallMarker,
writeInstallMarker,
isInstallCurrent,
platformBunRemediation,
platformUvRemediation,
bunCommonPaths,
uvCommonPaths,
installPluginDependencies,
} from '../src/npx-cli/install/setup-runtime';
import { IS_WINDOWS } from '../src/npx-cli/utils/paths';
const SETUP_RUNTIME_SOURCE_PATH = join(import.meta.dir, '..', 'src', 'npx-cli', 'install', 'setup-runtime.ts');
const SHARED_SPAWN_SOURCE_PATH = join(import.meta.dir, '..', 'src', 'shared', 'spawn.ts');
const DOCTOR_SOURCE_PATH = join(import.meta.dir, '..', 'src', 'npx-cli', 'commands', 'doctor.ts');
function probeBunVersion(): string | null {
try {
const result = spawnSync('bun', ['--version'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
return result.status === 0 ? result.stdout.trim() : null;
} catch {
return null;
}
}
describe('setup-runtime install marker', () => {
let tempDir: string;
beforeEach(() => {
tempDir = join(
tmpdir(),
`setup-runtime-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(tempDir, { recursive: true });
});
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('readInstallMarker', () => {
it('returns null when marker file is missing', () => {
expect(readInstallMarker(tempDir)).toBeNull();
});
it('returns null when marker file is invalid JSON', () => {
writeFileSync(join(tempDir, '.install-version'), 'not valid json');
expect(readInstallMarker(tempDir)).toBeNull();
});
it('returns parsed marker when file is valid', () => {
writeInstallMarker(tempDir, '1.2.3', '1.0.0', '0.5.0');
const marker = readInstallMarker(tempDir);
expect(marker).not.toBeNull();
expect(marker?.version).toBe('1.2.3');
expect(marker?.bun).toBe('1.0.0');
expect(marker?.uv).toBe('0.5.0');
});
it('returns parsed marker when file is a legacy plain-text version', () => {
writeFileSync(join(tempDir, '.install-version'), '12.4.4\n');
const marker = readInstallMarker(tempDir);
expect(marker).toEqual({ version: '12.4.4' });
});
it('normalizes a leading v in legacy plain-text versions', () => {
writeFileSync(join(tempDir, '.install-version'), 'v12.4.4\n');
const marker = readInstallMarker(tempDir);
expect(marker).toEqual({ version: '12.4.4' });
});
});
describe('writeInstallMarker', () => {
it('writes a JSON file with the canonical schema { version, bun, uv, installedAt }', () => {
writeInstallMarker(tempDir, '12.4.7', '1.2.0', '0.4.18');
const path = join(tempDir, '.install-version');
expect(existsSync(path)).toBe(true);
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
expect(parsed.version).toBe('12.4.7');
expect(parsed.bun).toBe('1.2.0');
expect(parsed.uv).toBe('0.4.18');
expect(typeof parsed.installedAt).toBe('string');
expect(() => new Date(parsed.installedAt).toISOString()).not.toThrow();
});
it('only writes the four documented fields', () => {
writeInstallMarker(tempDir, '1.0.0', '1.0.0', '0.1.0');
const parsed = JSON.parse(readFileSync(join(tempDir, '.install-version'), 'utf-8'));
expect(Object.keys(parsed).sort()).toEqual(['bun', 'installedAt', 'uv', 'version'].sort());
});
});
describe('isInstallCurrent', () => {
it('returns false when node_modules is missing', () => {
writeInstallMarker(tempDir, '1.0.0', '1.0.0', '0.1.0');
expect(isInstallCurrent(tempDir, '1.0.0')).toBe(false);
});
it('returns false when marker is missing (but node_modules exists)', () => {
mkdirSync(join(tempDir, 'node_modules'));
expect(isInstallCurrent(tempDir, '1.0.0')).toBe(false);
});
it('returns false when marker version does not match expected', () => {
mkdirSync(join(tempDir, 'node_modules'));
const bunVersion = probeBunVersion() ?? '1.0.0';
writeInstallMarker(tempDir, '1.0.0', bunVersion, '0.1.0');
expect(isInstallCurrent(tempDir, '2.0.0')).toBe(false);
});
it('returns true when marker matches version and bun version matches', () => {
const bunVersion = probeBunVersion();
if (!bunVersion) {
return;
}
mkdirSync(join(tempDir, 'node_modules'));
writeInstallMarker(tempDir, '1.0.0', bunVersion, '0.1.0');
expect(isInstallCurrent(tempDir, '1.0.0')).toBe(true);
});
it('returns false for a matching legacy plain-text marker when bun is available', () => {
const bunVersion = probeBunVersion();
if (!bunVersion) {
return;
}
mkdirSync(join(tempDir, 'node_modules'));
writeFileSync(join(tempDir, '.install-version'), '1.0.0\n');
expect(isInstallCurrent(tempDir, '1.0.0')).toBe(false);
});
});
describe('platform remediation strings (Phase 5)', () => {
it('bun remediation is non-empty and references Bun install', () => {
const text = platformBunRemediation();
expect(text.length).toBeGreaterThan(0);
expect(text).toContain('Bun');
expect(text).toContain('claude-mem install');
});
it('uv remediation is non-empty and references uv install', () => {
const text = platformUvRemediation();
expect(text.length).toBeGreaterThan(0);
expect(text.toLowerCase()).toContain('uv');
expect(text).toContain('claude-mem install');
});
});
});
describe('setup-runtime binary detection honours installer env vars', () => {
const bunName = IS_WINDOWS ? 'bun.exe' : 'bun';
const uvName = IS_WINDOWS ? 'uv.exe' : 'uv';
it('bunCommonPaths honours BUN_INSTALL', () => {
const paths = bunCommonPaths({ BUN_INSTALL: '/opt/bun' });
expect(paths).toContain(join('/opt/bun', 'bin', bunName));
});
it('uvCommonPaths honours UV_INSTALL_DIR', () => {
const paths = uvCommonPaths({ UV_INSTALL_DIR: '/opt/uv/bin' });
expect(paths).toContain(join('/opt/uv/bin', uvName));
});
it('uvCommonPaths honours XDG_BIN_HOME', () => {
const paths = uvCommonPaths({ XDG_BIN_HOME: '/xdg/bin' });
expect(paths).toContain(join('/xdg/bin', uvName));
});
it('bunCommonPaths returns absolute paths and no duplicates', () => {
const paths = bunCommonPaths({ BUN_INSTALL: '/opt/bun' });
expect(paths.length).toBe(new Set(paths).size);
expect(paths.every(p => p.length > 0)).toBe(true);
});
});
describe('installPluginDependencies passes the bun path as an argument, not through a shell', () => {
// The bun path now flows from installer env vars (e.g. $BUN_INSTALL) that can
// hold spaces or shell metacharacters. execFile must pass it as argv[0] so it
// never reaches a shell.
it('runs a bun path containing spaces and injection syntax without evaluating it', async () => {
if (IS_WINDOWS) return; // POSIX fake-bin shell script
const unique = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const marker = join(tmpdir(), `pwned-${unique}`);
// The bun executable lives in a dir whose name has a space and injection
// syntax; its output goes to a clean path so the fake script's own redirect
// is never the thing under test.
const base = join(tmpdir(), `bun space $(touch ${marker}) ${unique}`);
const targetDir = join(base, 'target');
const argsFile = join(tmpdir(), `args-${unique}.txt`);
mkdirSync(targetDir, { recursive: true });
writeFileSync(join(targetDir, 'package.json'), JSON.stringify({ dependencies: {} }));
const fakeBun = join(base, 'bun');
writeFileSync(fakeBun, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsFile}"\nexit 0\n`);
chmodSync(fakeBun, 0o755);
try {
await installPluginDependencies(targetDir, fakeBun);
const recorded = readFileSync(argsFile, 'utf-8').trim().split('\n');
expect(recorded).toEqual(['install', '--frozen-lockfile', '--ignore-scripts']);
// The $(touch ...) in the path must NOT have executed.
expect(existsSync(marker)).toBe(false);
} finally {
rmSync(base, { recursive: true, force: true });
rmSync(marker, { force: true });
rmSync(argsFile, { force: true });
}
});
});
describe('setup-runtime Windows spawn hygiene', () => {
it('does not use shell: IS_WINDOWS for bun/uv version probes', () => {
const source = readFileSync(SETUP_RUNTIME_SOURCE_PATH, 'utf-8');
const sharedSpawnSource = readFileSync(SHARED_SPAWN_SOURCE_PATH, 'utf-8');
expect(source).not.toContain('shell: IS_WINDOWS');
expect(source).toContain('buildSpawnSyncInvocation(command, args, options)');
expect(source).toContain('lookupWindowsCommand(command)');
expect(sharedSpawnSource).toContain("spawnSync('where', [command]");
expect(sharedSpawnSource).toContain('windowsHide: true');
});
});
describe('doctor marketplace runtime hygiene', () => {
it('checks the executable marketplace root marker, not only node_modules', () => {
const source = readFileSync(DOCTOR_SOURCE_PATH, 'utf-8');
expect(source).toContain("name: 'Marketplace runtime'");
expect(source).toContain('isInstallCurrent(marketplaceDir, readPluginVersion())');
// A missing marker with node_modules present is a warn, not a fail: the
// marker is written only by the npx installer, and marketplace-flow /
// dev-sync installs never have one (#3661).
expect(source).toContain('no npx install marker');
expect(source).toContain('install marker stale');
});
});