diff --git a/package-lock.json b/package-lock.json index f81b0c7..8fbad2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "chalk": "^5.4.1", "commander": "^13.1.0", + "cross-spawn": "^7.0.6", "glob": "^11.0.1", "ink": "^7.0.3", "posthog-node": "^5.21.2", @@ -26,6 +27,7 @@ "mex": "dist/cli.js" }, "devDependencies": { + "@types/cross-spawn": "^6.0.6", "@types/node": "^22.0.0", "@types/react": "^19.2.14", "ink-testing-library": "^4.0.0", @@ -925,6 +927,16 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", diff --git a/package.json b/package.json index abe7b4e..d6520f2 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "dependencies": { "chalk": "^5.4.1", "commander": "^13.1.0", + "cross-spawn": "^7.0.6", "glob": "^11.0.1", "ink": "^7.0.3", "posthog-node": "^5.21.2", @@ -66,6 +67,7 @@ "yaml": "^2.7.0" }, "devDependencies": { + "@types/cross-spawn": "^6.0.6", "@types/node": "^22.0.0", "@types/react": "^19.2.14", "ink-testing-library": "^4.0.0", diff --git a/src/cli-tools.ts b/src/cli-tools.ts new file mode 100644 index 0000000..fb2dfb4 --- /dev/null +++ b/src/cli-tools.ts @@ -0,0 +1,20 @@ +import { execSync } from "node:child_process"; + +/** + * Whether a CLI command is installed and on PATH — cross-platform. + * + * Windows has no `which`; the equivalent is the built-in `where`. Using `which` + * everywhere made detection always throw on Windows, so `mex sync`/`mex setup` + * concluded no AI CLI was installed and silently dropped to copy-paste prompts + * even when Claude/Codex were present (see issue #85). Only the bare command + * name is passed here, so there are no quoting concerns. + */ +export function isCliAvailable(cmd: string): boolean { + const probe = process.platform === "win32" ? "where" : "which"; + try { + execSync(`${probe} ${cmd}`, { stdio: "ignore" }); + return true; + } catch { + return false; + } +} diff --git a/src/setup/index.ts b/src/setup/index.ts index 2b89397..adaadaa 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -2,7 +2,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from import { resolve, dirname, relative, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createInterface } from "node:readline/promises"; -import { execSync, spawn } from "node:child_process"; +import { execSync } from "node:child_process"; +import crossSpawn from "cross-spawn"; import { stdin, stdout } from "node:process"; import { globSync } from "glob"; import chalk from "chalk"; @@ -12,6 +13,7 @@ import { buildExistingNoBriefPrompt, } from "./prompts.js"; import { saveAiTools, ensureScaffoldIdentity } from "../config.js"; +import { isCliAvailable } from "../cli-tools.js"; import type { AiTool } from "../types.js"; // ── Constants ── @@ -263,10 +265,18 @@ export async function runSetup(opts: { dryRun?: boolean; mode?: string } = {}): info("You'll see the agent working in real-time."); console.log(); - await launchClaude(prompt); - - console.log(); - ok("Setup complete."); + try { + await launchClaude(prompt); + console.log(); + ok("Setup complete."); + } catch (err) { + // A launch/exit failure must not crash setup with an unhandled + // rejection — report it and fall back to the manual-paste prompt. + console.log(); + warn(`Couldn't run Claude Code automatically: ${(err as Error).message}`); + info("Paste the prompt below into your AI tool to populate the scaffold instead."); + printPromptForManualPaste(prompt); + } await promptGlobalInstall(); return; } else { @@ -284,14 +294,7 @@ export async function runSetup(opts: { dryRun?: boolean; mode?: string } = {}): info("The agent will read your codebase and fill every scaffold file."); } - console.log(); - console.log("─────────────────── COPY BELOW THIS LINE ───────────────────"); - console.log(); - console.log(prompt); - console.log(); - console.log("─────────────────── COPY ABOVE THIS LINE ───────────────────"); - console.log(); - ok("Paste the prompt above into your agent to populate the scaffold."); + printPromptForManualPaste(prompt); } await promptGlobalInstall(); @@ -432,20 +435,27 @@ async function selectToolConfig( return selectedClaude; } +function printPromptForManualPaste(prompt: string): void { + console.log(); + console.log("─────────────────── COPY BELOW THIS LINE ───────────────────"); + console.log(); + console.log(prompt); + console.log(); + console.log("─────────────────── COPY ABOVE THIS LINE ───────────────────"); + console.log(); + ok("Paste the prompt above into your agent to populate the scaffold."); +} + function hasClaudeCli(): boolean { - try { - execSync("which claude", { stdio: "ignore" }); - return true; - } catch { - return false; - } + return isCliAvailable("claude"); } function launchClaude(prompt: string): Promise { return new Promise((resolve, reject) => { - const child = spawn("claude", [prompt], { + // cross-spawn resolves the Windows `claude.cmd` wrapper and escapes the + // prompt correctly. Plain spawn threw ENOENT on Windows (issue #85). + const child = crossSpawn("claude", [prompt], { stdio: "inherit", - shell: false, }); child.on("close", (code) => { diff --git a/src/sync/index.ts b/src/sync/index.ts index f30afef..400549b 100644 --- a/src/sync/index.ts +++ b/src/sync/index.ts @@ -1,9 +1,10 @@ import chalk from "chalk"; -import { spawnSync, execSync } from "node:child_process"; +import crossSpawn from "cross-spawn"; import { createInterface } from "node:readline"; import type { MexConfig, SyncTarget, DriftIssue, AiTool } from "../types.js"; import { AI_TOOLS } from "../types.js"; import { runDriftCheck } from "../drift/index.js"; +import { isCliAvailable } from "../cli-tools.js"; import { buildSyncBrief, buildCombinedBrief } from "./brief-builder.js"; function askUser(question: string): Promise { @@ -16,26 +17,23 @@ function askUser(question: string): Promise { }); } -function hasCliTool(cmd: string): boolean { - try { - execSync(`which ${cmd}`, { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - -function runToolInteractive(tool: AiTool, brief: string, cwd: string): boolean { +export function runToolInteractive(tool: AiTool, brief: string, cwd: string): boolean { const meta = AI_TOOLS[tool]; if (!meta.cli) return false; const args = [...meta.promptFlag, brief]; - const result = spawnSync(meta.cli, args, { + // cross-spawn resolves Windows `.cmd`/`.bat` wrappers (npm installs `claude` + // as `claude.cmd`) and escapes args correctly — plain spawnSync throws ENOENT + // on Windows, and `shell: true` mangles the multi-line prompt (issue #85). + const result = crossSpawn.sync(meta.cli, args, { cwd, stdio: "inherit", timeout: 300_000, }); - return result.status === 0 || result.status === null; + // A spawn failure (ENOENT, etc.) sets `error` and leaves `status` null — don't + // mistake that for success, or launch problems get silently swallowed. + if (result.error) return false; + return result.status === 0; } /** Pick which AI tool to use for interactive sync */ @@ -43,14 +41,14 @@ async function pickSyncTool(configuredTools: AiTool[]): Promise { // Filter to tools that have a CLI and are installed let available = configuredTools.filter((t) => { const meta = AI_TOOLS[t]; - return meta.cli && hasCliTool(meta.cli); + return meta.cli && isCliAvailable(meta.cli); }); // If no configured tools matched, scan for any installed CLI and ask user if (available.length === 0) { const detected = (Object.keys(AI_TOOLS) as AiTool[]).filter((t) => { const meta = AI_TOOLS[t]; - return meta.cli && hasCliTool(meta.cli); + return meta.cli && isCliAvailable(meta.cli); }); if (detected.length === 0) return null; diff --git a/test/cli-tools.test.ts b/test/cli-tools.test.ts new file mode 100644 index 0000000..c7888c8 --- /dev/null +++ b/test/cli-tools.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { isCliAvailable } from "../src/cli-tools.js"; + +describe("isCliAvailable", () => { + it("detects a command that is on PATH", () => { + // `node` is necessarily present — the test runner is running under it. + expect(isCliAvailable("node")).toBe(true); + }); + + it("returns false for a command that does not exist", () => { + expect(isCliAvailable("definitely-not-a-real-cli-xyz123")).toBe(false); + }); + + it("does not throw on a bogus command (swallows the probe error)", () => { + expect(() => isCliAvailable("nope-nope-nope")).not.toThrow(); + }); +}); diff --git a/test/sync.test.ts b/test/sync.test.ts new file mode 100644 index 0000000..89f0252 --- /dev/null +++ b/test/sync.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock cross-spawn so we can drive the exact SpawnSyncReturns shapes that +// `runToolInteractive` must map to a boolean, without launching anything. +vi.mock("cross-spawn", () => ({ + default: { sync: vi.fn() }, +})); + +import crossSpawn from "cross-spawn"; +import { runToolInteractive } from "../src/sync/index.js"; + +const mockSync = crossSpawn.sync as unknown as ReturnType; + +describe("runToolInteractive return-value logic", () => { + beforeEach(() => { + mockSync.mockReset(); + }); + + it("treats a clean exit (status 0) as success", () => { + mockSync.mockReturnValue({ status: 0 }); + expect(runToolInteractive("claude", "brief", process.cwd())).toBe(true); + }); + + it("treats a non-zero exit (status 1) as failure", () => { + mockSync.mockReturnValue({ status: 1 }); + expect(runToolInteractive("claude", "brief", process.cwd())).toBe(false); + }); + + it("treats a spawn error / timeout (error set, status null) as failure", () => { + mockSync.mockReturnValue({ error: new Error("spawn ENOENT"), status: null }); + expect(runToolInteractive("claude", "brief", process.cwd())).toBe(false); + }); + + it("treats a signal kill (status null, no error) as failure", () => { + mockSync.mockReturnValue({ status: null, signal: "SIGINT" }); + expect(runToolInteractive("claude", "brief", process.cwd())).toBe(false); + }); + + it("returns false without spawning for a tool that has no CLI", () => { + // `cursor` is IDE-only (cli: null) — must short-circuit before spawning. + expect(runToolInteractive("cursor", "brief", process.cwd())).toBe(false); + expect(mockSync).not.toHaveBeenCalled(); + }); +});