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
46 changes: 39 additions & 7 deletions packages/app/src/shell/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
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 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 +12,7 @@ import {
buildMcpServerUrl,
DEFAULT_INSPECT_PATH,
DEFAULT_MCP_AGENTS,
GENERATED_PACKAGE_MANAGER,
resolveClaimPath,
type BootstrapClaim,
type BootstrapSummary,
Expand All @@ -18,9 +22,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 +282,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(corepackResult, "corepack", ["pnpm", "install"])}`,
`pnpm: ${formatCommandFailure(pnpmResult, "pnpm", ["install"])}`,
`npx fallback: ${formatCommandFailure(npxResult, "npx", ["-y", GENERATED_PACKAGE_MANAGER, "install"])}`,
"Install Node.js 20+ with Corepack enabled, or install pnpm globally, then rerun the bootstrap command.",
].join("\n"),
),
)
})

const registerAgentIntegrations = (
Expand Down Expand Up @@ -513,6 +536,15 @@ 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 WINDOWS_CMD_SHIMS = new Set(["codex", "corepack", "npm", "npx", "pnpm"])

export const resolveCommandExecutable = (
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
96 changes: 96 additions & 0 deletions packages/app/tests/bootstrap-install.test.ts
Original file line number Diff line number Diff line change
@@ -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`)
})
})
17 changes: 17 additions & 0 deletions packages/app/tests/template-command.test.ts
Original file line number Diff line number Diff line change
@@ -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("")
})
})