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
51 changes: 43 additions & 8 deletions packages/app/src/shell/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,18 +330,15 @@ const runCommand = (
): Effect.Effect<SpawnSyncReturns<string>, 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
Expand All @@ -352,7 +349,7 @@ const runCommand = (
const commandExists = (command: string): Effect.Effect<boolean, Error> =>
Effect.try({
try: () => {
const result = spawnSync(command, ["--help"], {
const result = spawnSync(resolveCommandExecutable(command), ["--help"], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "ignore",
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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<string>,
command: string,
args: ReadonlyArray<string>,
): 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 })

Expand Down
54 changes: 54 additions & 0 deletions packages/app/tests/bootstrap-command.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>> = {},
): SpawnSyncReturns<string> =>
({
pid: 0,
output: [],
stdout: "",
stderr: "",
status: 0,
signal: null,
...overrides,
}) as SpawnSyncReturns<string>

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")
})
})