From ccef0397d82038769e55247800eca0cfa077fcfb Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:50:45 +0000 Subject: [PATCH] fix: harden bootstrap and windows command resolution --- packages/app/src/shell/bootstrap.ts | 115 +++++++++++++++--- .../spawndock/command.d.mts | 2 + .../spawndock/command.mjs | 17 +++ .../template-nextjs-overlay/spawndock/mcp.mjs | 3 +- .../spawndock/next.mjs | 3 +- .../spawndock/publish.mjs | 27 ++-- .../spawndock/tunnel.mjs | 3 +- packages/app/tests/bootstrap-install.test.ts | 96 +++++++++++++++ packages/app/tests/template-command.test.ts | 17 +++ 9 files changed, 251 insertions(+), 32 deletions(-) create mode 100644 packages/app/template-nextjs-overlay/spawndock/command.d.mts create mode 100644 packages/app/template-nextjs-overlay/spawndock/command.mjs create mode 100644 packages/app/tests/bootstrap-install.test.ts create mode 100644 packages/app/tests/template-command.test.ts diff --git a/packages/app/src/shell/bootstrap.ts b/packages/app/src/shell/bootstrap.ts index ae75e60..45f758a 100644 --- a/packages/app/src/shell/bootstrap.ts +++ b/packages/app/src/shell/bootstrap.ts @@ -1,5 +1,9 @@ -import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" -import { spawnSync, type SpawnSyncReturns } from "node:child_process" +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" +import { + spawnSync, + type SpawnSyncOptionsWithStringEncoding, + type SpawnSyncReturns, +} from "node:child_process" import { fileURLToPath } from "node:url" import { dirname, join, resolve } from "node:path" import { Console, Effect } from "effect" @@ -9,6 +13,7 @@ import { buildMcpServerUrl, DEFAULT_INSPECT_PATH, DEFAULT_MCP_AGENTS, + GENERATED_PACKAGE_MANAGER, resolveClaimPath, type BootstrapClaim, type BootstrapSummary, @@ -18,9 +23,7 @@ import { resolveProjectContext, } from "../core/bootstrap.js" -const TEMPLATE_OVERLAY_DIR = resolve( - fileURLToPath(new URL("../../../template-nextjs-overlay", import.meta.url)), -) +const TEMPLATE_OVERLAY_DIR = resolveTemplateOverlayDir() interface BootstrapPreflightTarget { readonly projectDir: string @@ -280,8 +283,29 @@ const installDependencies = (projectDir: string): Effect.Effect => return } - yield* runCommand("pnpm", ["install"], projectDir) - yield* Console.log("Dependencies installed with pnpm.") + const pnpmResult = yield* runCommand("pnpm", ["install"], projectDir, false) + if (pnpmResult.status === 0) { + yield* Console.log("Dependencies installed with pnpm.") + return + } + + const npxResult = yield* runCommand("npx", ["-y", GENERATED_PACKAGE_MANAGER, "install"], projectDir, false) + if (npxResult.status === 0) { + yield* Console.log("Dependencies installed with pnpm.") + return + } + + yield* Effect.fail( + new Error( + [ + "Failed to install project dependencies with pnpm.", + `corepack: ${formatCommandFailure("corepack", ["pnpm", "install"], corepackResult)}`, + `pnpm: ${formatCommandFailure("pnpm", ["install"], pnpmResult)}`, + `npx fallback: ${formatCommandFailure("npx", ["-y", GENERATED_PACKAGE_MANAGER, "install"], npxResult)}`, + "Install Node.js 20+ with Corepack enabled, or install pnpm globally, then rerun the bootstrap command.", + ].join("\n"), + ), + ) }) const registerAgentIntegrations = ( @@ -330,18 +354,14 @@ const runCommand = ( ): Effect.Effect, Error> => Effect.try({ try: () => { - const result = spawnSync(command, [...args], { + const result = spawnCommand(command, [...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.error || result.status !== 0)) { + throw new Error(formatCommandFailure(command, args, result)) } return result @@ -352,7 +372,7 @@ const runCommand = ( const commandExists = (command: string): Effect.Effect => Effect.try({ try: () => { - const result = spawnSync(command, ["--help"], { + const result = spawnCommand(command, ["--help"], { cwd: process.cwd(), encoding: "utf8", stdio: "ignore", @@ -468,6 +488,9 @@ const isRecord = (value: unknown): value is Record => const isNodeError = (error: unknown): error is NodeJS.ErrnoException => error instanceof Error +const isMissingCommandError = (error: unknown): boolean => + isNodeError(error) && error.code === "ENOENT" + const toError = (cause: unknown): Error => cause instanceof Error ? cause : new Error(String(cause)) @@ -501,6 +524,68 @@ const isKnownClaimErrorCode = (errorCode: string | null): boolean => errorCode === "TokenNotFound" || errorCode === "project_not_found" +function resolveTemplateOverlayDir(): string { + const candidates = [ + fileURLToPath(new URL("../../template-nextjs-overlay", import.meta.url)), + fileURLToPath(new URL("../../../template-nextjs-overlay", import.meta.url)), + ] as const + + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] +} + +const spawnCommand = ( + command: string, + args: ReadonlyArray, + options: SpawnSyncOptionsWithStringEncoding, +): SpawnSyncReturns => { + const candidates = resolveCommandCandidates(command) + let lastResult: SpawnSyncReturns | null = null + + for (const candidate of candidates) { + const result = spawnSync(candidate, [...args], options) + + if (!isMissingCommandError(result.error)) { + return result + } + + lastResult = result + } + + return lastResult ?? spawnSync(command, [...args], options) +} + +const resolveCommandCandidates = (command: string): ReadonlyArray => { + if (process.platform !== "win32" || /\.[^\\/]+$/.test(command)) { + return [command] + } + + return [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`] +} + +const formatCommandFailure = ( + command: string, + args: ReadonlyArray, + result: SpawnSyncReturns, +): string => + readSpawnText(result.stderr) || + readSpawnText(result.stdout) || + (result.error ? result.error.message : "") || + (typeof result.status === "number" ? `exit code ${result.status}` : "") || + (result.signal ? `terminated by signal ${result.signal}` : "") || + `Command failed: ${command} ${args.join(" ")}` + +const readSpawnText = (value: string | Buffer | null | undefined): string => { + if (typeof value === "string") { + return value.trim() + } + + if (value instanceof Buffer) { + return value.toString("utf8").trim() + } + + return "" +} + const copyOverlayTreeSync = (sourceDir: string, targetDir: string): void => { const entries = readdirSync(sourceDir, { withFileTypes: true }) diff --git a/packages/app/template-nextjs-overlay/spawndock/command.d.mts b/packages/app/template-nextjs-overlay/spawndock/command.d.mts new file mode 100644 index 0000000..aa0ee7e --- /dev/null +++ b/packages/app/template-nextjs-overlay/spawndock/command.d.mts @@ -0,0 +1,2 @@ +export function resolveCommand(command: string, platform?: NodeJS.Platform): string +export function trimOutput(value: string | null | undefined): string diff --git a/packages/app/template-nextjs-overlay/spawndock/command.mjs b/packages/app/template-nextjs-overlay/spawndock/command.mjs new file mode 100644 index 0000000..dea7161 --- /dev/null +++ b/packages/app/template-nextjs-overlay/spawndock/command.mjs @@ -0,0 +1,17 @@ +const WINDOWS_COMMAND_OVERRIDES = { + gh: "gh.exe", + git: "git.exe", + pnpm: "pnpm.cmd", +} + +export function resolveCommand(command, platform = process.platform) { + if (platform !== "win32") { + return command + } + + return WINDOWS_COMMAND_OVERRIDES[command] ?? command +} + +export function trimOutput(value) { + return typeof value === "string" ? value.trim() : "" +} diff --git a/packages/app/template-nextjs-overlay/spawndock/mcp.mjs b/packages/app/template-nextjs-overlay/spawndock/mcp.mjs index 6627471..bd199e9 100644 --- a/packages/app/template-nextjs-overlay/spawndock/mcp.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/mcp.mjs @@ -1,12 +1,13 @@ import { spawn } from "node:child_process" +import { resolveCommand } from "./command.mjs" import { readSpawndockConfig, resolveMcpApiKey, resolveMcpServerUrl } from "./config.mjs" const config = readSpawndockConfig() const mcpServerUrl = process.env.MCP_SERVER_URL ?? resolveMcpServerUrl(config) const mcpServerApiKey = process.env.MCP_SERVER_API_KEY ?? resolveMcpApiKey(config) -const child = spawn("pnpm", ["exec", "spawn-dock-mcp"], { +const child = spawn(resolveCommand("pnpm"), ["exec", "spawn-dock-mcp"], { cwd: process.cwd(), env: { ...process.env, diff --git a/packages/app/template-nextjs-overlay/spawndock/next.mjs b/packages/app/template-nextjs-overlay/spawndock/next.mjs index eac534e..0acb213 100644 --- a/packages/app/template-nextjs-overlay/spawndock/next.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/next.mjs @@ -6,6 +6,7 @@ import { resolveAllowedDevOrigins, resolveConfiguredLocalPort, } from "./config.mjs" +import { resolveCommand } from "./command.mjs" import { findAvailablePort } from "./port.mjs" const config = readSpawndockConfig() @@ -22,7 +23,7 @@ if (localPort !== requestedLocalPort) { ) } -const child = spawn("pnpm", ["exec", "next", "dev", "-p", String(localPort)], { +const child = spawn(resolveCommand("pnpm"), ["exec", "next", "dev", "-p", String(localPort)], { cwd: process.cwd(), env: { ...process.env, diff --git a/packages/app/template-nextjs-overlay/spawndock/publish.mjs b/packages/app/template-nextjs-overlay/spawndock/publish.mjs index 52d8cb0..2fb5d94 100644 --- a/packages/app/template-nextjs-overlay/spawndock/publish.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/publish.mjs @@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process" import { cpSync, existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { dirname, join, resolve } from "node:path" +import { resolveCommand, trimOutput } from "./command.mjs" import { readSpawndockConfig } from "./config.mjs" const cwd = process.cwd() @@ -9,8 +10,8 @@ const config = readSpawndockConfig(cwd) runBuild() -const owner = trim(readGh("api", "user", "--jq", ".login")) -const repoName = trim(config.projectSlug) +const owner = trimOutput(readGh("api", "user", "--jq", ".login")) +const repoName = trimOutput(config.projectSlug) const repoFullName = `${owner}/${repoName}` const remoteUrl = ensureRepository(repoFullName) deployToGhPagesBranch(remoteUrl) @@ -64,7 +65,7 @@ function deployToGhPagesBranch(remoteUrl) { run("git", ["-C", tempDir, "commit", "-m", "Deploy SpawnDock app to GitHub Pages"], undefined, true) run("git", ["-C", tempDir, "push", remoteUrl, "gh-pages", "--force"]) } finally { - spawnSync("git", ["worktree", "remove", tempDir, "--force"], { cwd, stdio: "ignore" }) + spawnSync(resolveCommand("git"), ["worktree", "remove", tempDir, "--force"], { cwd, stdio: "ignore" }) rmSync(tempDir, { recursive: true, force: true }) } } @@ -98,23 +99,23 @@ function enablePages(repoFullName) { } function remoteBranchExists(branch) { - const result = spawnSync("git", ["ls-remote", "--heads", "origin", branch], { + const result = spawnSync(resolveCommand("git"), ["ls-remote", "--heads", "origin", branch], { cwd, encoding: "utf8", stdio: "pipe", }) - return result.status === 0 && result.stdout.trim().length > 0 + return result.status === 0 && trimOutput(result.stdout).length > 0 } function getOriginUrl() { - const result = spawnSync("git", ["remote", "get-url", "origin"], { + const result = spawnSync(resolveCommand("git"), ["remote", "get-url", "origin"], { cwd, encoding: "utf8", stdio: "pipe", }) - return result.status === 0 ? trim(result.stdout) : null + return result.status === 0 ? trimOutput(result.stdout) : null } function clearDirectory(dir) { @@ -125,14 +126,16 @@ function clearDirectory(dir) { } function readGh(...args) { - const result = spawnSync("gh", args, { + const result = spawnSync(resolveCommand("gh"), args, { cwd, encoding: "utf8", stdio: "pipe", }) if (result.status !== 0) { - throw new Error(trim(result.stderr) || trim(result.stdout) || `gh ${args.join(" ")} failed`) + throw new Error( + trimOutput(result.stderr) || trimOutput(result.stdout) || `gh ${args.join(" ")} failed`, + ) } return result.stdout @@ -143,7 +146,7 @@ function run(command, args, env = process.env, allowEmptyCommit = false) { ? [...args, "--allow-empty"] : args - const result = spawnSync(command, finalArgs, { + const result = spawnSync(resolveCommand(command), finalArgs, { cwd, env, encoding: "utf8", @@ -154,7 +157,3 @@ function run(command, args, env = process.env, allowEmptyCommit = false) { throw new Error(`${command} ${finalArgs.join(" ")} failed`) } } - -function trim(value) { - return value.trim() -} diff --git a/packages/app/template-nextjs-overlay/spawndock/tunnel.mjs b/packages/app/template-nextjs-overlay/spawndock/tunnel.mjs index 776deb1..7c5eff4 100644 --- a/packages/app/template-nextjs-overlay/spawndock/tunnel.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/tunnel.mjs @@ -1,6 +1,7 @@ import { spawn } from "node:child_process" +import { resolveCommand } from "./command.mjs" -const child = spawn("pnpm", ["exec", "spawn-dock-tunnel"], { +const child = spawn(resolveCommand("pnpm"), ["exec", "spawn-dock-tunnel"], { cwd: process.cwd(), env: process.env, stdio: "inherit", diff --git a/packages/app/tests/bootstrap-install.test.ts b/packages/app/tests/bootstrap-install.test.ts new file mode 100644 index 0000000..e1de4aa --- /dev/null +++ b/packages/app/tests/bootstrap-install.test.ts @@ -0,0 +1,96 @@ +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { execFileSync } from "node:child_process" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { Effect } from "effect" +import { GENERATED_PACKAGE_MANAGER } from "../src/core/bootstrap.js" +import { bootstrapProject } from "../src/shell/bootstrap.js" + +const writeExecutable = (path: string, content: string): void => { + writeFileSync(path, content, "utf8") + chmodSync(path, 0o755) +} + +describe("bootstrapProject dependency install", () => { + const originalCwd = process.cwd() + const originalPath = process.env["PATH"] + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal("fetch", fetchMock) + }) + + afterEach(() => { + process.chdir(originalCwd) + process.env["PATH"] = originalPath + vi.unstubAllGlobals() + }) + + it("falls back to npx pnpm when pnpm is unavailable", async () => { + const workspaceDir = mkdtempSync(join(tmpdir(), "spawndock-create-install-")) + const templateDir = join(workspaceDir, "template") + const binDir = join(workspaceDir, "bin") + const npxLogPath = join(workspaceDir, "npx.log") + const gitPath = execFileSync("which", ["git"], { encoding: "utf8" }).trim() + + mkdirSync(templateDir, { recursive: true }) + mkdirSync(binDir, { recursive: true }) + + writeFileSync( + join(templateDir, "package.json"), + `${JSON.stringify({ name: "template-demo", version: "1.0.0", scripts: {} }, null, 2)}\n`, + "utf8", + ) + + execFileSync(gitPath, ["init", "-b", "master"], { cwd: templateDir, stdio: "ignore" }) + execFileSync(gitPath, ["add", "package.json"], { cwd: templateDir, stdio: "ignore" }) + execFileSync( + gitPath, + ["-c", "user.name=SpawnDock Test", "-c", "user.email=test@example.com", "commit", "-m", "init"], + { cwd: templateDir, stdio: "ignore" }, + ) + + writeExecutable(join(binDir, "git"), `#!/bin/sh\nexec "${gitPath}" "$@"\n`) + writeExecutable( + join(binDir, "npx"), + `#!/bin/sh\nprintf '%s\n' "$*" > "${npxLogPath}"\nexit 0\n`, + ) + + process.env["PATH"] = binDir + process.chdir(workspaceDir) + + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + projectId: "project_demo", + projectSlug: "demo-app", + controlPlaneUrl: "https://spawn-dock.w3voice.net", + previewOrigin: "https://spawn-dock.w3voice.net/preview/demo-app", + deviceSecret: "device_demo", + mcpApiKey: "mcp_demo", + localPort: 3000, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ) + + const summary = await Effect.runPromise( + bootstrapProject({ + token: "pair_demo", + projectDir: "demo-app", + controlPlaneUrl: "https://spawn-dock.w3voice.net", + claimPath: "/v1/bootstrap/claim", + templateRepo: templateDir, + templateBranch: "master", + }), + ) + + expect(summary.projectDir).toBe(join(workspaceDir, "demo-app")) + expect(readFileSync(npxLogPath, "utf8")).toContain(`-y ${GENERATED_PACKAGE_MANAGER} install`) + }) +}) diff --git a/packages/app/tests/template-command.test.ts b/packages/app/tests/template-command.test.ts new file mode 100644 index 0000000..b643c19 --- /dev/null +++ b/packages/app/tests/template-command.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest" + +import { resolveCommand, trimOutput } from "../template-nextjs-overlay/spawndock/command.mjs" + +describe("template command helpers", () => { + it("maps pnpm to pnpm.cmd on Windows", () => { + expect(resolveCommand("pnpm", "win32")).toBe("pnpm.cmd") + }) + + it("keeps other platforms unchanged", () => { + expect(resolveCommand("pnpm", "linux")).toBe("pnpm") + }) + + it("returns an empty string for missing output", () => { + expect(trimOutput(undefined)).toBe("") + }) +})