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
44 changes: 15 additions & 29 deletions packages/app/src/shell/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -353,12 +354,7 @@ const runCommand = (
): Effect.Effect<SpawnSyncReturns<string>, 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))
Expand All @@ -372,21 +368,8 @@ const runCommand = (
const commandExists = (command: string): Effect.Effect<boolean, Error> =>
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,
})
Expand Down Expand Up @@ -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<string>,
Expand Down
7 changes: 7 additions & 0 deletions packages/app/template-nextjs-overlay/spawndock/command.d.mts
Original file line number Diff line number Diff line change
@@ -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
15 changes: 8 additions & 7 deletions packages/app/template-nextjs-overlay/spawndock/command.mjs
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions packages/app/template-nextjs-overlay/spawndock/mcp.mjs
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
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,
MCP_SERVER_URL: mcpServerUrl,
MCP_SERVER_API_KEY: mcpServerApiKey,
},
stdio: "inherit",
...resolveSpawnOptions(pnpmCommand),
})

child.on("exit", (code) => {
Expand Down
6 changes: 4 additions & 2 deletions packages/app/template-nextjs-overlay/spawndock/next.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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) => {
Expand Down
25 changes: 19 additions & 6 deletions packages/app/template-nextjs-overlay/spawndock/publish.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 })
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions packages/app/template-nextjs-overlay/spawndock/tunnel.mjs
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
27 changes: 23 additions & 4 deletions packages/app/tests/bootstrap-command.test.ts
Original file line number Diff line number Diff line change
@@ -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<SpawnSyncReturns<string>> = {},
Expand All @@ -16,13 +20,28 @@ const buildResult = (
}) 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")
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,
Expand Down
18 changes: 15 additions & 3 deletions packages/app/tests/template-command.test.ts
Original file line number Diff line number Diff line change
@@ -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("")
})
Expand Down