Skip to content
Closed
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
115 changes: 100 additions & 15 deletions packages/app/src/shell/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -9,6 +13,7 @@ import {
buildMcpServerUrl,
DEFAULT_INSPECT_PATH,
DEFAULT_MCP_AGENTS,
GENERATED_PACKAGE_MANAGER,
resolveClaimPath,
type BootstrapClaim,
type BootstrapSummary,
Expand All @@ -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
Expand Down Expand Up @@ -280,8 +283,29 @@ const installDependencies = (projectDir: string): Effect.Effect<void, Error> =>
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 = (
Expand Down Expand Up @@ -330,18 +354,14 @@ const runCommand = (
): Effect.Effect<SpawnSyncReturns<string>, 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
Expand All @@ -352,7 +372,7 @@ const runCommand = (
const commandExists = (command: string): Effect.Effect<boolean, Error> =>
Effect.try({
try: () => {
const result = spawnSync(command, ["--help"], {
const result = spawnCommand(command, ["--help"], {
cwd: process.cwd(),
encoding: "utf8",
stdio: "ignore",
Expand Down Expand Up @@ -468,6 +488,9 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
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))

Expand Down Expand Up @@ -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<string>,
options: SpawnSyncOptionsWithStringEncoding,
): SpawnSyncReturns<string> => {
const candidates = resolveCommandCandidates(command)
let lastResult: SpawnSyncReturns<string> | 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<string> => {
if (process.platform !== "win32" || /\.[^\\/]+$/.test(command)) {
return [command]
}

return [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`]
}

const formatCommandFailure = (
command: string,
args: ReadonlyArray<string>,
result: SpawnSyncReturns<string>,
): 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 })

Expand Down
2 changes: 2 additions & 0 deletions packages/app/template-nextjs-overlay/spawndock/command.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export function resolveCommand(command: string, platform?: NodeJS.Platform): string
export function trimOutput(value: string | null | undefined): string
17 changes: 17 additions & 0 deletions packages/app/template-nextjs-overlay/spawndock/command.mjs
Original file line number Diff line number Diff line change
@@ -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() : ""
}
3 changes: 2 additions & 1 deletion packages/app/template-nextjs-overlay/spawndock/mcp.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/app/template-nextjs-overlay/spawndock/next.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
resolveAllowedDevOrigins,
resolveConfiguredLocalPort,
} from "./config.mjs"
import { resolveCommand } from "./command.mjs"
import { findAvailablePort } from "./port.mjs"

const config = readSpawndockConfig()
Expand All @@ -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,
Expand Down
27 changes: 13 additions & 14 deletions packages/app/template-nextjs-overlay/spawndock/publish.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ 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()
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)
Expand Down Expand Up @@ -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 })
}
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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",
Expand All @@ -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()
}
3 changes: 2 additions & 1 deletion packages/app/template-nextjs-overlay/spawndock/tunnel.mjs
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading