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
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ pnpm-lock.yaml
dist/
node_modules/
CHANGELOG.md
CLAUDE.md
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ Telemetry is automatically disabled in CI environments and during development.

## Development

On Windows, clone with symlinks enabled so `CLAUDE.md` is not checked out as a copy (which breaks Prettier):

```bash
git clone -c core.symlinks=true https://github.com/spotify/confidence-cli.git
```

If the repo is already cloned, run `git checkout -- CLAUDE.md` after `git config core.symlinks true`.

```bash
pnpm install
pnpm try # Run the wizard locally via tsx
Expand Down
12 changes: 9 additions & 3 deletions __tests__/e2e/system-check-failure.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createSession } from './testing-framework/index.js';
import { dirname } from 'node:path';
import { isWindows } from '../shared/platform.js';
import { createSession } from './testing-framework/index.js';

describe('when system check fails', () => {
it('shows error when git is missing', async () => {
Expand All @@ -18,8 +19,13 @@ describe('when system check fails', () => {
expect(session.snapshot()).toMatchSnapshot('system-check-failure');
});

it('shows error when node is not on PATH', async () => {
// PATH with git but not node — CLI still runs via absolute path
/**
* @todo Make this test work on Windows. It currently relies on `/usr/bin`
* as a PATH that contains git but not node, which has no direct Windows
* equivalent. Constructing one portably is non-trivial because git's
* install location varies across Windows setups.
*/
it.skipIf(isWindows)('shows error when node is not on PATH', async () => {
using session = createSession({ systemPath: '/usr/bin' });

await session.waitForText('Start setup');
Expand Down
23 changes: 23 additions & 0 deletions __tests__/e2e/testing-framework/mocks/binaries/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { writeFileSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { isWindows } from '../../../../shared/platform.js';

export const writeMockBinary = isWindows ? writeWindowsBinary : writeUnixBinary;

function writeUnixBinary(dir: string, name: string, script: string): void {
const filePath = join(dir, name);
writeFileSync(filePath, script, 'utf-8');
chmodSync(filePath, 0o755);
}

function writeWindowsBinary(dir: string, name: string, script: string): void {
const jsPath = join(dir, `${name}.js`);
writeFileSync(jsPath, script.replace(/^#!.*\n/, ''), 'utf-8');
// `.cmd` is for cmd.exe; CreateProcess cannot run it. The CLI resolves
// `{name}.js` on PATH and runs it with Node instead (`resolveBin`).
writeFileSync(join(dir, `${name}.cmd`), `@node "%~dp0${name}.js" %*\r\n`, 'utf-8');
}

export const writeMockOpenStub = isWindows
? (dir: string) => writeFileSync(join(dir, 'open.cmd'), '@exit /b 0\r\n', 'utf-8')
: (dir: string) => writeUnixBinary(dir, 'open', '#!/bin/sh\nexit 0\n');
12 changes: 4 additions & 8 deletions __tests__/e2e/testing-framework/mocks/binaries/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import { writeFileSync, mkdirSync, chmodSync } from 'node:fs';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { CLAUDE_SCRIPT } from './claude.js';
import { CURSOR_SCRIPT } from './cursor.js';
import { CODEX_SCRIPT } from './codex.js';
import { writeMockBinary, writeMockOpenStub } from './fs.js';

/** Filename the mock IDE binary writes the chat prompt to. */
export const CHAT_PROMPT_FILE = '.e2e-chat-prompt';

/** Filename the mock IDE binary writes the onboarding invocation JSON to. */
export const ONBOARDING_INVOCATION_FILE = '.e2e-onboarding-invocation';

function writeMockBinary(dir: string, name: string, script: string): void {
const filePath = join(dir, name);
writeFileSync(filePath, script, 'utf-8');
chmodSync(filePath, 0o755);
}

/**
* Creates a directory of executable mock IDE binaries (`claude`, `cursor`,
* `codex`, `open`) that the wizard will find on `PATH` during e2e tests.
Expand All @@ -34,7 +29,8 @@ export function createMockBinDir(dir: string): string {
writeMockBinary(binDir, 'claude', CLAUDE_SCRIPT);
writeMockBinary(binDir, 'cursor', CURSOR_SCRIPT);
writeMockBinary(binDir, 'codex', CODEX_SCRIPT);
writeMockBinary(binDir, 'open', '#!/bin/sh\nexit 0\n');

writeMockOpenStub(binDir);

return binDir;
}
4 changes: 2 additions & 2 deletions __tests__/e2e/testing-framework/session-factory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdtempSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { TerminalSession } from './terminal/index.js';
import { createProjectDir, type ProjectType } from '../../shared/project-scaffold/index.js';

Expand Down Expand Up @@ -56,7 +56,7 @@ export function createSession({
const { path: projectDir } = createProjectDir(project);

const sessionEnv: Record<string, string> = {
PATH: `${mockBinDir}:${systemPath ?? process.env.PATH}`,
PATH: `${mockBinDir}${delimiter}${systemPath ?? process.env.PATH}`,
...env,
};

Expand Down
12 changes: 7 additions & 5 deletions __tests__/e2e/testing-framework/terminal/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { join } from 'node:path';
import { stripAnsi } from './strip-ansi.js';
import { renderScreen, normalizeSnapshot } from './screen-buffer.js';
import { E2E_BASE_ENV } from '../env.js';
import { overlayEnv } from '../../../shared/overlay-env.js';
import { isWindows } from '../../../shared/platform.js';
import { resolveKey, type Modifiers } from '../../../shared/key-map.js';

const CLI_PATH = resolve(import.meta.dirname, '../../../../dist/bin/cli.js');
Expand Down Expand Up @@ -81,13 +83,13 @@ export class TerminalSession {
cols,
rows,
cwd: this.cwd,
env: {
...process.env,
...E2E_BASE_ENV,
...env,
env: overlayEnv(process.env, E2E_BASE_ENV, env, {
HOME: isolatedTmpDir,
TMPDIR: isolatedTmpDir,
},
...(isWindows
? { USERPROFILE: isolatedTmpDir, TEMP: isolatedTmpDir, TMP: isolatedTmpDir }
: {}),
}),
});

this.pty.onData((data) => {
Expand Down
20 changes: 2 additions & 18 deletions __tests__/integrations/prepare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,8 @@ import { vi } from 'vitest';

const execFile = vi.fn();

vi.mock('node:child_process', () => ({
execFile: (
_cmd: string,
_args: string[],
cb: (err: Error | null, stdout?: string, stderr?: string) => void,
) => {
const result = execFile(_cmd, _args) as Promise<{ stdout: string }>;
result.then(
(val) => cb(null, val.stdout, ''),
(err: Error) => cb(err),
);
return {};
},
}));

vi.mock('node:util', () => ({
promisify: () => (cmd: string, args: string[]) =>
execFile(cmd, args) as Promise<{ stdout: string }>,
vi.mock('@lib/exec.js', () => ({
execFile: (cmd: string, args: string[]) => execFile(cmd, args) as Promise<{ stdout: string }>,
}));

beforeEach(() => {
Expand Down
41 changes: 41 additions & 0 deletions __tests__/lib/resolve-bin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { join } from 'node:path';
import { resolveBin } from '@lib/resolve-bin.js';

describe('resolveBin', () => {
it('returns the command unchanged on Unix even when a .js shim exists', () => {
const sut = resolveBin('claude', ['--version'], {
platform: 'darwin',
pathEnv: '/mock/bin',
exists: () => true,
});

expect(sut).toEqual({ command: 'claude', args: ['--version'] });
});

it('runs a PATH .js shim with node on Windows', () => {
const binDir = '/mock/bin';
const jsPath = join(binDir, 'claude.js');

const sut = resolveBin('claude', ['--version'], {
platform: 'win32',
pathEnv: binDir,
execPath: '/nodejs/node.exe',
exists: (path) => path === jsPath,
});

expect(sut).toEqual({
command: '/nodejs/node.exe',
args: [jsPath, '--version'],
});
});

it('returns the command unchanged on Windows when no .js shim exists', () => {
const sut = resolveBin('claude', ['--version'], {
platform: 'win32',
pathEnv: join('/Windows', 'System32'),
exists: () => false,
});

expect(sut).toEqual({ command: 'claude', args: ['--version'] });
});
});
26 changes: 26 additions & 0 deletions __tests__/shared/overlay-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { overlayEnv } from './overlay-env.js';

describe('overlayEnv', () => {
it('replaces Windows Path with PATH so mock bins win', () => {
const sut = overlayEnv(
{ Path: 'C:\\Windows', HOME: '/old' },
{ PATH: 'C:\\mock\\bin', HOME: '/tmp/e2e' },
);

expect(sut.PATH).toBe('C:\\mock\\bin');
expect(sut.Path).toBeUndefined();
expect(sut.HOME).toBe('/tmp/e2e');
});

it('replaces Temp and Tmp when TEMP and TMP are set', () => {
const sut = overlayEnv(
{ Temp: 'C:\\Windows\\Temp', Tmp: 'C:\\Windows\\Temp' },
{ TEMP: 'D:\\e2e', TMP: 'D:\\e2e' },
);

expect(sut.TEMP).toBe('D:\\e2e');
expect(sut.TMP).toBe('D:\\e2e');
expect(sut.Temp).toBeUndefined();
expect(sut.Tmp).toBeUndefined();
});
});
29 changes: 29 additions & 0 deletions __tests__/shared/overlay-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Merges environment layers, replacing keys that differ only by case.
*
* Spreading `process.env` on Windows leaves `Path` / `Temp` alongside later
* `PATH` / `TEMP` entries. Child processes can then keep the host values.
*/
export function overlayEnv(
...layers: Array<NodeJS.Dict<string | undefined>>
): Record<string, string> {
const result: Record<string, string> = {};
const keyByLower = new Map<string, string>();

for (const layer of layers) {
for (const [key, value] of Object.entries(layer)) {
if (value === undefined) continue;

const lower = key.toLowerCase();
const previousKey = keyByLower.get(lower);
if (previousKey !== undefined && previousKey !== key) {
delete result[previousKey];
}

result[key] = value;
keyByLower.set(lower, key);
}
}

return result;
}
5 changes: 5 additions & 0 deletions __tests__/shared/platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const isWindows = process.platform === 'win32';

export function perPlatform<T>(options: { windows: T; unix: T }): T {
return isWindows ? options.windows : options.unix;
}
23 changes: 18 additions & 5 deletions __tests__/shared/project-scaffold/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { perPlatform } from '../platform.js';
import { SCAFFOLDS } from './scaffolds.js';
import type { ProjectType } from './types.js';

Expand All @@ -9,10 +12,15 @@ export type { ProjectType };
* project scaffold. Supports `Symbol.dispose` for automatic cleanup.
*
* @remarks
* Uses a hardcoded `/tmp/` prefix instead of `os.tmpdir()`. On macOS
* `tmpdir()` returns `/var/folders/…` which is longer than Linux's `/tmp/`,
* shifting column alignment in the VT100 screen buffer and breaking e2e
* snapshot assertions across platforms.
* Uses `/tmp/` on Unix instead of `os.tmpdir()`. On macOS `tmpdir()`
* returns `/var/folders/…` which is longer than Linux's `/tmp/`, shifting
* column alignment in the VT100 screen buffer and breaking e2e snapshot
* assertions across platforms. On Windows, `/tmp/` doesn't exist so we
* fall back to `os.tmpdir()`.
*
* @todo The Windows `tmpdir()` path is longer than `/tmp/`, producing
* different VT100 column alignment and incompatible e2e snapshots. When
* Windows CI is added, use a fixed-length prefix on all platforms.
*
* @param type - A named scaffold, or `null` for an empty directory.
* @defaultValue `'react'`
Expand All @@ -26,7 +34,12 @@ export type { ProjectType };
* ```
*/
export function createProjectDir(type: ProjectType = 'react') {
const dir = mkdtempSync('/tmp/wizard-test-');
const prefix = perPlatform({
windows: join(tmpdir(), 'wizard-test-'),
unix: '/tmp/wizard-test-',
});

const dir = mkdtempSync(prefix);
SCAFFOLDS[type](dir);

return {
Expand Down
2 changes: 1 addition & 1 deletion src/integrations/claude/chat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn } from 'node:child_process';
import { spawn } from '@lib/exec.js';
import type { ChatOpts } from '../types.js';

export function launchChat({ prompt, cwd }: ChatOpts): void {
Expand Down
5 changes: 1 addition & 4 deletions src/integrations/claude/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { execFile } from '@lib/exec.js';
import type { McpConnectOpts } from '../types.js';
import {
type McpServerName,
Expand All @@ -11,8 +10,6 @@ import {
import { getRegisteredMcpNames, getStoredAuthToken } from '../mcp/config.js';
import { projectConfigPath } from './paths.js';

const execFile = promisify(execFileCb);

export function detectMcpStatuses(
projectDir: string,
): Promise<Record<McpServerName, McpServerStatus>> {
Expand Down
3 changes: 2 additions & 1 deletion src/integrations/claude/onboarding.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type ChildProcess, spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { createInterface } from 'node:readline';
import { spawn } from '@lib/exec.js';
import type { OnboardingOpts, OnboardingCallbacks } from '../types.js';
import { ONBOARDING_TIMEOUT_MS } from '../constants.js';
import { type StreamEvent, extractTextLines } from '../stream-json.js';
Expand Down
5 changes: 1 addition & 4 deletions src/integrations/claude/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { execFile as execFileCb } from 'node:child_process';
import { resolve } from 'node:path';
import { promisify } from 'node:util';
import { execFile } from '@lib/exec.js';
import { PLUGIN_NAME } from '@lib/constants.js';
import type { PluginInstallationMethod } from '@shared-kernel/types.js';
import { hasDownloadedSkills } from '../skills/local.js';
import { skillsDir } from './paths.js';

const execFile = promisify(execFileCb);

type PluginEntry = {
id: string;
enabled: boolean;
Expand Down
5 changes: 1 addition & 4 deletions src/integrations/claude/prepare.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { execFile } from '@lib/exec.js';
import { extractVersion, isAtLeast } from '../version.js';

const execFile = promisify(execFileCb);

const MIN_VERSION = [2, 1, 212];

export async function prepare(): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion src/integrations/codex/chat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn } from 'node:child_process';
import { spawn } from '@lib/exec.js';
import type { ChatOpts } from '../types.js';

export function launchChat({ prompt, cwd, token }: ChatOpts): void {
Expand Down
Loading
Loading