From 35d8655cd2ab1604d82720acd672ed92118c1fbe Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:18:12 +0000 Subject: [PATCH] fix: use shell execution for Windows commands --- packages/app/src/shell/bootstrap.ts | 44 +++++++------------ .../spawndock/command.d.mts | 7 +++ .../spawndock/command.mjs | 15 ++++--- .../template-nextjs-overlay/spawndock/mcp.mjs | 6 ++- .../spawndock/next.mjs | 6 ++- .../spawndock/publish.mjs | 25 ++++++++--- .../spawndock/tunnel.mjs | 6 ++- packages/app/tests/bootstrap-command.test.ts | 27 ++++++++++-- packages/app/tests/template-command.test.ts | 18 ++++++-- 9 files changed, 99 insertions(+), 55 deletions(-) diff --git a/packages/app/src/shell/bootstrap.ts b/packages/app/src/shell/bootstrap.ts index 452f3b7..43d9065 100644 --- a/packages/app/src/shell/bootstrap.ts +++ b/packages/app/src/shell/bootstrap.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" import { spawnSync, + type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns, } from "node:child_process" import { fileURLToPath } from "node:url" @@ -353,12 +354,7 @@ const runCommand = ( ): Effect.Effect, Error> => Effect.try({ try: () => { - const resolvedCommand = resolveCommandExecutable(command) - const result = spawnSync(resolvedCommand, [...args], { - cwd, - encoding: "utf8", - stdio: "pipe", - }) + const result = spawnSync(command, [...args], createSpawnOptions(cwd, "pipe")) if (failOnNonZero && (result.status !== 0 || result.error)) { throw new Error(formatCommandFailure(result, command, args)) @@ -372,21 +368,8 @@ const runCommand = ( const commandExists = (command: string): Effect.Effect => Effect.try({ try: () => { - const result = spawnSync(resolveCommandExecutable(command), ["--help"], { - cwd: process.cwd(), - encoding: "utf8", - stdio: "ignore", - }) - - if (result.error) { - if (isNodeError(result.error) && result.error.code === "ENOENT") { - return false - } - - throw toError(result.error) - } - - return true + const result = spawnSync(command, ["--version"], createSpawnOptions(process.cwd(), "ignore")) + return result.status === 0 }, catch: toError, }) @@ -545,18 +528,21 @@ function resolveTemplateOverlayDir(): string { return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] } -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 - } +): string => platform === "win32" ? command : command - return WINDOWS_CMD_SHIMS.has(command.toLowerCase()) ? `${command}.cmd` : command -} +export const createSpawnOptions = ( + cwd: string, + stdio: SpawnSyncOptionsWithStringEncoding["stdio"], + platform = process.platform, +): SpawnSyncOptionsWithStringEncoding => ({ + cwd, + encoding: "utf8", + stdio, + ...(platform === "win32" ? { shell: true, windowsHide: true } : {}), +}) export const formatCommandFailure = ( result: SpawnSyncReturns, diff --git a/packages/app/template-nextjs-overlay/spawndock/command.d.mts b/packages/app/template-nextjs-overlay/spawndock/command.d.mts index aa0ee7e..38ad3c3 100644 --- a/packages/app/template-nextjs-overlay/spawndock/command.d.mts +++ b/packages/app/template-nextjs-overlay/spawndock/command.d.mts @@ -1,2 +1,9 @@ export function resolveCommand(command: string, platform?: NodeJS.Platform): string +export function resolveSpawnOptions( + command: string, + platform?: NodeJS.Platform, +): { + shell?: boolean + windowsHide?: boolean +} 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 index dea7161..ca2d49e 100644 --- a/packages/app/template-nextjs-overlay/spawndock/command.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/command.mjs @@ -1,15 +1,16 @@ -const WINDOWS_COMMAND_OVERRIDES = { - gh: "gh.exe", - git: "git.exe", - pnpm: "pnpm.cmd", +export function resolveCommand(command, platform = process.platform) { + return platform === "win32" ? command : command } -export function resolveCommand(command, platform = process.platform) { +export function resolveSpawnOptions(command, platform = process.platform) { if (platform !== "win32") { - return command + return {} } - return WINDOWS_COMMAND_OVERRIDES[command] ?? command + return { + shell: true, + windowsHide: true, + } } export function trimOutput(value) { diff --git a/packages/app/template-nextjs-overlay/spawndock/mcp.mjs b/packages/app/template-nextjs-overlay/spawndock/mcp.mjs index bd199e9..951f6ac 100644 --- a/packages/app/template-nextjs-overlay/spawndock/mcp.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/mcp.mjs @@ -1,13 +1,14 @@ import { spawn } from "node:child_process" -import { resolveCommand } from "./command.mjs" +import { resolveCommand, resolveSpawnOptions } 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(resolveCommand("pnpm"), ["exec", "spawn-dock-mcp"], { +const pnpmCommand = resolveCommand("pnpm") +const child = spawn(pnpmCommand, ["exec", "spawn-dock-mcp"], { cwd: process.cwd(), env: { ...process.env, @@ -15,6 +16,7 @@ const child = spawn(resolveCommand("pnpm"), ["exec", "spawn-dock-mcp"], { MCP_SERVER_API_KEY: mcpServerApiKey, }, stdio: "inherit", + ...resolveSpawnOptions(pnpmCommand), }) child.on("exit", (code) => { diff --git a/packages/app/template-nextjs-overlay/spawndock/next.mjs b/packages/app/template-nextjs-overlay/spawndock/next.mjs index 0acb213..e92e1a4 100644 --- a/packages/app/template-nextjs-overlay/spawndock/next.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/next.mjs @@ -6,7 +6,7 @@ import { resolveAllowedDevOrigins, resolveConfiguredLocalPort, } from "./config.mjs" -import { resolveCommand } from "./command.mjs" +import { resolveCommand, resolveSpawnOptions } from "./command.mjs" import { findAvailablePort } from "./port.mjs" const config = readSpawndockConfig() @@ -23,7 +23,8 @@ if (localPort !== requestedLocalPort) { ) } -const child = spawn(resolveCommand("pnpm"), ["exec", "next", "dev", "-p", String(localPort)], { +const pnpmCommand = resolveCommand("pnpm") +const child = spawn(pnpmCommand, ["exec", "next", "dev", "-p", String(localPort)], { cwd: process.cwd(), env: { ...process.env, @@ -33,6 +34,7 @@ const child = spawn(resolveCommand("pnpm"), ["exec", "next", "dev", "-p", String SPAWNDOCK_SERVER_ACTIONS_ALLOWED_ORIGINS: config.previewHost ?? "", }, stdio: ["inherit", "pipe", "pipe"], + ...resolveSpawnOptions(pnpmCommand), }) const exitWithChild = (code) => { diff --git a/packages/app/template-nextjs-overlay/spawndock/publish.mjs b/packages/app/template-nextjs-overlay/spawndock/publish.mjs index 2fb5d94..e3df67b 100644 --- a/packages/app/template-nextjs-overlay/spawndock/publish.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/publish.mjs @@ -2,7 +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 { resolveCommand, resolveSpawnOptions, trimOutput } from "./command.mjs" import { readSpawndockConfig } from "./config.mjs" const cwd = process.cwd() @@ -65,7 +65,12 @@ 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(resolveCommand("git"), ["worktree", "remove", tempDir, "--force"], { cwd, stdio: "ignore" }) + const gitCommand = resolveCommand("git") + spawnSync(gitCommand, ["worktree", "remove", tempDir, "--force"], { + cwd, + stdio: "ignore", + ...resolveSpawnOptions(gitCommand), + }) rmSync(tempDir, { recursive: true, force: true }) } } @@ -99,20 +104,24 @@ function enablePages(repoFullName) { } function remoteBranchExists(branch) { - const result = spawnSync(resolveCommand("git"), ["ls-remote", "--heads", "origin", branch], { + const gitCommand = resolveCommand("git") + const result = spawnSync(gitCommand, ["ls-remote", "--heads", "origin", branch], { cwd, encoding: "utf8", stdio: "pipe", + ...resolveSpawnOptions(gitCommand), }) return result.status === 0 && trimOutput(result.stdout).length > 0 } function getOriginUrl() { - const result = spawnSync(resolveCommand("git"), ["remote", "get-url", "origin"], { + const gitCommand = resolveCommand("git") + const result = spawnSync(gitCommand, ["remote", "get-url", "origin"], { cwd, encoding: "utf8", stdio: "pipe", + ...resolveSpawnOptions(gitCommand), }) return result.status === 0 ? trimOutput(result.stdout) : null @@ -126,10 +135,12 @@ function clearDirectory(dir) { } function readGh(...args) { - const result = spawnSync(resolveCommand("gh"), args, { + const ghCommand = resolveCommand("gh") + const result = spawnSync(ghCommand, args, { cwd, encoding: "utf8", stdio: "pipe", + ...resolveSpawnOptions(ghCommand), }) if (result.status !== 0) { @@ -146,11 +157,13 @@ function run(command, args, env = process.env, allowEmptyCommit = false) { ? [...args, "--allow-empty"] : args - const result = spawnSync(resolveCommand(command), finalArgs, { + const resolvedCommand = resolveCommand(command) + const result = spawnSync(resolvedCommand, finalArgs, { cwd, env, encoding: "utf8", stdio: "inherit", + ...resolveSpawnOptions(resolvedCommand), }) if (result.status !== 0) { diff --git a/packages/app/template-nextjs-overlay/spawndock/tunnel.mjs b/packages/app/template-nextjs-overlay/spawndock/tunnel.mjs index 7c5eff4..a2865c6 100644 --- a/packages/app/template-nextjs-overlay/spawndock/tunnel.mjs +++ b/packages/app/template-nextjs-overlay/spawndock/tunnel.mjs @@ -1,10 +1,12 @@ import { spawn } from "node:child_process" -import { resolveCommand } from "./command.mjs" +import { resolveCommand, resolveSpawnOptions } from "./command.mjs" -const child = spawn(resolveCommand("pnpm"), ["exec", "spawn-dock-tunnel"], { +const pnpmCommand = resolveCommand("pnpm") +const child = spawn(pnpmCommand, ["exec", "spawn-dock-tunnel"], { cwd: process.cwd(), env: process.env, stdio: "inherit", + ...resolveSpawnOptions(pnpmCommand), }) child.on("exit", (code) => { diff --git a/packages/app/tests/bootstrap-command.test.ts b/packages/app/tests/bootstrap-command.test.ts index b29e279..2a133a4 100644 --- a/packages/app/tests/bootstrap-command.test.ts +++ b/packages/app/tests/bootstrap-command.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest" import type { SpawnSyncReturns } from "node:child_process" -import { formatCommandFailure, resolveCommandExecutable } from "../src/shell/bootstrap.js" +import { + createSpawnOptions, + formatCommandFailure, + resolveCommandExecutable, +} from "../src/shell/bootstrap.js" const buildResult = ( overrides: Partial> = {}, @@ -16,13 +20,28 @@ const buildResult = ( }) 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") + it("keeps command names unchanged and relies on shell execution on Windows", () => { + expect(resolveCommandExecutable("pnpm", "win32")).toBe("pnpm") + expect(resolveCommandExecutable("corepack", "win32")).toBe("corepack") expect(resolveCommandExecutable("git", "win32")).toBe("git") expect(resolveCommandExecutable("pnpm", "linux")).toBe("pnpm") }) + it("enables shell execution on Windows spawn options", () => { + expect(createSpawnOptions("/tmp/demo", "pipe", "win32")).toEqual({ + cwd: "/tmp/demo", + encoding: "utf8", + stdio: "pipe", + shell: true, + windowsHide: true, + }) + expect(createSpawnOptions("/tmp/demo", "ignore", "linux")).toEqual({ + cwd: "/tmp/demo", + encoding: "utf8", + stdio: "ignore", + }) + }) + it("formats spawn errors even when stdout and stderr are missing", () => { const result = buildResult({ status: null, diff --git a/packages/app/tests/template-command.test.ts b/packages/app/tests/template-command.test.ts index b643c19..e853db1 100644 --- a/packages/app/tests/template-command.test.ts +++ b/packages/app/tests/template-command.test.ts @@ -1,16 +1,28 @@ import { describe, expect, it } from "vitest" -import { resolveCommand, trimOutput } from "../template-nextjs-overlay/spawndock/command.mjs" +import { + resolveCommand, + resolveSpawnOptions, + 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 the command name unchanged on Windows", () => { + expect(resolveCommand("pnpm", "win32")).toBe("pnpm") }) it("keeps other platforms unchanged", () => { expect(resolveCommand("pnpm", "linux")).toBe("pnpm") }) + it("uses a shell for Windows spawns", () => { + expect(resolveSpawnOptions("pnpm", "win32")).toEqual({ + shell: true, + windowsHide: true, + }) + expect(resolveSpawnOptions("pnpm", "linux")).toEqual({}) + }) + it("returns an empty string for missing output", () => { expect(trimOutput(undefined)).toBe("") })