Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/utils/__tests__/claude-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
getClaudeJsonPath,
getClaudeSettingsPath,
getExistingStatusLine,
getPackageCommandAvailability,
getRefreshInterval,
getRemoteControlStatus,
getSandboxConfig,
Expand Down Expand Up @@ -790,6 +791,34 @@ describe('isClaudeCodeVersionAtLeast', () => {
});
});

describe('getPackageCommandAvailability', () => {
const originalPath = process.env.PATH;

afterEach(() => {
process.env.PATH = originalPath;
});

// Minimal Linux images (Fedora, UBI, Arch without the which package) have no `which`
it.skipIf(process.platform === 'win32')('finds package managers on PATH when there is no which command', () => {
const binDir = path.join(testClaudeConfigDir, 'bin');
fs.mkdirSync(path.join(binDir, 'bunx'), { recursive: true });
fs.writeFileSync(path.join(binDir, 'npm'), '#!/bin/sh\n', { mode: 0o755 });
fs.writeFileSync(path.join(binDir, 'npx'), '#!/bin/sh\n', { mode: 0o755 });
fs.writeFileSync(path.join(binDir, 'bun'), '#!/bin/sh\n', { mode: 0o644 });
process.env.PATH = binDir;
vi.spyOn(childProcess, 'execFileSync').mockImplementation((command) => {
throw Object.assign(new Error(`spawnSync ${command} ENOENT`), { code: 'ENOENT' });
});

expect(getPackageCommandAvailability()).toEqual({
npm: true,
npx: true,
bun: false,
bunx: false
});
});
});

describe('getVoiceConfig', () => {
let testProjectDir = '';

Expand Down
77 changes: 76 additions & 1 deletion src/utils/__tests__/executable-path.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
describe,
expect,
it
} from 'vitest';

import { resolveExecutable } from '../executable-path';
import {
findAllOnPosixPath,
resolveExecutable
} from '../executable-path';

function windows(files: string[], env: NodeJS.ProcessEnv = { Path: 'C:\\Windows\\System32;C:\\Program Files\\Git\\cmd' }) {
const present = new Set(files.map(file => file.toLowerCase()));
Expand Down Expand Up @@ -59,3 +65,72 @@ describe('resolveExecutable', () => {
expect(resolveExecutable('C:\\tools\\git.exe', windows([]))).toBe('C:\\tools\\git.exe');
});
});

function posix(executables: string[], pathValue: string) {
const present = new Set(executables);
return {
env: { PATH: pathValue },
isExecutableFile: (candidate: string) => present.has(candidate)
};
}

describe('findAllOnPosixPath', () => {
it('lists every match in PATH order, as which -a does', () => {
const search = posix(
['/usr/local/bin/ccstatusline', '/home/alice/.bun/bin/ccstatusline'],
'/home/alice/.bun/bin:/usr/bin:/usr/local/bin'
);

expect(findAllOnPosixPath('ccstatusline', search)).toEqual([
'/home/alice/.bun/bin/ccstatusline',
'/usr/local/bin/ccstatusline'
]);
});

it('lists a directory that PATH repeats once', () => {
expect(findAllOnPosixPath('npm', posix(['/usr/bin/npm'], '/usr/bin:/bin:/usr/bin'))).toEqual(['/usr/bin/npm']);
});

it('skips empty and relative PATH entries', () => {
expect(findAllOnPosixPath('npm', posix(['./npm', 'node_modules/.bin/npm', '/bin/npm'], '/usr/bin::node_modules/.bin:/bin'))).toEqual(['/bin/npm']);
});

it('finds nothing without a PATH', () => {
expect(findAllOnPosixPath('npm', { env: {}, isExecutableFile: () => true })).toEqual([]);
});

// A test or a caller may swap in a new env object; the lookup must see it
it.skipIf(process.platform === 'win32')('reads PATH from process.env as it is when called', () => {
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-path-env-'));
const originalEnv = process.env;
try {
fs.writeFileSync(path.join(binDir, 'npm'), '#!/bin/sh\n', { mode: 0o755 });
process.env = { ...originalEnv, PATH: binDir };

expect(findAllOnPosixPath('npm')).toEqual([path.join(binDir, 'npm')]);
} finally {
process.env = originalEnv;
fs.rmSync(binDir, { recursive: true, force: true });
}
});

it.skipIf(process.platform === 'win32')('only lists files the user can run', () => {
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-path-scan-'));
const originalPath = process.env.PATH;
try {
fs.writeFileSync(path.join(binDir, 'npm'), '#!/bin/sh\n', { mode: 0o755 });
fs.writeFileSync(path.join(binDir, 'bun'), '#!/bin/sh\n', { mode: 0o644 });
fs.mkdirSync(path.join(binDir, 'bunx'));
fs.symlinkSync('npm', path.join(binDir, 'npx'));
process.env.PATH = binDir;

expect(findAllOnPosixPath('npm')).toEqual([path.join(binDir, 'npm')]);
expect(findAllOnPosixPath('npx')).toEqual([path.join(binDir, 'npx')]);
expect(findAllOnPosixPath('bun')).toEqual([]);
expect(findAllOnPosixPath('bunx')).toEqual([]);
} finally {
process.env.PATH = originalPath;
fs.rmSync(binDir, { recursive: true, force: true });
}
});
});
137 changes: 136 additions & 1 deletion src/utils/__tests__/global-command-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import * as childProcess from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
afterEach,
describe,
Expand All @@ -9,14 +12,15 @@ import {

import {
getCommandResolutionPaths,
getRealDirectory,
inspectGlobalCommandResolution
} from '../global-command-resolution';
import {
getPackageManagerExecutable,
getPackageManagerShellOptions
} from '../package-manager-executable';

function mockExecFileSync(responses: Record<string, string>) {
function mockExecFileSync(responses: Record<string, string | Error>) {
const spy = vi.spyOn(childProcess, 'execFileSync').mockImplementation((command, args) => {
const key = `${command} ${(args as string[]).join(' ')}`;
const response = responses[key];
Expand All @@ -25,6 +29,10 @@ function mockExecFileSync(responses: Record<string, string>) {
throw new Error(`Unexpected command: ${key}`);
}

if (response instanceof Error) {
throw response;
}

return response;
});

Expand All @@ -37,6 +45,27 @@ function mockExecFileSync(responses: Record<string, string>) {
return spy;
}

// What running a program that isn't installed throws
function missingProgramError(command: string): Error {
return Object.assign(new Error(`spawnSync ${command} ENOENT`), { code: 'ENOENT' });
}

// What a program that runs and exits with status 1 throws, as `which` does when it finds nothing
function failedCommandError(command: string): Error {
return Object.assign(new Error(`Command failed: ${command}`), { status: 1 });
}

// macOS and Windows file systems ignore letter case by default; most Linux ones don't
function tempFileSystemIgnoresCase(): boolean {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-case-'));
try {
fs.mkdirSync(path.join(dir, 'probe'));
return fs.existsSync(path.join(dir, 'PROBE'));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}

describe('global command resolution', () => {
afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -79,6 +108,43 @@ describe('global command resolution', () => {
]);
});

// Minimal Linux images (Fedora, UBI, Arch without the which package) have no `which`
it.skipIf(process.platform === 'win32')('looks through PATH itself when there is no which command', () => {
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-path-'));
const originalPath = process.env.PATH;
try {
fs.writeFileSync(path.join(binDir, 'ccstatusline'), '#!/bin/sh\n', { mode: 0o755 });
process.env.PATH = `${path.join(binDir, 'missing')}:${binDir}`;
mockExecFileSync({
'which -a ccstatusline': missingProgramError('which'),
'bun pm bin -g': `${binDir}\n`
});

const resolution = inspectGlobalCommandResolution('bun', { platform: 'linux' });

expect(resolution.resolvedPaths).toEqual([path.join(binDir, 'ccstatusline')]);
expect(resolution.warning).toBeNull();
} finally {
process.env.PATH = originalPath;
fs.rmSync(binDir, { recursive: true, force: true });
}
});

it.skipIf(process.platform === 'win32')('does not look through PATH itself when which runs and fails', () => {
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-path-'));
const originalPath = process.env.PATH;
try {
fs.writeFileSync(path.join(binDir, 'ccstatusline'), '#!/bin/sh\n', { mode: 0o755 });
process.env.PATH = binDir;
mockExecFileSync({ 'which -a ccstatusline': failedCommandError('which -a ccstatusline') });

expect(getCommandResolutionPaths('ccstatusline', { platform: 'linux' })).toEqual([]);
} finally {
process.env.PATH = originalPath;
fs.rmSync(binDir, { recursive: true, force: true });
}
});

it('silences child stderr on best-effort probes so failures cannot leak to the terminal', () => {
const execFileSyncSpy = mockExecFileSync({
'which -a ccstatusline': '/home/alice/.bun/bin/ccstatusline\n',
Expand Down Expand Up @@ -118,6 +184,75 @@ describe('global command resolution', () => {
expect(resolution.warning).toContain('/usr/local/bin/ccstatusline');
});

// Merged /usr (Ubuntu 22.04+, Debian 12): /bin links to usr/bin and PATH lists both
it.skipIf(process.platform === 'win32')('counts a PATH directory reached through a symlink once', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-merged-usr-'));
try {
fs.mkdirSync(path.join(root, 'usr', 'bin'), { recursive: true });
fs.writeFileSync(path.join(root, 'usr', 'bin', 'ccstatusline'), '#!/bin/sh\n', { mode: 0o755 });
fs.symlinkSync('usr/bin', path.join(root, 'bin'));
mockExecFileSync({
'which -a ccstatusline': `${root}/usr/bin/ccstatusline\n${root}/bin/ccstatusline\n`,
'npm prefix -g': `${root}/usr\n`
});

const resolution = inspectGlobalCommandResolution('npm', { platform: 'linux' });

expect(resolution.resolvedPaths).toEqual([`${root}/usr/bin/ccstatusline`, `${root}/bin/ccstatusline`]);
expect(resolution.warning).toBeNull();
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

it.skipIf(process.platform === 'win32')('lists another install once and leaves out other spellings of the first match', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-merged-usr-'));
try {
fs.mkdirSync(path.join(root, 'usr', 'bin'), { recursive: true });
fs.mkdirSync(path.join(root, '.bun', 'bin'), { recursive: true });
fs.writeFileSync(path.join(root, 'usr', 'bin', 'ccstatusline'), '#!/bin/sh\n', { mode: 0o755 });
fs.writeFileSync(path.join(root, '.bun', 'bin', 'ccstatusline'), '#!/bin/sh\n', { mode: 0o755 });
fs.symlinkSync('usr/bin', path.join(root, 'bin'));
mockExecFileSync({
'which -a ccstatusline': `${root}/usr/bin/ccstatusline\n${root}/bin/ccstatusline\n${root}/.bun/bin/ccstatusline\n`,
'npm prefix -g': `${root}/usr\n`
});

const resolution = inspectGlobalCommandResolution('npm', { platform: 'linux' });

expect(resolution.warning).toBe(`⚠ Multiple ccstatusline binaries are on PATH. Claude Code will run the first match: ${root}/usr/bin/ccstatusline.\nOther matches: ${root}/.bun/bin/ccstatusline`);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

// Where letter case is ignored, PATH can spell one directory two ways
it.skipIf(!tempFileSystemIgnoresCase())('resolves a directory spelled in another letter case to one path', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-case-'));
try {
fs.mkdirSync(path.join(root, 'bin'));

expect(getRealDirectory(path.join(root, 'Bin'))).toBe(getRealDirectory(path.join(root, 'bin')));
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

// Bun's plain realpathSync already reports the on-disk case, so only the
// call itself shows that Node gets the same answer
it('resolves directories with the native realpath', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-case-'));
try {
const nativeSpy = vi.spyOn(fs.realpathSync, 'native');

getRealDirectory(root);

expect(nativeSpy).toHaveBeenCalledWith(root);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

it('ignores transient bunx status line shims when resolving global commands', () => {
mockExecFileSync({
'which -a ccstatusline': '/var/folders/demo/T/bunx-501-ccstatusline@latest/node_modules/.bin/ccstatusline\n/Users/alice/.bun/bin/ccstatusline\n',
Expand Down
Loading
Loading