import { readdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import type { PortMapping } from "./types.ts"; import { buildCommandEnv, runChildProcess } from "./workspace.ts"; /** * The result of executing a build strategy. */ export interface BuildArtifact { /** Absolute path to the directory containing the built files. */ directory: string; /** Entrypoint file path, relative to `directory`, using posix separators. */ entrypoint: string; /** * Default port mapping for the application. Used as the port mapping * when deploying to a newly created service and the user did not provide * an explicit --http-port. */ defaultPortMapping?: PortMapping; /** * Optional cleanup callback. Called by the core after the archive has been * created and the artifact directory is no longer needed. Strategies that * create temporary output directories (e.g., BunBuild) use this to clean up. */ cleanup?(): Promise; } /** * A build strategy produces a deployable artifact. */ export interface BuildStrategy { canBuild(signal?: AbortSignal): Promise; execute(signal?: AbortSignal): Promise; } /** * Reads directly from a pre-built application directory without copying. * Validates the entrypoint exists. */ export class PreBuilt implements BuildStrategy { readonly #appPath: string; readonly #entrypoint: string; constructor(options: { appPath: string; entrypoint: string }) { this.#appPath = options.appPath; this.#entrypoint = options.entrypoint; } async canBuild(_signal?: AbortSignal): Promise { return true; } async execute(signal?: AbortSignal): Promise { signal?.throwIfAborted(); const normalized = path.normalize(this.#entrypoint); if (path.isAbsolute(normalized)) { throw new Error("Entrypoint must be a relative path"); } if (normalized.startsWith("..")) { throw new Error("Entrypoint must not escape the application directory"); } const fullPath = path.join(this.#appPath, normalized); const stats = await stat(fullPath).catch(() => null); if (!stats?.isFile()) { throw new Error( `Entrypoint not found in pre-built artifact: ${this.#entrypoint}`, ); } return { directory: this.#appPath, entrypoint: normalized.split(path.sep).join("/"), }; } } // --------------------------------------------------------------------------- // Shared helpers used by framework strategies (Next, Nuxt, Astro, TanStack // Start). Live here because they're tightly tied to the BuildStrategy surface // (framework detection + CLI dispatch) and don't justify their own module. // --------------------------------------------------------------------------- /** * True if any of `filenames` exists at the root of `appPath`. Swallows * readdir errors (missing/unreadable dir) and returns false. */ export async function hasRootFile( appPath: string, filenames: readonly string[], signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); let entries: string[]; try { entries = await readdir(appPath); } catch { return false; } return entries.some((entry) => filenames.includes(entry)); } /** * True if any of `packageNames` is listed in `package.json`'s `dependencies` * or `devDependencies`. Swallows missing-file / invalid-JSON errors and * returns false. */ export async function hasPackageDependency( appPath: string, packageNames: readonly string[], signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); let content: string; try { content = await readFile(path.join(appPath, "package.json"), "utf-8"); } catch { return false; } let parsed: { dependencies?: unknown; devDependencies?: unknown }; try { parsed = JSON.parse(content) as { dependencies?: unknown; devDependencies?: unknown; }; } catch { return false; } const deps = isRecord(parsed.dependencies) ? parsed.dependencies : {}; const devDeps = isRecord(parsed.devDependencies) ? parsed.devDependencies : {}; return packageNames.some((name) => name in deps || name in devDeps); } /** * Run a package's CLI inside `appPath`. Tries the project-local bin first, * then `npx `, then `bunx `. Each candidate that fails * with ENOENT is skipped; other failures (e.g., build errors) re-throw * with stderr surfaced. * * Throws with `missingMessage` if none of the launchers is available. */ export async function runPackageCli(opts: { appPath: string; /** Executable name, e.g. "astro", "next", "nuxt", "vite". */ cliName: string; /** Args passed to the CLI, e.g. ["build"]. */ args: string[]; /** Prefix used when surfacing a build failure, e.g. "Astro". */ failurePrefix: string; /** Thrown when no launcher is available. */ missingMessage: string; /** Extra environment merged over `process.env` for the build child. */ env?: NodeJS.ProcessEnv; /** Per-line tap for build output (stdout/stderr). */ onOutput?: (line: string, source: "stdout" | "stderr") => void; signal?: AbortSignal; }): Promise { // Every ancestor `node_modules/.bin` on PATH plus any injected deploy vars, // so a workspace-hoisted CLI resolves and the build sees its deploy env. const env = await buildCommandEnv( opts.appPath, { ...process.env, ...opts.env }, opts.signal, ); const localBin = path.join( opts.appPath, "node_modules", ".bin", opts.cliName, ); const candidates: Array<{ command: string; args: string[] }> = [ { command: localBin, args: opts.args }, { command: "npx", args: [opts.cliName, ...opts.args] }, { command: "bunx", args: [opts.cliName, ...opts.args] }, ]; for (const { command, args } of candidates) { opts.signal?.throwIfAborted(); try { await runChildProcess({ command, args, cwd: opts.appPath, env, failurePrefix: opts.failurePrefix, onOutput: opts.onOutput, signal: opts.signal, }); return; } catch (error) { if (isENOENT(error)) continue; throw error; } } throw new Error(opts.missingMessage); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function isENOENT(error: unknown): boolean { return ( error instanceof Error && "code" in error && (error as { code?: string }).code === "ENOENT" ); }