From 046fe86fca5ffacb6cd90c6138e60d14381955c8 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 10 Sep 2026 15:40:15 +0200 Subject: [PATCH 1/8] fix: handle CLAUDE.md symlink on Windows Add CLAUDE.md to .prettierignore so Prettier skips the symlink target, and enable core.symlinks in the prepare script so Windows clones checkout the symlink correctly after pnpm install. Co-Authored-By: Claude Opus 4.6 (1M context) --- .prettierignore | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index 32405c6..5380627 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,4 @@ pnpm-lock.yaml dist/ node_modules/ CHANGELOG.md +CLAUDE.md diff --git a/package.json b/package.json index 7525d4a..2e26108 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "typecheck": "tsc --noEmit", "qa": "pnpm typecheck && pnpm lint && pnpm test", "clear": "./scripts/clean-dev-env.sh", - "prepare": "husky" + "prepare": "git config --local core.symlinks true && husky" }, "dependencies": { "@inkjs/ui": "^2.0.0", From b15013d594eeb003b750afe8fd69e6ecc37cdedf Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 10 Sep 2026 15:42:15 +0200 Subject: [PATCH 2/8] fix: use fileURLToPath in vitest configs for Windows compatibility URL.pathname returns /C:/Users/... on Windows, breaking path alias resolution. fileURLToPath() handles this correctly cross-platform. Co-Authored-By: Claude Opus 4.6 (1M context) --- vitest.config.e2e.ts | 13 +++++++------ vitest.config.ts | 17 +++++++++-------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts index 0481f0b..393a2b6 100644 --- a/vitest.config.e2e.ts +++ b/vitest.config.e2e.ts @@ -1,14 +1,15 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; export default defineConfig({ resolve: { alias: { - '@commands': new URL('./src/commands', import.meta.url).pathname, - '@frameworks': new URL('./src/frameworks', import.meta.url).pathname, - '@integrations': new URL('./src/integrations', import.meta.url).pathname, - '@providers': new URL('./src/providers', import.meta.url).pathname, - '@ui': new URL('./src/ui', import.meta.url).pathname, - '@lib': new URL('./src/lib', import.meta.url).pathname, + '@commands': fileURLToPath(new URL('./src/commands', import.meta.url)), + '@frameworks': fileURLToPath(new URL('./src/frameworks', import.meta.url)), + '@integrations': fileURLToPath(new URL('./src/integrations', import.meta.url)), + '@providers': fileURLToPath(new URL('./src/providers', import.meta.url)), + '@ui': fileURLToPath(new URL('./src/ui', import.meta.url)), + '@lib': fileURLToPath(new URL('./src/lib', import.meta.url)), }, }, test: { diff --git a/vitest.config.ts b/vitest.config.ts index 7369bcf..338847d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; const isCI = !!process.env.CI; @@ -5,14 +6,14 @@ const isCI = !!process.env.CI; export default defineConfig({ resolve: { alias: { - '@commands': new URL('./src/commands', import.meta.url).pathname, - '@features': new URL('./src/features', import.meta.url).pathname, - '@frameworks': new URL('./src/frameworks', import.meta.url).pathname, - '@integrations': new URL('./src/integrations', import.meta.url).pathname, - '@providers': new URL('./src/providers', import.meta.url).pathname, - '@shared-kernel': new URL('./src/shared-kernel', import.meta.url).pathname, - '@ui': new URL('./src/ui', import.meta.url).pathname, - '@lib': new URL('./src/lib', import.meta.url).pathname, + '@commands': fileURLToPath(new URL('./src/commands', import.meta.url)), + '@features': fileURLToPath(new URL('./src/features', import.meta.url)), + '@frameworks': fileURLToPath(new URL('./src/frameworks', import.meta.url)), + '@integrations': fileURLToPath(new URL('./src/integrations', import.meta.url)), + '@providers': fileURLToPath(new URL('./src/providers', import.meta.url)), + '@shared-kernel': fileURLToPath(new URL('./src/shared-kernel', import.meta.url)), + '@ui': fileURLToPath(new URL('./src/ui', import.meta.url)), + '@lib': fileURLToPath(new URL('./src/lib', import.meta.url)), }, }, test: { From bb5e6d2a1b1c7c9df3c111b93f26d1b856985b72 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 10 Sep 2026 16:02:26 +0200 Subject: [PATCH 3/8] fix: use os.tmpdir() on Windows for test project directories /tmp/ doesn't exist on Windows. Use perPlatform() to pick os.tmpdir() on Windows while keeping /tmp/ on Unix for stable e2e snapshot column alignment. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/shared/platform.ts | 5 +++++ __tests__/shared/project-scaffold/index.ts | 23 +++++++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 __tests__/shared/platform.ts diff --git a/__tests__/shared/platform.ts b/__tests__/shared/platform.ts new file mode 100644 index 0000000..7dbb967 --- /dev/null +++ b/__tests__/shared/platform.ts @@ -0,0 +1,5 @@ +export const isWindows = process.platform === 'win32'; + +export function perPlatform(options: { windows: T; unix: T }): T { + return isWindows ? options.windows : options.unix; +} diff --git a/__tests__/shared/project-scaffold/index.ts b/__tests__/shared/project-scaffold/index.ts index a170981..4bed870 100644 --- a/__tests__/shared/project-scaffold/index.ts +++ b/__tests__/shared/project-scaffold/index.ts @@ -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'; @@ -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'` @@ -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 { From a4934c95c19f5810ed3540e1a97e8a8d49319817 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 10 Sep 2026 16:11:42 +0200 Subject: [PATCH 4/8] fix: make e2e mock binaries work on Windows On Windows, scripts need a .cmd launcher instead of a Unix shebang and chmod. Split binary writing into writeUnixBinary/writeWindowsBinary in a new fs.ts module and add IS_WINDOWS to the e2e env constants. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/e2e/testing-framework/env.ts | 3 +++ .../testing-framework/mocks/binaries/fs.ts | 21 +++++++++++++++++++ .../testing-framework/mocks/binaries/index.ts | 12 ++++------- 3 files changed, 28 insertions(+), 8 deletions(-) create mode 100644 __tests__/e2e/testing-framework/mocks/binaries/fs.ts diff --git a/__tests__/e2e/testing-framework/env.ts b/__tests__/e2e/testing-framework/env.ts index 4055bff..3678749 100644 --- a/__tests__/e2e/testing-framework/env.ts +++ b/__tests__/e2e/testing-framework/env.ts @@ -1,5 +1,8 @@ export { AUTH_CALLBACK_PORT } from '@lib/auth.js'; +/** `true` when the test suite is running on Windows. */ +export const IS_WINDOWS = process.platform === 'win32'; + /** * Baseline environment variables injected into every e2e terminal session. * diff --git a/__tests__/e2e/testing-framework/mocks/binaries/fs.ts b/__tests__/e2e/testing-framework/mocks/binaries/fs.ts new file mode 100644 index 0000000..86340dc --- /dev/null +++ b/__tests__/e2e/testing-framework/mocks/binaries/fs.ts @@ -0,0 +1,21 @@ +import { writeFileSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; +import { IS_WINDOWS } from '../../env.js'; + +export const writeMockBinary = IS_WINDOWS ? 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'); + writeFileSync(join(dir, `${name}.cmd`), `@node "%~dp0${name}.js" %*\r\n`, 'utf-8'); +} + +export const writeMockOpenStub = IS_WINDOWS + ? (dir: string) => writeFileSync(join(dir, 'open.cmd'), '@exit /b 0\r\n', 'utf-8') + : (dir: string) => writeUnixBinary(dir, 'open', '#!/bin/sh\nexit 0\n'); diff --git a/__tests__/e2e/testing-framework/mocks/binaries/index.ts b/__tests__/e2e/testing-framework/mocks/binaries/index.ts index 0a6a082..34be236 100644 --- a/__tests__/e2e/testing-framework/mocks/binaries/index.ts +++ b/__tests__/e2e/testing-framework/mocks/binaries/index.ts @@ -1,8 +1,9 @@ -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'; @@ -10,12 +11,6 @@ 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. @@ -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; } From dabf0057381739398d028f94184252865a8118dc Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 10 Sep 2026 16:17:18 +0200 Subject: [PATCH 5/8] fix: use path.delimiter for PATH separator in e2e sessions Windows uses ; not : as the PATH separator. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/e2e/testing-framework/session-factory.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__tests__/e2e/testing-framework/session-factory.ts b/__tests__/e2e/testing-framework/session-factory.ts index d2c706f..e91af91 100644 --- a/__tests__/e2e/testing-framework/session-factory.ts +++ b/__tests__/e2e/testing-framework/session-factory.ts @@ -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'; @@ -56,7 +56,7 @@ export function createSession({ const { path: projectDir } = createProjectDir(project); const sessionEnv: Record = { - PATH: `${mockBinDir}:${systemPath ?? process.env.PATH}`, + PATH: `${mockBinDir}${delimiter}${systemPath ?? process.env.PATH}`, ...env, }; From a651a07116454a8b6b04e613048e6ce038aea8e2 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 10 Sep 2026 16:20:49 +0200 Subject: [PATCH 6/8] fix: set platform-correct home and temp env vars in e2e sessions Windows uses USERPROFILE/TEMP/TMP instead of HOME/TMPDIR. Set the Windows equivalents alongside the Unix vars so both are available. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/e2e/testing-framework/terminal/session.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/__tests__/e2e/testing-framework/terminal/session.ts b/__tests__/e2e/testing-framework/terminal/session.ts index 50d12fa..30dea01 100644 --- a/__tests__/e2e/testing-framework/terminal/session.ts +++ b/__tests__/e2e/testing-framework/terminal/session.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; 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 { E2E_BASE_ENV, IS_WINDOWS } from '../env.js'; import { resolveKey, type Modifiers } from '../../../shared/key-map.js'; const CLI_PATH = resolve(import.meta.dirname, '../../../../dist/bin/cli.js'); @@ -85,8 +85,15 @@ export class TerminalSession { ...process.env, ...E2E_BASE_ENV, ...env, + HOME: isolatedTmpDir, TMPDIR: isolatedTmpDir, + + ...(IS_WINDOWS && { + USERPROFILE: isolatedTmpDir, + TEMP: isolatedTmpDir, + TMP: isolatedTmpDir, + }), }, }); From 3f27bbee53d67b42fa4ebab8beb9b3438847a5e8 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Thu, 10 Sep 2026 16:22:32 +0200 Subject: [PATCH 7/8] fix: skip /usr/bin system-check e2e test on Windows The test relies on /usr/bin as a PATH with git but not node, which has no portable Windows equivalent. Other system-check tests already validate the failure UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- __tests__/e2e/system-check-failure.e2e.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/__tests__/e2e/system-check-failure.e2e.ts b/__tests__/e2e/system-check-failure.e2e.ts index 9defccf..be58b24 100644 --- a/__tests__/e2e/system-check-failure.e2e.ts +++ b/__tests__/e2e/system-check-failure.e2e.ts @@ -1,4 +1,5 @@ import { createSession } from './testing-framework/index.js'; +import { IS_WINDOWS } from './testing-framework/env.js'; import { dirname } from 'node:path'; describe('when system check fails', () => { @@ -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(IS_WINDOWS)('shows error when node is not on PATH', async () => { using session = createSession({ systemPath: '/usr/bin' }); await session.waitForText('Start setup'); From f77153615a07ce35db8879d7bbe3a1c651e359e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Myll=C3=A4ri?= Date: Wed, 23 Sep 2026 11:34:21 +0200 Subject: [PATCH 8/8] fix: make Windows e2e env and IDE CLI spawning reliable Deduplicate case-insensitive env keys, run PATH .js shims with Node instead of .cmd files, and stop mutating git config in prepare. Co-authored-by: Cursor --- README.md | 8 ++++ __tests__/e2e/system-check-failure.e2e.ts | 6 +-- __tests__/e2e/testing-framework/env.ts | 3 -- .../testing-framework/mocks/binaries/fs.ts | 8 ++-- .../e2e/testing-framework/terminal/session.ts | 21 ++++----- __tests__/integrations/prepare.test.ts | 20 +------- __tests__/lib/resolve-bin.test.ts | 41 +++++++++++++++++ __tests__/shared/overlay-env.test.ts | 26 +++++++++++ __tests__/shared/overlay-env.ts | 29 ++++++++++++ package.json | 2 +- src/integrations/claude/chat.ts | 2 +- src/integrations/claude/mcp.ts | 5 +- src/integrations/claude/onboarding.ts | 3 +- src/integrations/claude/plugins.ts | 5 +- src/integrations/claude/prepare.ts | 5 +- src/integrations/codex/chat.ts | 2 +- src/integrations/codex/mcp.ts | 5 +- src/integrations/codex/onboarding.ts | 3 +- src/integrations/codex/plugins.ts | 5 +- src/integrations/codex/prepare.ts | 5 +- src/integrations/cursor/chat.ts | 2 +- src/integrations/cursor/mcp.ts | 5 +- src/integrations/cursor/onboarding.ts | 3 +- src/integrations/cursor/plugins.ts | 5 +- src/integrations/cursor/prepare.ts | 5 +- src/lib/exec.ts | 43 +++++++++++++++++ src/lib/resolve-bin.ts | 46 +++++++++++++++++++ 27 files changed, 230 insertions(+), 83 deletions(-) create mode 100644 __tests__/lib/resolve-bin.test.ts create mode 100644 __tests__/shared/overlay-env.test.ts create mode 100644 __tests__/shared/overlay-env.ts create mode 100644 src/lib/exec.ts create mode 100644 src/lib/resolve-bin.ts diff --git a/README.md b/README.md index 93cb8ac..d66ccb7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/__tests__/e2e/system-check-failure.e2e.ts b/__tests__/e2e/system-check-failure.e2e.ts index be58b24..496a872 100644 --- a/__tests__/e2e/system-check-failure.e2e.ts +++ b/__tests__/e2e/system-check-failure.e2e.ts @@ -1,6 +1,6 @@ -import { createSession } from './testing-framework/index.js'; -import { IS_WINDOWS } from './testing-framework/env.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 () => { @@ -25,7 +25,7 @@ describe('when system check fails', () => { * equivalent. Constructing one portably is non-trivial because git's * install location varies across Windows setups. */ - it.skipIf(IS_WINDOWS)('shows error when node is not on PATH', async () => { + it.skipIf(isWindows)('shows error when node is not on PATH', async () => { using session = createSession({ systemPath: '/usr/bin' }); await session.waitForText('Start setup'); diff --git a/__tests__/e2e/testing-framework/env.ts b/__tests__/e2e/testing-framework/env.ts index 3678749..4055bff 100644 --- a/__tests__/e2e/testing-framework/env.ts +++ b/__tests__/e2e/testing-framework/env.ts @@ -1,8 +1,5 @@ export { AUTH_CALLBACK_PORT } from '@lib/auth.js'; -/** `true` when the test suite is running on Windows. */ -export const IS_WINDOWS = process.platform === 'win32'; - /** * Baseline environment variables injected into every e2e terminal session. * diff --git a/__tests__/e2e/testing-framework/mocks/binaries/fs.ts b/__tests__/e2e/testing-framework/mocks/binaries/fs.ts index 86340dc..14f7f0f 100644 --- a/__tests__/e2e/testing-framework/mocks/binaries/fs.ts +++ b/__tests__/e2e/testing-framework/mocks/binaries/fs.ts @@ -1,8 +1,8 @@ import { writeFileSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; -import { IS_WINDOWS } from '../../env.js'; +import { isWindows } from '../../../../shared/platform.js'; -export const writeMockBinary = IS_WINDOWS ? writeWindowsBinary : writeUnixBinary; +export const writeMockBinary = isWindows ? writeWindowsBinary : writeUnixBinary; function writeUnixBinary(dir: string, name: string, script: string): void { const filePath = join(dir, name); @@ -13,9 +13,11 @@ function writeUnixBinary(dir: string, name: string, script: string): void { 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 = IS_WINDOWS +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'); diff --git a/__tests__/e2e/testing-framework/terminal/session.ts b/__tests__/e2e/testing-framework/terminal/session.ts index 30dea01..60caea6 100644 --- a/__tests__/e2e/testing-framework/terminal/session.ts +++ b/__tests__/e2e/testing-framework/terminal/session.ts @@ -5,7 +5,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { stripAnsi } from './strip-ansi.js'; import { renderScreen, normalizeSnapshot } from './screen-buffer.js'; -import { E2E_BASE_ENV, IS_WINDOWS } from '../env.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'); @@ -81,20 +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, - - ...(IS_WINDOWS && { - USERPROFILE: isolatedTmpDir, - TEMP: isolatedTmpDir, - TMP: isolatedTmpDir, - }), - }, + ...(isWindows + ? { USERPROFILE: isolatedTmpDir, TEMP: isolatedTmpDir, TMP: isolatedTmpDir } + : {}), + }), }); this.pty.onData((data) => { diff --git a/__tests__/integrations/prepare.test.ts b/__tests__/integrations/prepare.test.ts index df6300a..eafda0e 100644 --- a/__tests__/integrations/prepare.test.ts +++ b/__tests__/integrations/prepare.test.ts @@ -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(() => { diff --git a/__tests__/lib/resolve-bin.test.ts b/__tests__/lib/resolve-bin.test.ts new file mode 100644 index 0000000..f10fb43 --- /dev/null +++ b/__tests__/lib/resolve-bin.test.ts @@ -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'] }); + }); +}); diff --git a/__tests__/shared/overlay-env.test.ts b/__tests__/shared/overlay-env.test.ts new file mode 100644 index 0000000..a82c3a1 --- /dev/null +++ b/__tests__/shared/overlay-env.test.ts @@ -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(); + }); +}); diff --git a/__tests__/shared/overlay-env.ts b/__tests__/shared/overlay-env.ts new file mode 100644 index 0000000..1aa2568 --- /dev/null +++ b/__tests__/shared/overlay-env.ts @@ -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> +): Record { + const result: Record = {}; + const keyByLower = new Map(); + + 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; +} diff --git a/package.json b/package.json index 2e26108..7525d4a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "typecheck": "tsc --noEmit", "qa": "pnpm typecheck && pnpm lint && pnpm test", "clear": "./scripts/clean-dev-env.sh", - "prepare": "git config --local core.symlinks true && husky" + "prepare": "husky" }, "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/src/integrations/claude/chat.ts b/src/integrations/claude/chat.ts index d7103ed..060bf25 100644 --- a/src/integrations/claude/chat.ts +++ b/src/integrations/claude/chat.ts @@ -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 { diff --git a/src/integrations/claude/mcp.ts b/src/integrations/claude/mcp.ts index 4d25f2d..3c7e474 100644 --- a/src/integrations/claude/mcp.ts +++ b/src/integrations/claude/mcp.ts @@ -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, @@ -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> { diff --git a/src/integrations/claude/onboarding.ts b/src/integrations/claude/onboarding.ts index 6878ca5..82cbe0b 100644 --- a/src/integrations/claude/onboarding.ts +++ b/src/integrations/claude/onboarding.ts @@ -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'; diff --git a/src/integrations/claude/plugins.ts b/src/integrations/claude/plugins.ts index f182bb9..4ea09d1 100644 --- a/src/integrations/claude/plugins.ts +++ b/src/integrations/claude/plugins.ts @@ -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; diff --git a/src/integrations/claude/prepare.ts b/src/integrations/claude/prepare.ts index 48acecf..1a60609 100644 --- a/src/integrations/claude/prepare.ts +++ b/src/integrations/claude/prepare.ts @@ -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 { diff --git a/src/integrations/codex/chat.ts b/src/integrations/codex/chat.ts index f3298de..3013e9c 100644 --- a/src/integrations/codex/chat.ts +++ b/src/integrations/codex/chat.ts @@ -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 { diff --git a/src/integrations/codex/mcp.ts b/src/integrations/codex/mcp.ts index 0b8471c..1be8d9f 100644 --- a/src/integrations/codex/mcp.ts +++ b/src/integrations/codex/mcp.ts @@ -1,6 +1,5 @@ import { readFileSync, writeFileSync } from 'node:fs'; -import { execFile as execFileCb } from 'node:child_process'; -import { promisify } from 'node:util'; +import { execFile } from '@lib/exec.js'; import type { McpConnectOpts } from '../types.js'; import { MCP_SERVERS, @@ -10,8 +9,6 @@ import { } from '../mcp/servers.js'; import { globalConfigPath, projectConfigPath } from './paths.js'; -const execFile = promisify(execFileCb); - export function detectMcpStatuses( projectDir: string, ): Promise> { diff --git a/src/integrations/codex/onboarding.ts b/src/integrations/codex/onboarding.ts index 77c108f..dc7bd25 100644 --- a/src/integrations/codex/onboarding.ts +++ b/src/integrations/codex/onboarding.ts @@ -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 { diff --git a/src/integrations/codex/plugins.ts b/src/integrations/codex/plugins.ts index e684be8..eca8855 100644 --- a/src/integrations/codex/plugins.ts +++ b/src/integrations/codex/plugins.ts @@ -1,12 +1,9 @@ -import { execFile as execFileCb } from 'node:child_process'; -import { promisify } from 'node:util'; +import { execFile } from '@lib/exec.js'; import { PLUGIN_MARKETPLACE_REPO, PLUGIN_MARKETPLACE_NAME, 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); - export async function detectPlugin(projectDir: string): Promise { try { const cwd = projectDir; diff --git a/src/integrations/codex/prepare.ts b/src/integrations/codex/prepare.ts index 0277d64..de0b862 100644 --- a/src/integrations/codex/prepare.ts +++ b/src/integrations/codex/prepare.ts @@ -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 = [0, 146, 0]; export async function prepare(): Promise { diff --git a/src/integrations/cursor/chat.ts b/src/integrations/cursor/chat.ts index c5802ed..d0f7251 100644 --- a/src/integrations/cursor/chat.ts +++ b/src/integrations/cursor/chat.ts @@ -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 { diff --git a/src/integrations/cursor/mcp.ts b/src/integrations/cursor/mcp.ts index eb2e995..a9a5b40 100644 --- a/src/integrations/cursor/mcp.ts +++ b/src/integrations/cursor/mcp.ts @@ -1,7 +1,6 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { execFile as execFileCb } from 'node:child_process'; import { join } from 'node:path'; -import { promisify } from 'node:util'; +import { execFile } from '@lib/exec.js'; import type { McpConnectOpts } from '../types.js'; import { type McpServerName, @@ -11,8 +10,6 @@ import { import { getRegisteredMcpNames, getStoredAuthToken } from '../mcp/config.js'; import { cliConfigPath, globalConfigPath, mcpConfigPath } from './paths.js'; -const execFile = promisify(execFileCb); - export function detectMcpStatuses( projectDir: string, ): Promise> { diff --git a/src/integrations/cursor/onboarding.ts b/src/integrations/cursor/onboarding.ts index df56c47..a2b283f 100644 --- a/src/integrations/cursor/onboarding.ts +++ b/src/integrations/cursor/onboarding.ts @@ -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'; diff --git a/src/integrations/cursor/plugins.ts b/src/integrations/cursor/plugins.ts index c695fca..d738287 100644 --- a/src/integrations/cursor/plugins.ts +++ b/src/integrations/cursor/plugins.ts @@ -1,12 +1,9 @@ -import { execFile as execFileCb } from 'node:child_process'; -import { promisify } from 'node:util'; +import { execFile } from '@lib/exec.js'; import { PLUGIN_REPO_URL } 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); - export async function detectPlugin(projectDir: string): Promise { return hasDownloadedSkills(skillsDir(projectDir)) ? 'download' : null; } diff --git a/src/integrations/cursor/prepare.ts b/src/integrations/cursor/prepare.ts index 50ef171..217bcbc 100644 --- a/src/integrations/cursor/prepare.ts +++ b/src/integrations/cursor/prepare.ts @@ -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 = [3, 19, 7]; export async function prepare(): Promise { diff --git a/src/lib/exec.ts b/src/lib/exec.ts new file mode 100644 index 0000000..f9c868d --- /dev/null +++ b/src/lib/exec.ts @@ -0,0 +1,43 @@ +import { + execFile as cpExecFile, + spawn as cpSpawn, + type ChildProcess, + type ExecFileOptions, + type SpawnOptions, +} from 'node:child_process'; +import { promisify } from 'node:util'; +import { pathFromEnv, resolveBin } from './resolve-bin.js'; + +const promisifiedExecFile = promisify(cpExecFile); + +/** + * `spawn` that runs Windows `.js` PATH shims with Node instead of `.cmd` files. + * + * @see {@link resolveBin} + */ +export function spawn( + command: string, + args: readonly string[], + options?: SpawnOptions, +): ChildProcess { + const resolved = resolveBin(command, args, { pathEnv: pathFromEnv(options?.env) }); + return cpSpawn(resolved.command, resolved.args, options ?? {}); +} + +/** + * `execFile` that runs Windows `.js` PATH shims with Node instead of `.cmd` files. + * + * @see {@link resolveBin} + */ +export async function execFile( + command: string, + args: readonly string[], + options?: ExecFileOptions, +): Promise<{ stdout: string; stderr: string }> { + const resolved = resolveBin(command, args, { pathEnv: pathFromEnv(options?.env) }); + const { stdout, stderr } = await promisifiedExecFile(resolved.command, resolved.args, { + ...options, + encoding: 'utf8', + }); + return { stdout, stderr }; +} diff --git a/src/lib/resolve-bin.ts b/src/lib/resolve-bin.ts new file mode 100644 index 0000000..a104759 --- /dev/null +++ b/src/lib/resolve-bin.ts @@ -0,0 +1,46 @@ +import { existsSync } from 'node:fs'; +import { delimiter, join } from 'node:path'; + +type ResolveBinOptions = { + /** Override `process.platform` (tests). @defaultValue `process.platform` */ + platform?: NodeJS.Platform; + /** PATH to search for a `.js` shim. @defaultValue `process.env.PATH` */ + pathEnv?: string; + /** Node executable used to run a `.js` shim. @defaultValue `process.execPath` */ + execPath?: string; + /** Override filesystem lookup (tests). @defaultValue `existsSync` */ + exists?: (path: string) => boolean; +}; + +/** + * Resolves a command so `execFile`/`spawn` can run it without a shell. + * + * On Windows, `CreateProcess` cannot execute `.cmd` shims. If `{command}.js` + * exists on PATH (the e2e mock layout), run it with Node instead so flags + * like `--version` are passed to the script rather than to Node itself. + */ +export function resolveBin( + command: string, + args: readonly string[], + options: ResolveBinOptions = {}, +): { command: string; args: string[] } { + const platform = options.platform ?? process.platform; + if (platform !== 'win32') return { command, args: [...args] }; + + const pathEnv = options.pathEnv ?? process.env.PATH ?? process.env.Path ?? ''; + const exists = options.exists ?? existsSync; + const execPath = options.execPath ?? process.execPath; + + for (const dir of pathEnv.split(delimiter)) { + if (!dir) continue; + const jsPath = join(dir, `${command}.js`); + if (exists(jsPath)) return { command: execPath, args: [jsPath, ...args] }; + } + + return { command, args: [...args] }; +} + +export function pathFromEnv(env?: NodeJS.ProcessEnv): string | undefined { + if (!env) return undefined; + return env.PATH ?? env.Path ?? env.path; +}