import { spawn } from "node:child_process"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import type { Readable } from "node:stream"; import { sourceRootLineage } from "./config/source-root.ts"; /** * Workspace-aware build primitives shared by the framework strategies. These * are location-agnostic: they take an explicit app directory and walk up to * the source root (the repository or workspace boundary), so a build invoked * from inside a monorepo package resolves the same package manager and * binaries it would at the workspace root. Nothing here reads `process.cwd()` * or prompts. */ export type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; /** * Build-environment hooks a consumer threads into a strategy: extra `env` for * the build child and a per-line output tap. Both are optional, so the CLI * keeps today's behavior while a headless consumer (the build-runner) injects * deploy vars and streams a live build log. */ export interface BuildCommandIo { env?: NodeJS.ProcessEnv; onOutput?: (line: string, source: "stdout" | "stderr") => void; } /** * Detects the package manager governing `appPath` by walking from the app up * to the source root: the nearest `packageManager` field wins, then the * nearest lockfile. Workspace repos keep both at the root, so a package * deep in a monorepo still resolves correctly. Returns undefined when no * signal is found. */ export async function resolvePackageManager( appPath: string, signal?: AbortSignal, ): Promise { for (const directory of await sourceRootLineage(appPath, signal)) { const manifest = await readPackageManifest(directory, signal); const fromField = packageManagerFromField(manifest?.packageManager); if (fromField) { return fromField; } if ( (await pathExists(path.join(directory, "bun.lock"), signal)) || (await pathExists(path.join(directory, "bun.lockb"), signal)) ) { return "bun"; } if (await pathExists(path.join(directory, "pnpm-lock.yaml"), signal)) { return "pnpm"; } if (await pathExists(path.join(directory, "yarn.lock"), signal)) { return "yarn"; } if (await pathExists(path.join(directory, "package-lock.json"), signal)) { return "npm"; } } return undefined; } /** * Build environment for `appPath`: every `node_modules/.bin` between the app * and its source root, prepended to PATH. Workspace repos hoist binaries like * `next` to the workspace root, so a build run from a package directory still * finds them. The returned object is suitable as the `env` for a child build * process. */ export async function buildCommandEnv( appPath: string, baseEnv: NodeJS.ProcessEnv, signal?: AbortSignal, ): Promise { const binDirs = (await sourceRootLineage(appPath, signal)).map((directory) => path.join(directory, "node_modules", ".bin"), ); return { ...baseEnv, PATH: [...binDirs, baseEnv.PATH].filter(Boolean).join(path.delimiter), }; } /** * Runs a build command string inside `appPath`, with every ancestor * `node_modules/.bin` on PATH so workspace-hoisted binaries resolve. Rejects * with the framework prefix and captured output on a non-zero exit. */ export async function runBuildCommand(options: { appPath: string; command: string; failurePrefix: string; /** * Extra environment merged over `process.env` for the build child (after * the ancestor-bin PATH). A headless consumer (e.g. the build-runner) uses * this to inject per-branch deploy vars and build flags; the CLI passes none. */ env?: NodeJS.ProcessEnv; /** * Per-line tap for build output, called as each line arrives on stdout or * stderr. Lets a consumer stream a live build log instead of waiting for the * process to exit; the CLI passes none. */ onOutput?: (line: string, source: "stdout" | "stderr") => void; signal?: AbortSignal; }): Promise { const env = await buildCommandEnv( options.appPath, { ...process.env, ...options.env }, options.signal, ); // `shell: true` preserves the platform shell so a command string like // `pnpm run build` works the same as the CLI's previous `exec`. await runChildProcess({ command: options.command, args: [], cwd: options.appPath, env, shell: true, failurePrefix: options.failurePrefix, onOutput: options.onOutput, signal: options.signal, }); } /** * Spawns a child build process, streaming each output line to `onOutput` and * keeping a bounded tail for the failure message instead of buffering the whole * build. Resolves on a zero exit; rejects on a non-zero exit (with the prefix, * exit code, and tail) or a spawn error (whose `code`, e.g. ENOENT, is * preserved on the rejection so a launcher ladder can branch on it). * * Shared by `runBuildCommand` (a shell command string) and `runPackageCli` * (an explicit binary + args). */ export function runChildProcess(options: { command: string; args: readonly string[]; cwd: string; env: NodeJS.ProcessEnv; shell?: boolean; failurePrefix: string; onOutput?: (line: string, source: "stdout" | "stderr") => void; signal?: AbortSignal; }): Promise { return new Promise((resolve, reject) => { const tail = createBoundedTail(); const child = spawn(options.command, [...options.args], { cwd: options.cwd, env: options.env, signal: options.signal, shell: options.shell ?? false, }); const streamed = Promise.all([ streamLines(child.stdout, "stdout", tail, options.onOutput), streamLines(child.stderr, "stderr", tail, options.onOutput), ]); child.on("error", (error) => { // Preserve `code` (e.g. ENOENT) so a launcher ladder can fall through. reject( Object.assign( new Error(`${options.failurePrefix} build failed:\n${error.message}`), { code: (error as NodeJS.ErrnoException).code }, ), ); }); child.on("close", (code) => { streamed.then(() => { if (code === 0) { resolve(); return; } const detail = tail.text().trim(); const exit = code != null ? ` (exit code ${code})` : ""; reject( new Error( `${options.failurePrefix} build failed${exit}${detail ? `:\n${detail}` : "."}`, ), ); }, reject); }); }); } /** Splits a child stream into lines, taps each, and records it in the tail. */ function streamLines( stream: Readable | null, source: "stdout" | "stderr", tail: BoundedTail, onOutput?: (line: string, source: "stdout" | "stderr") => void, ): Promise { return new Promise((resolve) => { if (!stream) { resolve(); return; } stream.setEncoding("utf8"); let buffer = ""; const emit = (line: string): void => { tail.push(line); onOutput?.(line, source); }; stream.on("data", (chunk: string) => { buffer += chunk; let newline = buffer.indexOf("\n"); while (newline !== -1) { emit(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); newline = buffer.indexOf("\n"); } }); stream.on("end", () => { if (buffer.length > 0) { emit(buffer); } resolve(); }); // A stream error surfaces through the child's own error/close handlers. stream.on("error", () => resolve()); }); } interface BoundedTail { push(line: string): void; text(): string; } /** A ring of the most recent output lines, for the failure message. */ function createBoundedTail(maxLines = 200): BoundedTail { const lines: string[] = []; return { push(line: string): void { lines.push(line); if (lines.length > maxLines) { lines.shift(); } }, text(): string { return lines.join("\n"); }, }; } /** A `package.json`'s build-relevant fields. */ export interface PackageManifest { packageManager?: unknown; scripts?: Record; main?: unknown; module?: unknown; } /** Reads and parses `appPath/package.json`, returning null on any failure. */ export async function readPackageManifest( appPath: string, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); try { const content = await readFile(path.join(appPath, "package.json"), { encoding: "utf-8", signal, }); const parsed = JSON.parse(content) as unknown; return isRecord(parsed) ? (parsed as PackageManifest) : null; } catch (error) { // A missing file or invalid JSON is expected; an abort must propagate. if (signal?.aborted) throw error; return null; } } /** The trimmed `scripts.build` string, or null when absent or empty. */ export function readBuildScript( manifest: PackageManifest | null, ): string | null { const build = manifest?.scripts?.build; if (typeof build !== "string") { return null; } const trimmed = build.trim(); return trimmed.length > 0 ? trimmed : null; } function packageManagerFromField(value: unknown): PackageManager | null { if (typeof value !== "string") { return null; } const name = value.split("@")[0]; return name === "bun" || name === "pnpm" || name === "yarn" || name === "npm" ? name : null; } async function pathExists( targetPath: string, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); try { await stat(targetPath); return true; } catch { return false; } } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; }