diff --git a/packages/app/src/shell/bootstrap.ts b/packages/app/src/shell/bootstrap.ts index ae75e60..9079873 100644 --- a/packages/app/src/shell/bootstrap.ts +++ b/packages/app/src/shell/bootstrap.ts @@ -330,18 +330,15 @@ const runCommand = ( ): Effect.Effect, Error> => Effect.try({ try: () => { - const result = spawnSync(command, [...args], { + const resolvedCommand = resolveCommandExecutable(command) + const result = spawnSync(resolvedCommand, [...args], { cwd, encoding: "utf8", stdio: "pipe", }) - if (failOnNonZero && result.status !== 0) { - throw new Error( - result.stderr.trim() || - result.stdout.trim() || - `Command failed: ${command} ${args.join(" ")}`, - ) + if (failOnNonZero && (result.status !== 0 || result.error)) { + throw new Error(formatCommandFailure(result, command, args)) } return result @@ -352,7 +349,7 @@ const runCommand = ( const commandExists = (command: string): Effect.Effect => Effect.try({ try: () => { - const result = spawnSync(command, ["--help"], { + const result = spawnSync(resolveCommandExecutable(command), ["--help"], { cwd: process.cwd(), encoding: "utf8", stdio: "ignore", @@ -471,6 +468,21 @@ const isNodeError = (error: unknown): error is NodeJS.ErrnoException => const toError = (cause: unknown): Error => cause instanceof Error ? cause : new Error(String(cause)) +const trimSpawnOutput = (value: string | Buffer | null | undefined): string => { + if (typeof value === "string") { + return value.trim() + } + + if (Buffer.isBuffer(value)) { + return value.toString("utf8").trim() + } + + return "" +} + +const trimSpawnError = (error: Error | undefined): string => + typeof error?.message === "string" ? error.message.trim() : "" + const formatClaimError = (status: number, errorCode: string | null): string => { if (status === 410 || errorCode === "TokenExpired") { return "Pairing token expired. Create a new project in the SpawnDock bot and rerun bootstrap." @@ -501,6 +513,29 @@ const isKnownClaimErrorCode = (errorCode: string | null): boolean => errorCode === "TokenNotFound" || errorCode === "project_not_found" +const WINDOWS_CMD_SHIMS = new Set(["codex", "corepack", "npm", "npx", "pnpm"]) + +export const resolveCommandExecutable = ( + command: string, + platform = process.platform, +): string => { + if (platform !== "win32") { + return command + } + + return WINDOWS_CMD_SHIMS.has(command.toLowerCase()) ? `${command}.cmd` : command +} + +export const formatCommandFailure = ( + result: SpawnSyncReturns, + command: string, + args: ReadonlyArray, +): string => + trimSpawnOutput(result.stderr) || + trimSpawnOutput(result.stdout) || + trimSpawnError(result.error) || + `Command failed: ${command} ${args.join(" ")}` + const copyOverlayTreeSync = (sourceDir: string, targetDir: string): void => { const entries = readdirSync(sourceDir, { withFileTypes: true }) diff --git a/packages/app/tests/bootstrap-command.test.ts b/packages/app/tests/bootstrap-command.test.ts new file mode 100644 index 0000000..b29e279 --- /dev/null +++ b/packages/app/tests/bootstrap-command.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest" +import type { SpawnSyncReturns } from "node:child_process" +import { formatCommandFailure, resolveCommandExecutable } from "../src/shell/bootstrap.js" + +const buildResult = ( + overrides: Partial> = {}, +): SpawnSyncReturns => + ({ + pid: 0, + output: [], + stdout: "", + stderr: "", + status: 0, + signal: null, + ...overrides, + }) as SpawnSyncReturns + +describe("bootstrap shell command helpers", () => { + it("uses .cmd shims for Windows package manager commands", () => { + expect(resolveCommandExecutable("pnpm", "win32")).toBe("pnpm.cmd") + expect(resolveCommandExecutable("corepack", "win32")).toBe("corepack.cmd") + expect(resolveCommandExecutable("git", "win32")).toBe("git") + expect(resolveCommandExecutable("pnpm", "linux")).toBe("pnpm") + }) + + it("formats spawn errors even when stdout and stderr are missing", () => { + const result = buildResult({ + status: null, + stdout: undefined as unknown as string, + stderr: undefined as unknown as string, + error: new Error("spawn pnpm ENOENT"), + }) + + expect(formatCommandFailure(result, "pnpm", ["install"])).toBe("spawn pnpm ENOENT") + }) + + it("prefers stderr and stdout output before generic command failures", () => { + expect( + formatCommandFailure( + buildResult({ status: 1, stderr: " install failed \n", stdout: "ignored" }), + "pnpm", + ["install"], + ), + ).toBe("install failed") + + expect( + formatCommandFailure( + buildResult({ status: 1, stderr: "", stdout: " fallback output \n" }), + "pnpm", + ["install"], + ), + ).toBe("fallback output") + }) +})