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/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 9defccf..496a872 100644 --- a/__tests__/e2e/system-check-failure.e2e.ts +++ b/__tests__/e2e/system-check-failure.e2e.ts @@ -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 () => { @@ -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'); 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..14f7f0f --- /dev/null +++ b/__tests__/e2e/testing-framework/mocks/binaries/fs.ts @@ -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'); 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; } 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, }; diff --git a/__tests__/e2e/testing-framework/terminal/session.ts b/__tests__/e2e/testing-framework/terminal/session.ts index 50d12fa..60caea6 100644 --- a/__tests__/e2e/testing-framework/terminal/session.ts +++ b/__tests__/e2e/testing-framework/terminal/session.ts @@ -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'); @@ -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) => { 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/__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 { 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; +} 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: {