Skip to content

Commit b94cebf

Browse files
authored
Merge pull request #27 from SpawnDock/fix/windows-shell-spawn
fix: use shell execution for Windows commands
2 parents 48efe72 + 35d8655 commit b94cebf

9 files changed

Lines changed: 99 additions & 55 deletions

File tree

packages/app/src/shell/bootstrap.ts

Lines changed: 15 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
22
import {
33
spawnSync,
4+
type SpawnSyncOptionsWithStringEncoding,
45
type SpawnSyncReturns,
56
} from "node:child_process"
67
import { fileURLToPath } from "node:url"
@@ -353,12 +354,7 @@ const runCommand = (
353354
): Effect.Effect<SpawnSyncReturns<string>, Error> =>
354355
Effect.try({
355356
try: () => {
356-
const resolvedCommand = resolveCommandExecutable(command)
357-
const result = spawnSync(resolvedCommand, [...args], {
358-
cwd,
359-
encoding: "utf8",
360-
stdio: "pipe",
361-
})
357+
const result = spawnSync(command, [...args], createSpawnOptions(cwd, "pipe"))
362358

363359
if (failOnNonZero && (result.status !== 0 || result.error)) {
364360
throw new Error(formatCommandFailure(result, command, args))
@@ -372,21 +368,8 @@ const runCommand = (
372368
const commandExists = (command: string): Effect.Effect<boolean, Error> =>
373369
Effect.try({
374370
try: () => {
375-
const result = spawnSync(resolveCommandExecutable(command), ["--help"], {
376-
cwd: process.cwd(),
377-
encoding: "utf8",
378-
stdio: "ignore",
379-
})
380-
381-
if (result.error) {
382-
if (isNodeError(result.error) && result.error.code === "ENOENT") {
383-
return false
384-
}
385-
386-
throw toError(result.error)
387-
}
388-
389-
return true
371+
const result = spawnSync(command, ["--version"], createSpawnOptions(process.cwd(), "ignore"))
372+
return result.status === 0
390373
},
391374
catch: toError,
392375
})
@@ -545,18 +528,21 @@ function resolveTemplateOverlayDir(): string {
545528
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]
546529
}
547530

548-
const WINDOWS_CMD_SHIMS = new Set(["codex", "corepack", "npm", "npx", "pnpm"])
549-
550531
export const resolveCommandExecutable = (
551532
command: string,
552533
platform = process.platform,
553-
): string => {
554-
if (platform !== "win32") {
555-
return command
556-
}
534+
): string => platform === "win32" ? command : command
557535

558-
return WINDOWS_CMD_SHIMS.has(command.toLowerCase()) ? `${command}.cmd` : command
559-
}
536+
export const createSpawnOptions = (
537+
cwd: string,
538+
stdio: SpawnSyncOptionsWithStringEncoding["stdio"],
539+
platform = process.platform,
540+
): SpawnSyncOptionsWithStringEncoding => ({
541+
cwd,
542+
encoding: "utf8",
543+
stdio,
544+
...(platform === "win32" ? { shell: true, windowsHide: true } : {}),
545+
})
560546

561547
export const formatCommandFailure = (
562548
result: SpawnSyncReturns<string>,
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
11
export function resolveCommand(command: string, platform?: NodeJS.Platform): string
2+
export function resolveSpawnOptions(
3+
command: string,
4+
platform?: NodeJS.Platform,
5+
): {
6+
shell?: boolean
7+
windowsHide?: boolean
8+
}
29
export function trimOutput(value: string | null | undefined): string

packages/app/template-nextjs-overlay/spawndock/command.mjs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
const WINDOWS_COMMAND_OVERRIDES = {
2-
gh: "gh.exe",
3-
git: "git.exe",
4-
pnpm: "pnpm.cmd",
1+
export function resolveCommand(command, platform = process.platform) {
2+
return platform === "win32" ? command : command
53
}
64

7-
export function resolveCommand(command, platform = process.platform) {
5+
export function resolveSpawnOptions(command, platform = process.platform) {
86
if (platform !== "win32") {
9-
return command
7+
return {}
108
}
119

12-
return WINDOWS_COMMAND_OVERRIDES[command] ?? command
10+
return {
11+
shell: true,
12+
windowsHide: true,
13+
}
1314
}
1415

1516
export function trimOutput(value) {

packages/app/template-nextjs-overlay/spawndock/mcp.mjs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import { spawn } from "node:child_process"
22

3-
import { resolveCommand } from "./command.mjs"
3+
import { resolveCommand, resolveSpawnOptions } from "./command.mjs"
44
import { readSpawndockConfig, resolveMcpApiKey, resolveMcpServerUrl } from "./config.mjs"
55

66
const config = readSpawndockConfig()
77
const mcpServerUrl = process.env.MCP_SERVER_URL ?? resolveMcpServerUrl(config)
88
const mcpServerApiKey = process.env.MCP_SERVER_API_KEY ?? resolveMcpApiKey(config)
99

10-
const child = spawn(resolveCommand("pnpm"), ["exec", "spawn-dock-mcp"], {
10+
const pnpmCommand = resolveCommand("pnpm")
11+
const child = spawn(pnpmCommand, ["exec", "spawn-dock-mcp"], {
1112
cwd: process.cwd(),
1213
env: {
1314
...process.env,
1415
MCP_SERVER_URL: mcpServerUrl,
1516
MCP_SERVER_API_KEY: mcpServerApiKey,
1617
},
1718
stdio: "inherit",
19+
...resolveSpawnOptions(pnpmCommand),
1820
})
1921

2022
child.on("exit", (code) => {

packages/app/template-nextjs-overlay/spawndock/next.mjs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
resolveAllowedDevOrigins,
77
resolveConfiguredLocalPort,
88
} from "./config.mjs"
9-
import { resolveCommand } from "./command.mjs"
9+
import { resolveCommand, resolveSpawnOptions } from "./command.mjs"
1010
import { findAvailablePort } from "./port.mjs"
1111

1212
const config = readSpawndockConfig()
@@ -23,7 +23,8 @@ if (localPort !== requestedLocalPort) {
2323
)
2424
}
2525

26-
const child = spawn(resolveCommand("pnpm"), ["exec", "next", "dev", "-p", String(localPort)], {
26+
const pnpmCommand = resolveCommand("pnpm")
27+
const child = spawn(pnpmCommand, ["exec", "next", "dev", "-p", String(localPort)], {
2728
cwd: process.cwd(),
2829
env: {
2930
...process.env,
@@ -33,6 +34,7 @@ const child = spawn(resolveCommand("pnpm"), ["exec", "next", "dev", "-p", String
3334
SPAWNDOCK_SERVER_ACTIONS_ALLOWED_ORIGINS: config.previewHost ?? "",
3435
},
3536
stdio: ["inherit", "pipe", "pipe"],
37+
...resolveSpawnOptions(pnpmCommand),
3638
})
3739

3840
const exitWithChild = (code) => {

packages/app/template-nextjs-overlay/spawndock/publish.mjs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process"
22
import { cpSync, existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"
33
import { tmpdir } from "node:os"
44
import { dirname, join, resolve } from "node:path"
5-
import { resolveCommand, trimOutput } from "./command.mjs"
5+
import { resolveCommand, resolveSpawnOptions, trimOutput } from "./command.mjs"
66
import { readSpawndockConfig } from "./config.mjs"
77

88
const cwd = process.cwd()
@@ -65,7 +65,12 @@ function deployToGhPagesBranch(remoteUrl) {
6565
run("git", ["-C", tempDir, "commit", "-m", "Deploy SpawnDock app to GitHub Pages"], undefined, true)
6666
run("git", ["-C", tempDir, "push", remoteUrl, "gh-pages", "--force"])
6767
} finally {
68-
spawnSync(resolveCommand("git"), ["worktree", "remove", tempDir, "--force"], { cwd, stdio: "ignore" })
68+
const gitCommand = resolveCommand("git")
69+
spawnSync(gitCommand, ["worktree", "remove", tempDir, "--force"], {
70+
cwd,
71+
stdio: "ignore",
72+
...resolveSpawnOptions(gitCommand),
73+
})
6974
rmSync(tempDir, { recursive: true, force: true })
7075
}
7176
}
@@ -99,20 +104,24 @@ function enablePages(repoFullName) {
99104
}
100105

101106
function remoteBranchExists(branch) {
102-
const result = spawnSync(resolveCommand("git"), ["ls-remote", "--heads", "origin", branch], {
107+
const gitCommand = resolveCommand("git")
108+
const result = spawnSync(gitCommand, ["ls-remote", "--heads", "origin", branch], {
103109
cwd,
104110
encoding: "utf8",
105111
stdio: "pipe",
112+
...resolveSpawnOptions(gitCommand),
106113
})
107114

108115
return result.status === 0 && trimOutput(result.stdout).length > 0
109116
}
110117

111118
function getOriginUrl() {
112-
const result = spawnSync(resolveCommand("git"), ["remote", "get-url", "origin"], {
119+
const gitCommand = resolveCommand("git")
120+
const result = spawnSync(gitCommand, ["remote", "get-url", "origin"], {
113121
cwd,
114122
encoding: "utf8",
115123
stdio: "pipe",
124+
...resolveSpawnOptions(gitCommand),
116125
})
117126

118127
return result.status === 0 ? trimOutput(result.stdout) : null
@@ -126,10 +135,12 @@ function clearDirectory(dir) {
126135
}
127136

128137
function readGh(...args) {
129-
const result = spawnSync(resolveCommand("gh"), args, {
138+
const ghCommand = resolveCommand("gh")
139+
const result = spawnSync(ghCommand, args, {
130140
cwd,
131141
encoding: "utf8",
132142
stdio: "pipe",
143+
...resolveSpawnOptions(ghCommand),
133144
})
134145

135146
if (result.status !== 0) {
@@ -146,11 +157,13 @@ function run(command, args, env = process.env, allowEmptyCommit = false) {
146157
? [...args, "--allow-empty"]
147158
: args
148159

149-
const result = spawnSync(resolveCommand(command), finalArgs, {
160+
const resolvedCommand = resolveCommand(command)
161+
const result = spawnSync(resolvedCommand, finalArgs, {
150162
cwd,
151163
env,
152164
encoding: "utf8",
153165
stdio: "inherit",
166+
...resolveSpawnOptions(resolvedCommand),
154167
})
155168

156169
if (result.status !== 0) {

packages/app/template-nextjs-overlay/spawndock/tunnel.mjs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { spawn } from "node:child_process"
2-
import { resolveCommand } from "./command.mjs"
2+
import { resolveCommand, resolveSpawnOptions } from "./command.mjs"
33

4-
const child = spawn(resolveCommand("pnpm"), ["exec", "spawn-dock-tunnel"], {
4+
const pnpmCommand = resolveCommand("pnpm")
5+
const child = spawn(pnpmCommand, ["exec", "spawn-dock-tunnel"], {
56
cwd: process.cwd(),
67
env: process.env,
78
stdio: "inherit",
9+
...resolveSpawnOptions(pnpmCommand),
810
})
911

1012
child.on("exit", (code) => {

packages/app/tests/bootstrap-command.test.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it } from "vitest"
22
import type { SpawnSyncReturns } from "node:child_process"
3-
import { formatCommandFailure, resolveCommandExecutable } from "../src/shell/bootstrap.js"
3+
import {
4+
createSpawnOptions,
5+
formatCommandFailure,
6+
resolveCommandExecutable,
7+
} from "../src/shell/bootstrap.js"
48

59
const buildResult = (
610
overrides: Partial<SpawnSyncReturns<string>> = {},
@@ -16,13 +20,28 @@ const buildResult = (
1620
}) as SpawnSyncReturns<string>
1721

1822
describe("bootstrap shell command helpers", () => {
19-
it("uses .cmd shims for Windows package manager commands", () => {
20-
expect(resolveCommandExecutable("pnpm", "win32")).toBe("pnpm.cmd")
21-
expect(resolveCommandExecutable("corepack", "win32")).toBe("corepack.cmd")
23+
it("keeps command names unchanged and relies on shell execution on Windows", () => {
24+
expect(resolveCommandExecutable("pnpm", "win32")).toBe("pnpm")
25+
expect(resolveCommandExecutable("corepack", "win32")).toBe("corepack")
2226
expect(resolveCommandExecutable("git", "win32")).toBe("git")
2327
expect(resolveCommandExecutable("pnpm", "linux")).toBe("pnpm")
2428
})
2529

30+
it("enables shell execution on Windows spawn options", () => {
31+
expect(createSpawnOptions("/tmp/demo", "pipe", "win32")).toEqual({
32+
cwd: "/tmp/demo",
33+
encoding: "utf8",
34+
stdio: "pipe",
35+
shell: true,
36+
windowsHide: true,
37+
})
38+
expect(createSpawnOptions("/tmp/demo", "ignore", "linux")).toEqual({
39+
cwd: "/tmp/demo",
40+
encoding: "utf8",
41+
stdio: "ignore",
42+
})
43+
})
44+
2645
it("formats spawn errors even when stdout and stderr are missing", () => {
2746
const result = buildResult({
2847
status: null,

packages/app/tests/template-command.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
import { describe, expect, it } from "vitest"
22

3-
import { resolveCommand, trimOutput } from "../template-nextjs-overlay/spawndock/command.mjs"
3+
import {
4+
resolveCommand,
5+
resolveSpawnOptions,
6+
trimOutput,
7+
} from "../template-nextjs-overlay/spawndock/command.mjs"
48

59
describe("template command helpers", () => {
6-
it("maps pnpm to pnpm.cmd on Windows", () => {
7-
expect(resolveCommand("pnpm", "win32")).toBe("pnpm.cmd")
10+
it("keeps the command name unchanged on Windows", () => {
11+
expect(resolveCommand("pnpm", "win32")).toBe("pnpm")
812
})
913

1014
it("keeps other platforms unchanged", () => {
1115
expect(resolveCommand("pnpm", "linux")).toBe("pnpm")
1216
})
1317

18+
it("uses a shell for Windows spawns", () => {
19+
expect(resolveSpawnOptions("pnpm", "win32")).toEqual({
20+
shell: true,
21+
windowsHide: true,
22+
})
23+
expect(resolveSpawnOptions("pnpm", "linux")).toEqual({})
24+
})
25+
1426
it("returns an empty string for missing output", () => {
1527
expect(trimOutput(undefined)).toBe("")
1628
})

0 commit comments

Comments
 (0)