import { readFile } from "node:fs/promises"; import path from "node:path"; import { type ASTNode, parseModule } from "magicast"; import type { FrameworkBuildType } from "./config/frameworks.ts"; import { type PackageManifest, readBuildScript, readPackageManifest, resolvePackageManager, } from "./workspace.ts"; /** * The build command and output directory for a framework build, with a * human-readable source for each value (for display by a consumer). Resolving * settings is location-agnostic: package manager and binaries come from the * app's workspace, not the current working directory. */ export interface BuildSettings { buildCommand: string | null; buildCommandSource: string | null; outputDirectory: string; outputDirectorySource: string | null; } /** * Resolves build settings from framework inference: the user's * `package.json` build script (run via the detected package manager) over the * framework default, plus the framework's output directory (reading * `next.config` `distDir` for Next.js). */ export async function resolveBuildSettings(options: { appPath: string; buildType: FrameworkBuildType; signal?: AbortSignal; }): Promise { switch (options.buildType) { // Nuxt and Astro run their framework CLI and stage fixed output; these // settings only describe that for display. case "nuxt": return { buildCommand: "nuxt build", buildCommandSource: "Nuxt default", outputDirectory: ".output", outputDirectorySource: "Nuxt output", }; case "astro": return { buildCommand: "astro build", buildCommandSource: "Astro default", outputDirectory: "dist", outputDirectorySource: "Astro output", }; case "nextjs": { const manifest = await readPackageManifest( options.appPath, options.signal, ); const buildCommand = await resolveFrameworkBuildCommand( options.appPath, manifest, { command: "next build", source: "Next.js default" }, options.signal, ); const outputRoot = await resolveNextOutputRoot( options.appPath, options.signal, ); return { buildCommand: buildCommand.command, buildCommandSource: buildCommand.source, outputDirectory: joinPosix(outputRoot, "standalone"), outputDirectorySource: outputRoot === ".next" ? "Next.js output" : "next.config distDir", }; } case "nestjs": { const manifest = await readPackageManifest( options.appPath, options.signal, ); const buildCommand = await resolveFrameworkBuildCommand( options.appPath, manifest, { command: "nest build", source: "NestJS default" }, options.signal, ); return { buildCommand: buildCommand.command, buildCommandSource: buildCommand.source, outputDirectory: "dist", outputDirectorySource: "NestJS output", }; } case "tanstack-start": { const manifest = await readPackageManifest( options.appPath, options.signal, ); const buildCommand = await resolveFrameworkBuildCommand( options.appPath, manifest, { command: "vite build", source: "TanStack Start default" }, options.signal, ); return { buildCommand: buildCommand.command, buildCommandSource: buildCommand.source, outputDirectory: ".output", outputDirectorySource: "TanStack Start output", }; } case "bun": { const manifest = await readPackageManifest( options.appPath, options.signal, ); const buildCommand = await resolveFrameworkBuildCommand( options.appPath, manifest, { command: null, source: null }, options.signal, ); return { buildCommand: buildCommand.command, buildCommandSource: buildCommand.source, outputDirectory: ".", outputDirectorySource: "app root", }; } } } /** * Build settings when committed config owns them: configured fields win, * omitted fields fall back to framework inference. `source` labels configured * values with the config filename so a consumer can show provenance. */ export async function resolveConfiguredBuildSettings(options: { appPath: string; buildType: FrameworkBuildType; configured: { command: string | null | undefined; outputDirectory: string | undefined; }; /** Label for configured values, e.g. the config file basename. */ source: string; signal?: AbortSignal; }): Promise { const needsFallback = options.configured.command === undefined || options.configured.outputDirectory === undefined; const fallback = needsFallback ? await resolveBuildSettings(options) : null; return { buildCommand: options.configured.command !== undefined ? options.configured.command : (fallback as BuildSettings).buildCommand, buildCommandSource: options.configured.command !== undefined ? options.source : (fallback as BuildSettings).buildCommandSource, outputDirectory: options.configured.outputDirectory ?? (fallback as BuildSettings).outputDirectory, outputDirectorySource: options.configured.outputDirectory !== undefined ? options.source : (fallback as BuildSettings).outputDirectorySource, }; } async function resolveFrameworkBuildCommand( appPath: string, manifest: PackageManifest | null, fallback: { command: string | null; source: string | null }, signal?: AbortSignal, ): Promise<{ command: string | null; source: string | null }> { const buildScript = readBuildScript(manifest); if (buildScript) { const packageManager = await resolvePackageManager(appPath, signal); return { command: packageManager ? `${packageManager} run build` : buildScript, source: "package.json scripts.build", }; } return fallback; } /** Joins posix-style path segments, collapsing duplicate separators. */ export function joinPosix(...parts: string[]): string { return parts.join("/").replace(/\/+/g, "/"); } /** * Maps a Next.js standalone output directory (e.g. ".next/standalone") back to * its output root (".next"), so static assets can be staged alongside it. */ export function nextOutputRootFromStandaloneDirectory( outputDirectory: string, ): string { const normalized = outputDirectory.replace(/\/+$/g, ""); if (normalized === "standalone") { return "."; } if (normalized.endsWith("/standalone")) { const outputRoot = normalized.slice(0, -"/standalone".length); return outputRoot.length > 0 ? outputRoot : "."; } const dirname = path.posix.dirname(normalized); return dirname === "." ? "." : dirname; } interface StaticNextConfig { distDir?: string; output?: "standalone" | "export"; } const NEXT_CONFIG_FILENAMES = [ "next.config.js", "next.config.mjs", "next.config.ts", "next.config.mts", ] as const; async function resolveNextOutputRoot( appPath: string, signal?: AbortSignal, ): Promise { const config = await readNextConfig(appPath, signal); return config.distDir ?? ".next"; } async function readNextConfig( appPath: string, signal?: AbortSignal, ): Promise { for (const fileName of NEXT_CONFIG_FILENAMES) { signal?.throwIfAborted(); let content: string; try { content = await readFile(path.join(appPath, fileName), { encoding: "utf8", signal, }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { continue; } throw error; } return readStaticNextConfig(content); } return {}; } /** Best-effort static read of `distDir`/`output` from a next.config source. */ export function readStaticNextConfig(content: string): StaticNextConfig { try { const module = parseModule(content); const program = asAstNode(module.$ast); const bindings = program ? collectStaticBindings(program) : new Map(); const configObject = program ? findExportedConfigObject(program, bindings) : null; if (!configObject) { return {}; } const rawDistDir = readStaticStringProperty(configObject, "distDir"); const output = readStaticStringProperty(configObject, "output"); return { distDir: rawDistDir ? normalizeRelativePath(rawDistDir) : undefined, output: output === "standalone" || output === "export" ? output : undefined, }; } catch { return {}; } } function normalizeRelativePath(value: string): string | undefined { const raw = value.trim().replace(/\\/g, "/"); if (raw.length === 0 || raw.split("/").includes("..")) { return undefined; } if (/^[A-Za-z]:/.test(raw)) { return undefined; } const normalized = path.posix.normalize(raw); if ( path.win32.isAbsolute(value) || path.posix.isAbsolute(normalized) || normalized.split("/").includes("..") ) { return undefined; } return normalized === "." ? "." : normalized; } type AstNode = ASTNode & { type: string; [key: string]: unknown }; function asAstNode(value: unknown): AstNode | null { if (!value || typeof value !== "object") { return null; } const type = (value as { type?: unknown }).type; return typeof type === "string" ? (value as AstNode) : null; } function astNodes(value: unknown): AstNode[] { if (!Array.isArray(value)) { return []; } return value.map(asAstNode).filter((node): node is AstNode => Boolean(node)); } function collectStaticBindings(program: AstNode): Map { const bindings = new Map(); for (const statement of astNodes(program.body)) { if (statement.type !== "VariableDeclaration") { continue; } for (const declaration of astNodes(statement.declarations)) { const id = asAstNode(declaration.id); const init = asAstNode(declaration.init); if (id?.type === "Identifier" && typeof id.name === "string" && init) { bindings.set(id.name, init); } } } return bindings; } function findExportedConfigObject( program: AstNode, bindings: Map, ): AstNode | null { for (const statement of astNodes(program.body)) { if (statement.type === "ExportDefaultDeclaration") { return resolveConfigObject(statement.declaration, bindings); } if (statement.type !== "ExpressionStatement") { continue; } const expression = asAstNode(statement.expression); if ( expression?.type !== "AssignmentExpression" || expression.operator !== "=" ) { continue; } if (isModuleExports(expression.left)) { return resolveConfigObject(expression.right, bindings); } } return null; } function resolveConfigObject( value: unknown, bindings: Map, depth = 0, ): AstNode | null { if (depth > 4) { return null; } const node = unwrapStaticExpression(asAstNode(value)); if (!node) { return null; } if (node.type === "ObjectExpression") { return node; } if (node.type === "Identifier" && typeof node.name === "string") { return resolveConfigObject(bindings.get(node.name), bindings, depth + 1); } if (node.type === "CallExpression") { return resolveConfigObject( astNodes(node.arguments)[0], bindings, depth + 1, ); } return null; } function unwrapStaticExpression(node: AstNode | null): AstNode | null { let current = node; while ( current?.type === "TSAsExpression" || current?.type === "TSSatisfiesExpression" || current?.type === "TSNonNullExpression" ) { current = asAstNode(current.expression); } return current; } function isModuleExports(value: unknown): boolean { const node = asAstNode(value); if (node?.type !== "MemberExpression" || node.computed === true) { return false; } const object = asAstNode(node.object); const property = asAstNode(node.property); return ( object?.type === "Identifier" && object.name === "module" && property?.type === "Identifier" && property.name === "exports" ); } function readStaticStringProperty( objectExpression: AstNode, propertyName: string, ): string | undefined { for (const property of astNodes(objectExpression.properties)) { if (property.type !== "ObjectProperty" || property.computed === true) { continue; } if (propertyKeyName(property.key) !== propertyName) { continue; } const value = unwrapStaticExpression(asAstNode(property.value)); if (value?.type === "StringLiteral" && typeof value.value === "string") { const trimmed = value.value.trim(); return trimmed.length > 0 ? trimmed : undefined; } } return undefined; } function propertyKeyName(value: unknown): string | undefined { const key = asAstNode(value); if (key?.type === "Identifier" && typeof key.name === "string") { return key.name; } if (key?.type === "StringLiteral" && typeof key.value === "string") { return key.value; } return undefined; }