From 92f6201e815bf2298b3aeca4ca08dbe207ea47c7 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 3 Jun 2026 03:53:14 +0200 Subject: [PATCH 1/3] feat(config): introduce app config file --- docs/product/command-spec.md | 8 +- docs/product/error-conventions.md | 1 + docs/product/output-conventions.md | 1 + docs/product/resource-model.md | 2 + packages/cli/src/controllers/app.ts | 48 ++ packages/cli/src/lib/app/bun-project.ts | 1 + .../cli/src/lib/app/preview-build-settings.ts | 488 ++++++++++++++++++ packages/cli/src/lib/app/preview-build.ts | 289 ++++++++++- packages/cli/src/lib/app/preview-provider.ts | 4 +- packages/cli/tests/app-build.test.ts | 309 +++++++++++ packages/cli/tests/app-controller.test.ts | 111 +++- 11 files changed, 1245 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/lib/app/preview-build-settings.ts diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 4787c5c..96dd9d1 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -58,7 +58,7 @@ Out of scope for the current beta: non-TTY stderr, and when `NO_UPDATE_NOTIFIER` is set. When shown, update notifications are stderr-only human output and do not change the original command result. -- Public Beta does not read or write committed config files such as `prisma.config.ts` or `.prisma/settings.json` for Project -> Branch -> App resolution. `.prisma/local.json` is a gitignored local pin/cache, not a declarative repo config file. +- Public Beta does not read or write committed config files such as `prisma.config.ts` or `.prisma/settings.json` for Project -> Branch -> App resolution. `.prisma/local.json` is a gitignored local pin/cache, not a declarative repo config file. `prisma.app.json` is only for app build settings. - Remote commands do not silently change local context. ## Authentication @@ -645,11 +645,17 @@ Behavior: - writes `.prisma/local.json` after Project binding succeeds and before build/deploy starts, so retries after a failed deploy do not repeat setup - before asking `Customize build settings? (y/N)`, previews the detected framework and runtime so the user can see the defaults they are accepting or changing - asks `Customize build settings? (y/N)` only while binding the directory for the first time, and only asks for Framework and HTTP port when the user opts in +- for Next.js, TanStack Start, and Bun/Hono deploys, reads or creates `prisma.app.json` before build and uses it for app build settings: + - `Build Command` prefers ` run build` when `package.json` has `scripts.build` + - otherwise `Build Command` falls back to the framework default, such as `next build` + - `Output Directory` is a literal framework output path, such as `.next/standalone`, `.output`, or `.` +- does not overwrite an existing `prisma.app.json`; edit the file or delete it and rerun deploy to regenerate defaults - after setup, deploy prints `Deploying to / / `; later deploys print a compact target header such as `Deploying ./j1 to j1 / main / j1` - deploy progress uses short stage copy (`Building locally...`, `Built `, `Uploading...`, `Uploaded`, `Deploying...`, `Deployed`) and never prints `Status: running` or `Deployment is running at ...` - success human output prints `Live in `, the URL on its own line, and `Logs prisma-cli app logs` - accepts repeated `--env NAME=VALUE` flags - maps user-facing framework names to deploy build strategies +- does not accept `--build-command` or `--output-directory`; custom build settings are edited in `prisma.app.json`, which is initially generated from `package.json` `scripts.build` and framework defaults for config-backed deploy types - uses `src/index.ts` as the Hono deploy entrypoint when the app has no `package.json#main` or `package.json#module` and that file exists - supports vanilla Bun apps with `--framework bun` using `package.json#main` or `package.json#module`, or with `--entry ` - treats `--entry ` without `--framework` as a Bun app deploy diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index a0ffce8..b2fb71b 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -210,6 +210,7 @@ Recommended meanings: - `APP_AMBIGUOUS`: multiple apps matched the inferred or explicit app target - `LOCAL_STATE_STALE`: local Project pin no longer matches platform data and continuing would be ambiguous - `BRANCH_NOT_DEPLOYABLE`: command tried to deploy to a non-deployable branch context +- `APP_CONFIG_INVALID`: `prisma.app.json` is missing required build settings, has invalid JSON, or points outside the app root - `FRAMEWORK_NOT_DETECTED`: app deploy could not detect a supported Beta framework and no explicit framework/build type was provided - `DEPLOYMENT_NOT_FOUND`: requested deployment id does not exist - `NO_DEPLOYMENTS`: command resolved a branch or app but found no deployments diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 3b40c12..8317977 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -315,6 +315,7 @@ Examples: - `app deploy` should state the resolved target that matters in the current slice - first local `app deploy` binding should make the Project choice explicit before work begins - subsequent `app deploy` calls should use a compact target header such as `Deploying ./j1 to j1 / main / j1` +- config-backed `app deploy` builds should show whether they created or used `prisma.app.json` before build starts: `Build Command` with its source when inferred, and `Output Directory` as a literal path such as `.next/standalone` rather than an opaque framework default label - `app logs` should state the deployment it resolved - `app list-deploys` should state which app or branch is being listed diff --git a/docs/product/resource-model.md b/docs/product/resource-model.md index 4983464..45a726d 100644 --- a/docs/product/resource-model.md +++ b/docs/product/resource-model.md @@ -38,6 +38,7 @@ Rules: - `project` is not the same thing as `app` - Public Beta does not read or write committed config files such as `prisma.config.ts` or `.prisma/settings.json` for project resolution - `.prisma/local.json` is a gitignored local pin/cache for Workspace and Project IDs; it is not a declarative repo config file +- `prisma.app.json` is a committed app build-settings file only; it must not contain Workspace, Project, Branch, App, env, or secret resolution state - Project setup is explicit: users choose an existing Project or explicitly create a new one before remote work starts - `app deploy` may orchestrate Project setup, but it must not silently choose or create Project scope - everything under a project happens in a branch @@ -104,6 +105,7 @@ Rules: - the runtime app service is scoped by branch in the platform model - the app may be selected or created as part of app deployment workflows - app selection is local CLI state when needed for the beta package +- app build settings may live in `prisma.app.json` beside `package.json`; v1 fields are `buildCommand` and `outputDirectory` ### Deployment diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 20eca81..30b2255 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -74,6 +74,8 @@ import { executePreviewBuild, PREVIEW_BUILD_TYPES, RESOLVED_PREVIEW_BUILD_TYPES, + resolveOrCreatePreviewBuildSettings, + type PreviewBuildSettingsResolution, type ResolvedPreviewBuildType, type PreviewBuildType, } from "../lib/app/preview-build"; @@ -318,6 +320,16 @@ export async function runAppDeploy( const buildType = framework.buildType; assertSupportedEntrypoint(buildType, options?.entrypoint, "deploy"); const entrypoint = await resolveDeployEntrypoint(context.runtime.cwd, framework, options?.entrypoint, context.runtime.signal); + const buildSettingsResolution = usesPreviewBuildSettings(buildType) + ? await resolveOrCreatePreviewBuildSettings({ + appPath: context.runtime.cwd, + buildType, + signal: context.runtime.signal, + }) + : null; + if (buildSettingsResolution) { + maybeRenderDeployBuildSettings(context, buildSettingsResolution); + } const portMapping = parseDeployPortMapping(String(runtime.port)); const progressState = createPreviewDeployProgressState(); @@ -331,6 +343,7 @@ export async function runAppDeploy( region: selectedApp.region, entrypoint, buildType, + buildSettings: buildSettingsResolution?.settings, portMapping, envVars, interaction: undefined, @@ -2261,6 +2274,7 @@ async function resolveProjectContext( options?: { branch?: ResolvedDeployBranch; commandName?: string; + envProjectId?: string; }, ): Promise { const authState = await requireAuthenticatedAuthState(context); @@ -2854,6 +2868,40 @@ async function maybeRenderDeploySetupBlock( context.output.stderr.write(`${prefix} ${details.projectName} / ${details.branchName} / ${details.appName}\n\n`); } +function usesPreviewBuildSettings(buildType: ResolvedPreviewBuildType): boolean { + return buildType === "nextjs" || buildType === "tanstack-start" || buildType === "bun"; +} + +function maybeRenderDeployBuildSettings( + context: CommandContext, + resolution: PreviewBuildSettingsResolution, +): void { + if (context.flags.json || context.flags.quiet) { + return; + } + + const settings = resolution.settings; + const title = resolution.status === "created" + ? `Created ${resolution.relativeConfigPath}` + : `Using ${resolution.relativeConfigPath}`; + + context.output.stderr.write( + `${title}\n` + + `${renderDeployOutputRows(context.ui, [ + { + label: "Build Command", + value: settings.buildCommand ?? "none", + origin: settings.buildCommandSource ?? undefined, + }, + { + label: "Output Directory", + value: settings.outputDirectory, + origin: settings.outputDirectorySource ?? undefined, + }, + ]).join("\n")}\n\n`, + ); +} + function maybeRenderProjectLinked( context: CommandContext, directory: string, diff --git a/packages/cli/src/lib/app/bun-project.ts b/packages/cli/src/lib/app/bun-project.ts index 3e26fe3..5c5cf0f 100644 --- a/packages/cli/src/lib/app/bun-project.ts +++ b/packages/cli/src/lib/app/bun-project.ts @@ -4,6 +4,7 @@ import path from "node:path"; export interface BunPackageJsonLike { main?: unknown; module?: unknown; + packageManager?: unknown; scripts?: unknown; dependencies?: unknown; devDependencies?: unknown; diff --git a/packages/cli/src/lib/app/preview-build-settings.ts b/packages/cli/src/lib/app/preview-build-settings.ts new file mode 100644 index 0000000..331ecaa --- /dev/null +++ b/packages/cli/src/lib/app/preview-build-settings.ts @@ -0,0 +1,488 @@ +import { exec } from "node:child_process"; +import { readdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { CliError } from "../../shell/errors"; +import { readBunPackageJson, type BunPackageJsonLike } from "./bun-project"; +import type { ResolvedPreviewBuildType } from "./preview-build"; + +type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; + +export const PRISMA_APP_CONFIG_FILENAME = "prisma.app.json"; +export const PRISMA_APP_CONFIG_SCHEMA_URL = "https://pris.ly/schemas/prisma-app-config.v1.json"; + +interface ResolvedBuildCommand { + command: string | null; + source: string | null; +} + +export interface PreviewBuildSettings { + buildCommand: string | null; + buildCommandSource: string | null; + outputDirectory: string; + outputDirectorySource: string | null; +} + +export interface PreviewBuildSettingsResolution { + status: "created" | "used"; + configPath: string; + relativeConfigPath: typeof PRISMA_APP_CONFIG_FILENAME; + settings: PreviewBuildSettings; +} + +export async function resolveOrCreatePreviewBuildSettings(options: { + appPath: string; + buildType: ResolvedPreviewBuildType; + signal?: AbortSignal; +}): Promise { + const configPath = path.join(options.appPath, PRISMA_APP_CONFIG_FILENAME); + const existing = await readPreviewBuildSettingsConfig(configPath, options.signal); + if (existing) { + return { + status: "used", + configPath, + relativeConfigPath: PRISMA_APP_CONFIG_FILENAME, + settings: { + buildCommand: existing.buildCommand, + buildCommandSource: null, + outputDirectory: existing.outputDirectory, + outputDirectorySource: null, + }, + }; + } + + const settings = await resolvePreviewBuildSettings(options); + const config = { + $schema: PRISMA_APP_CONFIG_SCHEMA_URL, + buildCommand: settings.buildCommand, + outputDirectory: settings.outputDirectory, + }; + + try { + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + signal: options.signal, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + const raced = await readPreviewBuildSettingsConfig(configPath, options.signal); + if (raced) { + return { + status: "used", + configPath, + relativeConfigPath: PRISMA_APP_CONFIG_FILENAME, + settings: { + buildCommand: raced.buildCommand, + buildCommandSource: null, + outputDirectory: raced.outputDirectory, + outputDirectorySource: null, + }, + }; + } + } + + throw error; + } + + return { + status: "created", + configPath, + relativeConfigPath: PRISMA_APP_CONFIG_FILENAME, + settings, + }; +} + +export async function resolvePreviewBuildSettings(options: { + appPath: string; + buildType: ResolvedPreviewBuildType; + signal?: AbortSignal; +}): Promise { + switch (options.buildType) { + case "nextjs": { + const packageJson = await readBunPackageJson(options.appPath, options.signal); + const buildCommand = await resolveFrameworkBuildCommand(options.appPath, packageJson, { + command: "next build", + source: "Next.js default", + signal: 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 "tanstack-start": { + const packageJson = await readBunPackageJson(options.appPath, options.signal); + const buildCommand = await resolveFrameworkBuildCommand(options.appPath, packageJson, { + command: "vite build", + source: "TanStack Start default", + signal: options.signal, + }); + return { + buildCommand: buildCommand.command, + buildCommandSource: buildCommand.source, + outputDirectory: ".output", + outputDirectorySource: "TanStack Start output", + }; + } + default: { + const packageJson = await readBunPackageJson(options.appPath, options.signal); + const buildCommand = await resolveFrameworkBuildCommand(options.appPath, packageJson, { + command: null, + source: null, + signal: options.signal, + }); + return { + buildCommand: buildCommand.command, + buildCommandSource: buildCommand.source, + outputDirectory: ".", + outputDirectorySource: "app root", + }; + } + } +} + +interface PreviewBuildSettingsConfig { + buildCommand: string | null; + outputDirectory: string; +} + +async function readPreviewBuildSettingsConfig( + configPath: string, + signal?: AbortSignal, +): Promise { + let content: string; + try { + content = await readFile(configPath, { encoding: "utf8", signal }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content) as unknown; + } catch (error) { + throw invalidPrismaAppConfigError( + configPath, + `The file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw invalidPrismaAppConfigError(configPath, "The file must contain a JSON object."); + } + + const raw = parsed as Record; + if ( + "$schema" in raw && + raw.$schema !== undefined && + typeof raw.$schema !== "string" + ) { + throw invalidPrismaAppConfigError(configPath, "The $schema field must be a string when present."); + } + + if (raw.buildCommand !== null && typeof raw.buildCommand !== "string") { + throw invalidPrismaAppConfigError(configPath, "The buildCommand field must be a string or null."); + } + + let buildCommand: string | null = null; + if (typeof raw.buildCommand === "string") { + buildCommand = raw.buildCommand.trim(); + if (buildCommand.length === 0) { + throw invalidPrismaAppConfigError(configPath, "The buildCommand field must not be an empty string. Use null to skip the build step."); + } + } + + const outputDirectory = normalizeConfigOutputDirectory(configPath, raw.outputDirectory); + + return { + buildCommand, + outputDirectory, + }; +} + +function normalizeConfigOutputDirectory(configPath: string, value: unknown): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw invalidPrismaAppConfigError(configPath, "The outputDirectory field must be a non-empty string."); + } + + const normalized = path.normalize(value.trim()); + if ( + path.isAbsolute(normalized) || + normalized === ".." || + normalized.startsWith(`..${path.sep}`) || + normalized.includes(`${path.sep}..${path.sep}`) + ) { + throw invalidPrismaAppConfigError( + configPath, + "The outputDirectory field must be a relative path inside the app directory.", + ); + } + + if (normalized === ".") { + return "."; + } + + return normalized.split(path.sep).join("/"); +} + +function invalidPrismaAppConfigError(configPath: string, why: string): CliError { + return new CliError({ + code: "APP_CONFIG_INVALID", + domain: "app", + summary: `Invalid ${PRISMA_APP_CONFIG_FILENAME}`, + why, + fix: `Edit ${PRISMA_APP_CONFIG_FILENAME} so buildCommand is a string or null ` + + "and outputDirectory is a relative path inside the app root. " + + "Delete the file and rerun prisma-cli app deploy to regenerate defaults.", + where: configPath, + meta: { + configPath, + }, + exitCode: 2, + nextSteps: ["prisma-cli app deploy"], + }); +} + +export async function hasRootFile(appPath: string, filenames: readonly string[], signal?: AbortSignal): Promise { + let entries: string[]; + try { + signal?.throwIfAborted(); + entries = await readdir(appPath); + signal?.throwIfAborted(); + } catch (error) { + if (signal?.aborted) throw error; + return false; + } + + return entries.some((entry) => filenames.includes(entry)); +} + +export function hasPackageDependency(packageJson: BunPackageJsonLike | null, dependencyName: string): boolean { + return hasAnyPackageDependency(packageJson, [dependencyName]); +} + +export function hasAnyPackageDependency(packageJson: BunPackageJsonLike | null, dependencyNames: readonly string[]): boolean { + if (!packageJson) { + return false; + } + + const dependencyGroups = [packageJson.dependencies, packageJson.devDependencies]; + return dependencyGroups.some((group) => { + if (!group || typeof group !== "object") { + return false; + } + + return dependencyNames.some((dependencyName) => dependencyName in group); + }); +} + +async function resolveFrameworkBuildCommand( + appPath: string, + packageJson: BunPackageJsonLike | null, + fallback: { + command: string | null; + source: string | null; + signal?: AbortSignal; + }, +): Promise { + if (hasBuildScript(packageJson)) { + const packageManager = await resolvePackageManager(appPath, packageJson, fallback.signal); + return { + command: `${packageManager} run build`, + source: "package.json scripts.build", + }; + } + + return { + command: fallback.command, + source: fallback.source, + }; +} + +function hasBuildScript(packageJson: BunPackageJsonLike | null): boolean { + if (!packageJson?.scripts || typeof packageJson.scripts !== "object") { + return false; + } + + const scripts = packageJson.scripts as Record; + return typeof scripts.build === "string" && scripts.build.trim().length > 0; +} + +async function resolvePackageManager( + appPath: string, + packageJson: BunPackageJsonLike | null, + signal?: AbortSignal, +): Promise { + const fromPackageManager = packageManagerFromPackageJson(packageJson?.packageManager); + if (fromPackageManager) { + return fromPackageManager; + } + + if (await pathExists(path.join(appPath, "bun.lock"), signal) || await pathExists(path.join(appPath, "bun.lockb"), signal)) { + return "bun"; + } + + if (await pathExists(path.join(appPath, "pnpm-lock.yaml"), signal)) { + return "pnpm"; + } + + if (await pathExists(path.join(appPath, "yarn.lock"), signal)) { + return "yarn"; + } + + if (await pathExists(path.join(appPath, "package-lock.json"), signal)) { + return "npm"; + } + + return "bun"; +} + +function packageManagerFromPackageJson(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; +} + +export async function runResolvedBuildCommand( + appPath: string, + settings: PreviewBuildSettings, + failurePrefix: string, + signal?: AbortSignal, +): Promise { + if (!settings.buildCommand) { + return; + } + + await execBuildCommand(settings.buildCommand, appPath, failurePrefix, signal); +} + +function execBuildCommand( + command: string, + cwd: string, + failurePrefix: string, + signal?: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + const child = exec(command, { + cwd, + env: { + ...process.env, + PATH: [ + path.join(cwd, "node_modules", ".bin"), + process.env.PATH, + ].filter(Boolean).join(path.delimiter), + }, + maxBuffer: 10 * 1024 * 1024, + signal, + }, (error, stdout, stderr) => { + if (error) { + const output = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n"); + reject(new Error(`${failurePrefix} failed:\n${output || error.message}`)); + return; + } + + resolve(); + }); + + if (signal?.aborted) { + child.kill(); + } + }); +} + +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<{ distDir?: string }> { + for (const fileName of NEXT_CONFIG_FILENAMES) { + const filePath = path.join(appPath, fileName); + let content: string; + try { + content = await readFile(filePath, { encoding: "utf8", signal }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + + throw error; + } + + return { + distDir: readStaticDistDir(content), + }; + } + + return {}; +} + +const NEXT_CONFIG_FILENAMES = [ + "next.config.js", + "next.config.mjs", + "next.config.cjs", + "next.config.ts", + "next.config.mts", +] as const; + +function readStaticDistDir(content: string): string | undefined { + const match = /\bdistDir\s*:\s*["'`]([^"'`]+)["'`]/.exec(content); + const rawDistDir = match?.[1]?.trim(); + if (!rawDistDir) { + return undefined; + } + + const normalized = path.normalize(rawDistDir); + if ( + path.isAbsolute(normalized) || + normalized === ".." || + normalized.startsWith(`..${path.sep}`) || + normalized.includes(`${path.sep}..${path.sep}`) + ) { + return undefined; + } + + return normalized.split(path.sep).join("/"); +} + +export function joinPosix(...parts: string[]): string { + return parts.join("/").replace(/\/+/g, "/"); +} + +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; +} + +async function pathExists(targetPath: string, signal?: AbortSignal): Promise { + try { + signal?.throwIfAborted(); + await stat(targetPath); + signal?.throwIfAborted(); + return true; + } catch (error) { + if (signal?.aborted) throw error; + return false; + } +} diff --git a/packages/cli/src/lib/app/preview-build.ts b/packages/cli/src/lib/app/preview-build.ts index 8dccc4c..b4f02c8 100644 --- a/packages/cli/src/lib/app/preview-build.ts +++ b/packages/cli/src/lib/app/preview-build.ts @@ -1,16 +1,35 @@ -import { chmod, copyFile, cp, lstat, mkdir, readdir, readFile, readlink, rm, stat } from "node:fs/promises"; +import { chmod, copyFile, cp, lstat, mkdir, mkdtemp, readdir, readFile, readlink, rm, stat } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { AstroBuild, BunBuild, - NextjsBuild, NuxtBuild, - TanstackStartBuild, type BuildArtifact, type BuildStrategy, } from "@prisma/compute-sdk"; -import { resolveBunEntrypoint } from "./bun-project"; +import { readBunPackageJson, resolveBunEntrypoint } from "./bun-project"; +import { + PRISMA_APP_CONFIG_FILENAME, + hasAnyPackageDependency, + hasPackageDependency, + hasRootFile, + joinPosix, + nextOutputRootFromStandaloneDirectory, + resolvePreviewBuildSettings, + runResolvedBuildCommand, + type PreviewBuildSettings, +} from "./preview-build-settings"; + +export { + PRISMA_APP_CONFIG_FILENAME, + PRISMA_APP_CONFIG_SCHEMA_URL, + resolveOrCreatePreviewBuildSettings, + resolvePreviewBuildSettings, + type PreviewBuildSettings, + type PreviewBuildSettingsResolution, +} from "./preview-build-settings"; export const PREVIEW_BUILD_TYPES = [ "auto", @@ -33,12 +52,20 @@ export class PreviewBuildStrategy implements BuildStrategy { readonly #entrypoint?: string; readonly #buildType: PreviewBuildType; readonly #signal?: AbortSignal; + readonly #buildSettings?: PreviewBuildSettings; - constructor(options: { appPath: string; entrypoint?: string; buildType?: PreviewBuildType; signal?: AbortSignal }) { + constructor(options: { + appPath: string; + entrypoint?: string; + buildType?: PreviewBuildType; + signal?: AbortSignal; + buildSettings?: PreviewBuildSettings; + }) { this.#appPath = options.appPath; this.#entrypoint = options.entrypoint; this.#buildType = options.buildType ?? "auto"; this.#signal = options.signal; + this.#buildSettings = options.buildSettings; } async canBuild(signal = this.#signal): Promise { @@ -47,6 +74,7 @@ export class PreviewBuildStrategy implements BuildStrategy { entrypoint: this.#entrypoint, buildType: this.#buildType, signal, + buildSettings: this.#buildSettings, }); return strategy.canBuild(signal); @@ -58,6 +86,7 @@ export class PreviewBuildStrategy implements BuildStrategy { entrypoint: this.#entrypoint, buildType: this.#buildType, signal, + buildSettings: this.#buildSettings, }); return artifact; @@ -69,6 +98,7 @@ export async function executePreviewBuild(options: { entrypoint?: string; buildType?: PreviewBuildType; signal?: AbortSignal; + buildSettings?: PreviewBuildSettings; }): Promise<{ artifact: BuildArtifact; buildType: ResolvedPreviewBuildType; @@ -78,14 +108,11 @@ export async function executePreviewBuild(options: { entrypoint: options.entrypoint, buildType: options.buildType ?? "auto", signal: options.signal, + buildSettings: options.buildSettings, }); const artifact = await strategy.execute(options.signal); try { - if (buildType === "nextjs") { - await restageNextjsArtifact(artifact, options.appPath, options.signal); - } - await normalizeArtifactSymlinks(artifact.directory, options.appPath, options.signal); return { artifact, @@ -102,6 +129,7 @@ export async function resolvePreviewBuildStrategy(options: { entrypoint?: string; buildType: PreviewBuildType; signal?: AbortSignal; + buildSettings?: PreviewBuildSettings; }): Promise<{ strategy: BuildStrategy; buildType: ResolvedPreviewBuildType; @@ -112,6 +140,7 @@ export async function resolvePreviewBuildStrategy(options: { entrypoint: options.entrypoint, buildType: options.buildType, signal: options.signal, + buildSettings: options.buildSettings, }); return { @@ -129,6 +158,7 @@ export async function resolvePreviewBuildStrategy(options: { entrypoint: options.entrypoint, buildType, signal: options.signal, + buildSettings: options.buildSettings, }); if (await strategy.canBuild(options.signal)) { @@ -146,6 +176,7 @@ export async function resolvePreviewBuildStrategy(options: { entrypoint: options.entrypoint, buildType: "bun", signal: options.signal, + buildSettings: options.buildSettings, }), }; } @@ -155,26 +186,193 @@ async function createPreviewBuildStrategy(options: { entrypoint?: string; buildType: ResolvedPreviewBuildType; signal?: AbortSignal; + buildSettings?: PreviewBuildSettings; }): Promise { switch (options.buildType) { case "nextjs": - return new NextjsBuild({ appPath: options.appPath }); + return new PreviewNextjsBuild({ appPath: options.appPath, buildSettings: options.buildSettings }); case "nuxt": return new NuxtBuild({ appPath: options.appPath }); case "astro": return new AstroBuild({ appPath: options.appPath }); case "tanstack-start": - return new TanstackStartBuild({ appPath: options.appPath }); + return new PreviewTanstackStartBuild({ appPath: options.appPath, buildSettings: options.buildSettings }); case "bun": { const entrypoint = await resolveBunEntrypoint(options.appPath, options.entrypoint, options.signal); - return new BunBuild({ + return new PreviewBunBuild({ appPath: options.appPath, + strategy: new BunBuild({ + appPath: options.appPath, + entrypoint, + }), + buildSettings: options.buildSettings, + }); + } + } +} + +class PreviewNextjsBuild implements BuildStrategy { + readonly #appPath: string; + readonly #buildSettings?: PreviewBuildSettings; + + constructor(options: { appPath: string; buildSettings?: PreviewBuildSettings }) { + this.#appPath = options.appPath; + this.#buildSettings = options.buildSettings; + } + + async canBuild(signal?: AbortSignal): Promise { + const packageJson = await readBunPackageJson(this.#appPath, signal); + return (await hasRootFile(this.#appPath, NEXT_CONFIG_FILENAMES, signal)) || hasPackageDependency(packageJson, "next"); + } + + async execute(signal?: AbortSignal): Promise { + const settings = this.#buildSettings ?? await resolvePreviewBuildSettings({ + appPath: this.#appPath, + buildType: "nextjs", + signal, + }); + await runResolvedBuildCommand(this.#appPath, settings, "Next.js", signal); + + const standaloneDir = path.join(this.#appPath, settings.outputDirectory); + if (!await directoryExists(standaloneDir, signal)) { + throw new Error( + `Next.js build did not produce standalone output at ${settings.outputDirectory}. ` + + `Add output: "standalone" to your next.config file, or update ${PRISMA_APP_CONFIG_FILENAME}.`, + ); + } + + const outDir = await unsupportedFilesystemBoundary(signal, () => mkdtemp(path.join(os.tmpdir(), "compute-build-"))); + + try { + const artifactDir = path.join(outDir, "app"); + await stageNextjsStandaloneArtifact({ + standaloneDir, + artifactDir, + appPath: this.#appPath, + signal, + }); + const entrypoint = await findNextStandaloneEntrypoint(artifactDir, signal); + await copyNextjsStaticAssets({ + appPath: this.#appPath, + artifactDir, + outputRoot: nextOutputRootFromStandaloneDirectory(settings.outputDirectory), entrypoint, + signal, }); + + return { + directory: artifactDir, + entrypoint, + defaultPortMapping: { http: 3000 }, + cleanup: () => rm(outDir, { recursive: true, force: true }), + }; + } catch (error) { + await rm(outDir, { recursive: true, force: true }); + throw error; } } } +class PreviewTanstackStartBuild implements BuildStrategy { + readonly #appPath: string; + readonly #buildSettings?: PreviewBuildSettings; + + constructor(options: { appPath: string; buildSettings?: PreviewBuildSettings }) { + this.#appPath = options.appPath; + this.#buildSettings = options.buildSettings; + } + + async canBuild(signal?: AbortSignal): Promise { + const packageJson = await readBunPackageJson(this.#appPath, signal); + return hasAnyPackageDependency(packageJson, TANSTACK_START_PACKAGES); + } + + async execute(signal?: AbortSignal): Promise { + const settings = this.#buildSettings ?? await resolvePreviewBuildSettings({ + appPath: this.#appPath, + buildType: "tanstack-start", + signal, + }); + await runResolvedBuildCommand(this.#appPath, settings, "TanStack Start", signal); + + const outputDir = path.join(this.#appPath, settings.outputDirectory); + const entrypoint = "server/index.mjs"; + const entryPath = path.join(outputDir, entrypoint); + const entryStat = await unsupportedFilesystemBoundary(signal, () => stat(entryPath).catch(() => null)); + if (!entryStat?.isFile()) { + throw new Error( + `TanStack Start build did not produce a Nitro node server entrypoint at ${joinPosix(settings.outputDirectory, entrypoint)}. ` + + `Ensure your vite.config includes the tanstackStart() and nitro() plugins with the default node preset, or update ${PRISMA_APP_CONFIG_FILENAME}.`, + ); + } + + const outDir = await unsupportedFilesystemBoundary(signal, () => mkdtemp(path.join(os.tmpdir(), "compute-build-"))); + + try { + const artifactDir = path.join(outDir, "app"); + await unsupportedFilesystemBoundary(signal, () => cp(outputDir, artifactDir, { + recursive: true, + verbatimSymlinks: true, + })); + + return { + directory: artifactDir, + entrypoint, + defaultPortMapping: { http: 3000 }, + cleanup: () => rm(outDir, { recursive: true, force: true }), + }; + } catch (error) { + await rm(outDir, { recursive: true, force: true }); + throw error; + } + } +} + +class PreviewBunBuild implements BuildStrategy { + readonly #appPath: string; + readonly #strategy: BuildStrategy; + readonly #buildSettings?: PreviewBuildSettings; + + constructor(options: { + appPath: string; + strategy: BuildStrategy; + buildSettings?: PreviewBuildSettings; + }) { + this.#appPath = options.appPath; + this.#strategy = options.strategy; + this.#buildSettings = options.buildSettings; + } + + async canBuild(signal?: AbortSignal): Promise { + return this.#strategy.canBuild(signal); + } + + async execute(signal?: AbortSignal): Promise { + const settings = this.#buildSettings ?? await resolvePreviewBuildSettings({ + appPath: this.#appPath, + buildType: "bun", + signal, + }); + await runResolvedBuildCommand(this.#appPath, settings, "Bun", signal); + + return this.#strategy.execute(signal); + } +} + +const NEXT_CONFIG_FILENAMES = [ + "next.config.js", + "next.config.mjs", + "next.config.cjs", + "next.config.ts", + "next.config.mts", +] as const; + +const TANSTACK_START_PACKAGES = [ + "@tanstack/react-start", + "@tanstack/solid-start", + "@tanstack/start", +] as const; + export async function stageNextjsStandaloneArtifact(options: { standaloneDir: string; artifactDir: string; @@ -195,6 +393,73 @@ export async function stageNextjsStandaloneArtifact(options: { await hoistPnpmDependencies(path.join(artifactRoot, "node_modules"), options.signal); } +async function copyNextjsStaticAssets(options: { + appPath: string; + artifactDir: string; + outputRoot: string; + entrypoint: string; + signal?: AbortSignal; +}): Promise { + const serverSubpath = nextjsServerSubpath(options.entrypoint); + const serverDir = serverSubpath + ? path.join(options.artifactDir, serverSubpath) + : options.artifactDir; + + const publicDir = path.join(options.appPath, "public"); + if (await directoryExists(publicDir, options.signal)) { + await unsupportedFilesystemBoundary(options.signal, () => cp(publicDir, path.join(serverDir, "public"), { + recursive: true, + verbatimSymlinks: true, + })); + } + + const staticDir = path.join(options.appPath, options.outputRoot, "static"); + if (await directoryExists(staticDir, options.signal)) { + await unsupportedFilesystemBoundary(options.signal, () => cp(staticDir, path.join(serverDir, options.outputRoot, "static"), { + recursive: true, + verbatimSymlinks: true, + })); + } +} + +async function findNextStandaloneEntrypoint(artifactDir: string, signal?: AbortSignal): Promise { + const rootEntrypoint = path.join(artifactDir, "server.js"); + const rootStat = await unsupportedFilesystemBoundary(signal, () => stat(rootEntrypoint).catch(() => null)); + if (rootStat?.isFile()) { + return "server.js"; + } + + const candidates: string[] = []; + await walk(artifactDir); + candidates.sort((left, right) => left.split("/").length - right.split("/").length || left.localeCompare(right)); + + const selected = candidates[0]; + if (!selected) { + throw new Error(`Next.js standalone output did not contain server.js in ${artifactDir}`); + } + + return selected; + + async function walk(directory: string): Promise { + const entries = await unsupportedFilesystemBoundary(signal, () => readdir(directory, { withFileTypes: true })); + for (const entry of entries) { + if (entry.name === "node_modules") { + continue; + } + + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + continue; + } + + if (entry.isFile() && entry.name === "server.js") { + candidates.push(path.relative(artifactDir, fullPath).split(path.sep).join("/")); + } + } + } +} + export async function restageNextjsArtifact(artifact: BuildArtifact, appPath: string, signal?: AbortSignal): Promise { const artifactDir = artifact.directory; const standaloneDir = path.join(appPath, ".next", "standalone"); diff --git a/packages/cli/src/lib/app/preview-provider.ts b/packages/cli/src/lib/app/preview-provider.ts index 6831563..60468ce 100644 --- a/packages/cli/src/lib/app/preview-provider.ts +++ b/packages/cli/src/lib/app/preview-provider.ts @@ -6,7 +6,7 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { envVarNames } from "./env-vars"; import { PreviewBuildStrategy } from "./preview-build"; -import type { PreviewBuildType } from "./preview-build"; +import type { PreviewBuildSettings, PreviewBuildType } from "./preview-build"; import type { BranchKind } from "../../types/branch"; export interface PreviewAppRecord { @@ -135,6 +135,7 @@ export interface PreviewAppProvider { region?: string; entrypoint?: string; buildType?: PreviewBuildType; + buildSettings?: PreviewBuildSettings; portMapping?: PortMapping; envVars?: Record; interaction?: unknown; @@ -354,6 +355,7 @@ export function createPreviewAppProvider( entrypoint: options.entrypoint, buildType: options.buildType, signal: options.signal, + buildSettings: options.buildSettings, }), projectId: options.projectId, serviceId: resolvedApp.appId, diff --git a/packages/cli/tests/app-build.test.ts b/packages/cli/tests/app-build.test.ts index 758b135..055ba19 100644 --- a/packages/cli/tests/app-build.test.ts +++ b/packages/cli/tests/app-build.test.ts @@ -12,6 +12,315 @@ afterEach(() => { }); describe("preview build strategy", () => { + it("creates prisma.app.json with inferred Next.js settings", async () => { + const { PRISMA_APP_CONFIG_SCHEMA_URL, resolveOrCreatePreviewBuildSettings } = await import("../src/lib/app/preview-build"); + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "app"); + + await mkdir(appPath, { recursive: true }); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify({ + packageManager: "bun@1.2.0", + scripts: { + build: "prisma generate && next build", + }, + dependencies: { + next: "15.0.0", + }, + }, null, 2), + "utf8", + ); + + const resolution = await resolveOrCreatePreviewBuildSettings({ + appPath, + buildType: "nextjs", + }); + + expect(resolution.status).toBe("created"); + expect(resolution.relativeConfigPath).toBe("prisma.app.json"); + expect(resolution.settings).toEqual({ + buildCommand: "bun run build", + buildCommandSource: "package.json scripts.build", + outputDirectory: ".next/standalone", + outputDirectorySource: "Next.js output", + }); + await expect(readFile(path.join(appPath, "prisma.app.json"), "utf8")).resolves.toBe(`${JSON.stringify({ + $schema: PRISMA_APP_CONFIG_SCHEMA_URL, + buildCommand: "bun run build", + outputDirectory: ".next/standalone", + }, null, 2)}\n`); + }); + + it("creates TanStack and Hono build config defaults", async () => { + const { resolveOrCreatePreviewBuildSettings } = await import("../src/lib/app/preview-build"); + const cwd = await createTempCwd(); + const tanstackPath = path.join(cwd, "tanstack"); + const honoPath = path.join(cwd, "hono"); + + await mkdir(tanstackPath, { recursive: true }); + await writeFile( + path.join(tanstackPath, "package.json"), + JSON.stringify({ + dependencies: { + "@tanstack/react-start": "1.0.0", + }, + }, null, 2), + "utf8", + ); + await mkdir(honoPath, { recursive: true }); + await writeFile( + path.join(honoPath, "package.json"), + JSON.stringify({ + dependencies: { + hono: "4.0.0", + }, + }, null, 2), + "utf8", + ); + + await expect(resolveOrCreatePreviewBuildSettings({ + appPath: tanstackPath, + buildType: "tanstack-start", + })).resolves.toMatchObject({ + status: "created", + settings: { + buildCommand: "vite build", + outputDirectory: ".output", + }, + }); + await expect(resolveOrCreatePreviewBuildSettings({ + appPath: honoPath, + buildType: "bun", + })).resolves.toMatchObject({ + status: "created", + settings: { + buildCommand: null, + outputDirectory: ".", + }, + }); + }); + + it("uses an existing prisma.app.json without overwriting it", async () => { + const { resolveOrCreatePreviewBuildSettings } = await import("../src/lib/app/preview-build"); + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "app"); + const configPath = path.join(appPath, "prisma.app.json"); + const config = { + $schema: "custom-schema", + buildCommand: null, + outputDirectory: "custom-output", + }; + + await mkdir(appPath, { recursive: true }); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify({ + scripts: { + build: "next build", + }, + dependencies: { + next: "15.0.0", + }, + }, null, 2), + "utf8", + ); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + + await expect(resolveOrCreatePreviewBuildSettings({ + appPath, + buildType: "nextjs", + })).resolves.toMatchObject({ + status: "used", + settings: { + buildCommand: null, + buildCommandSource: null, + outputDirectory: "custom-output", + outputDirectorySource: null, + }, + }); + await expect(readFile(configPath, "utf8")).resolves.toBe(`${JSON.stringify(config, null, 2)}\n`); + }); + + it("rejects invalid prisma.app.json files", async () => { + const { resolveOrCreatePreviewBuildSettings } = await import("../src/lib/app/preview-build"); + const cwd = await createTempCwd(); + const invalidJsonPath = path.join(cwd, "invalid-json"); + const escapingPath = path.join(cwd, "escaping-output"); + + await mkdir(invalidJsonPath, { recursive: true }); + await writeFile(path.join(invalidJsonPath, "prisma.app.json"), "{ nope\n", "utf8"); + await mkdir(escapingPath, { recursive: true }); + await writeFile( + path.join(escapingPath, "prisma.app.json"), + JSON.stringify({ + buildCommand: "bun run build", + outputDirectory: "../dist", + }, null, 2), + "utf8", + ); + + await expect(resolveOrCreatePreviewBuildSettings({ + appPath: invalidJsonPath, + buildType: "nextjs", + })).rejects.toMatchObject({ + code: "APP_CONFIG_INVALID", + domain: "app", + }); + await expect(resolveOrCreatePreviewBuildSettings({ + appPath: escapingPath, + buildType: "nextjs", + })).rejects.toMatchObject({ + code: "APP_CONFIG_INVALID", + domain: "app", + }); + }); + + it("resolves package.json build scripts and literal framework output directories", async () => { + const { resolvePreviewBuildSettings } = await import("../src/lib/app/preview-build"); + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "app"); + + await mkdir(appPath, { recursive: true }); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify({ + packageManager: "pnpm@10.0.0", + scripts: { + build: "prisma generate && next build", + }, + dependencies: { + next: "15.0.0", + }, + }, null, 2), + "utf8", + ); + await writeFile( + path.join(appPath, "next.config.js"), + "module.exports = { output: 'standalone', distDir: 'build' };\n", + "utf8", + ); + + await expect(resolvePreviewBuildSettings({ + appPath, + buildType: "nextjs", + })).resolves.toEqual({ + buildCommand: "pnpm run build", + buildCommandSource: "package.json scripts.build", + outputDirectory: "build/standalone", + outputDirectorySource: "next.config distDir", + }); + }); + + it("detects the package manager for package.json build scripts", async () => { + const { resolvePreviewBuildSettings } = await import("../src/lib/app/preview-build"); + const cases = [ + { lockfile: "bun.lock", command: "bun run build" }, + { lockfile: "pnpm-lock.yaml", command: "pnpm run build" }, + { lockfile: "yarn.lock", command: "yarn run build" }, + { lockfile: "package-lock.json", command: "npm run build" }, + ]; + + for (const testCase of cases) { + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "app"); + + await mkdir(appPath, { recursive: true }); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify({ + scripts: { + build: "next build", + }, + dependencies: { + next: "15.0.0", + }, + }, null, 2), + "utf8", + ); + await writeFile(path.join(appPath, testCase.lockfile), "", "utf8"); + + await expect(resolvePreviewBuildSettings({ + appPath, + buildType: "nextjs", + })).resolves.toMatchObject({ + buildCommand: testCase.command, + buildCommandSource: "package.json scripts.build", + }); + } + }); + + it("runs package.json build scripts before staging Next.js output", async () => { + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "app"); + + await mkdir(appPath, { recursive: true }); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify({ + packageManager: "npm@10.0.0", + scripts: { + build: "node build.mjs", + }, + dependencies: { + next: "15.0.0", + }, + }, null, 2), + "utf8", + ); + await writeFile( + path.join(appPath, "build.mjs"), + [ + "import { mkdir, writeFile } from 'node:fs/promises';", + "await mkdir('.next/standalone', { recursive: true });", + "await mkdir('.next/static', { recursive: true });", + "await mkdir('public', { recursive: true });", + "await writeFile('.next/standalone/server.js', \"console.log('next');\\n\");", + "await writeFile('.next/static/client.js', \"console.log('static');\\n\");", + "await writeFile('public/hello.txt', 'hello\\n');", + ].join("\n"), + "utf8", + ); + + const { executePreviewBuild } = await import("../src/lib/app/preview-build"); + const result = await executePreviewBuild({ + appPath, + buildType: "nextjs", + }); + + expect(result.buildType).toBe("nextjs"); + expect(result.artifact.entrypoint).toBe("server.js"); + await expect(readFile(path.join(result.artifact.directory, ".next", "static", "client.js"), "utf8")).resolves.toContain("static"); + await expect(readFile(path.join(result.artifact.directory, "public", "hello.txt"), "utf8")).resolves.toContain("hello"); + await result.artifact.cleanup?.(); + }); + + it("skips the build command when prisma.app.json sets buildCommand to null", async () => { + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "app"); + const outputDir = path.join(appPath, ".next", "standalone"); + + await mkdir(outputDir, { recursive: true }); + await writeFile(path.join(outputDir, "server.js"), "console.log('prebuilt');\n", "utf8"); + + const { executePreviewBuild } = await import("../src/lib/app/preview-build"); + const result = await executePreviewBuild({ + appPath, + buildType: "nextjs", + buildSettings: { + buildCommand: null, + buildCommandSource: null, + outputDirectory: ".next/standalone", + outputDirectorySource: null, + }, + }); + + expect(result.buildType).toBe("nextjs"); + expect(result.artifact.entrypoint).toBe("server.js"); + await expect(readFile(path.join(result.artifact.directory, "server.js"), "utf8")).resolves.toContain("prebuilt"); + await result.artifact.cleanup?.(); + }); + it("returns the Next.js default HTTP port mapping in the built artifact", async () => { const cwd = await createTempCwd(); const appPath = path.join(cwd, "app"); diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 1e1ef12..a918fcf 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -147,8 +147,8 @@ async function writePackageJson( packageJson: { name?: string; module?: string; - dependencies?: Record; - devDependencies?: Record; + dependencies?: Record; + devDependencies?: Record; }, ): Promise { await writeFile(path.join(cwd, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); @@ -163,6 +163,10 @@ async function readLocalPin(cwd: string): Promise { return JSON.parse(await readFile(path.join(cwd, ".prisma/local.json"), "utf8")); } +async function readPrismaAppConfig(cwd: string): Promise { + return JSON.parse(await readFile(path.join(cwd, "prisma.app.json"), "utf8")); +} + async function writeLocalPin( cwd: string, pin: unknown | string, @@ -372,7 +376,6 @@ describe("app controller", () => { await runAppDeploy(context, "hello-world", { projectRef: "proj_123", entrypoint: "server.ts", - buildType: "bun", httpPort: "8080", envAssignments: ["DATABASE_URL=postgresql://example"], }); @@ -1170,6 +1173,12 @@ describe("app controller", () => { branchName: "feat-j1", appName: "my-app", buildType: "nextjs", + buildSettings: { + buildCommand: "next build", + buildCommandSource: "Next.js default", + outputDirectory: ".next/standalone", + outputDirectorySource: "Next.js output", + }, portMapping: { http: 3000 }, signal: context.runtime.signal, }), @@ -1200,6 +1209,16 @@ describe("app controller", () => { expect(stderr.buffer).toContain(`Linked "./${path.basename(cwd)}" to Project "my-app"`); expect(stderr.buffer).toContain("Saved .prisma/local.json"); expect(stderr.buffer).toContain("Deploying to my-app / feat-j1 / my-app"); + expect(stderr.buffer).toContain("Created prisma.app.json"); + expect(stderr.buffer).toContain("Build Command"); + expect(stderr.buffer).toContain("next build"); + expect(stderr.buffer).toContain("Output Directory"); + expect(stderr.buffer).toContain(".next/standalone"); + await expect(readPrismaAppConfig(cwd)).resolves.toEqual({ + $schema: "https://pris.ly/schemas/prisma-app-config.v1.json", + buildCommand: "next build", + outputDirectory: ".next/standalone", + }); await expect(readLocalPin(cwd)).resolves.toEqual({ workspaceId: "ws_123", projectId: "proj_my_app", @@ -1207,6 +1226,92 @@ describe("app controller", () => { await expect(readFile(path.join(cwd, ".gitignore"), "utf8")).resolves.toBe(".prisma/\n"); }); + it("uses existing prisma.app.json deploy settings", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const listApps = vi.fn().mockResolvedValue([ + { id: "app_1", name: "hello-world", region: "eu-central-1", liveDeploymentId: null, liveUrl: null }, + ]); + const deployApp = vi.fn().mockResolvedValue({ + projectId: "proj_123", + app: { + id: "app_1", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: "dep_123", + liveUrl: "https://hello-world.prisma.app", + }, + deployment: { + id: "dep_123", + status: "running", + url: "https://hello-world.prisma.app", + }, + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/lib/app/preview-provider", () => ({ + createPreviewAppProvider: vi.fn(() => ({ + resolveBranch: createResolveBranch(), + listApps, + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + })), + })); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + await writePackageJson(cwd, { + name: "hello-world", + dependencies: { + next: "15.0.0", + }, + }); + await writeFile( + path.join(cwd, "prisma.app.json"), + `${JSON.stringify({ + $schema: "https://pris.ly/schemas/prisma-app-config.v1.json", + buildCommand: "bun run build", + outputDirectory: ".next/standalone", + }, null, 2)}\n`, + "utf8", + ); + const stateDir = path.join(cwd, ".state"); + const { context, stderr } = await createTestCommandContext({ + cwd, + stateDir, + isTTY: false, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + framework: "nextjs", + }); + + expect(deployApp).toHaveBeenCalledWith( + expect.objectContaining({ + buildType: "nextjs", + buildSettings: { + buildCommand: "bun run build", + buildCommandSource: null, + outputDirectory: ".next/standalone", + outputDirectorySource: null, + }, + }), + ); + expect(stderr.buffer).toContain("Using prisma.app.json"); + expect(stderr.buffer).toContain("Build Command"); + expect(stderr.buffer).toContain("bun run build"); + expect(stderr.buffer).toContain("Output Directory"); + expect(stderr.buffer).toContain(".next/standalone"); + }); + it("writes the local binding before build failures and renders build-failure copy", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const listApps = vi.fn().mockResolvedValue([]); From 5bc6e5a856280adb77cc3b40bf809857c2e5b167 Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 3 Jun 2026 04:22:19 +0200 Subject: [PATCH 2/3] refactor(config): tighten deploy build settings types --- packages/cli/src/controllers/app.ts | 25 +++++++------------ .../cli/src/lib/app/preview-build-settings.ts | 7 +++--- packages/cli/src/lib/app/preview-build.ts | 1 + 3 files changed, 14 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 30b2255..d7a2553 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -75,6 +75,7 @@ import { PREVIEW_BUILD_TYPES, RESOLVED_PREVIEW_BUILD_TYPES, resolveOrCreatePreviewBuildSettings, + type PreviewBuildSettingsBuildType, type PreviewBuildSettingsResolution, type ResolvedPreviewBuildType, type PreviewBuildType, @@ -320,16 +321,12 @@ export async function runAppDeploy( const buildType = framework.buildType; assertSupportedEntrypoint(buildType, options?.entrypoint, "deploy"); const entrypoint = await resolveDeployEntrypoint(context.runtime.cwd, framework, options?.entrypoint, context.runtime.signal); - const buildSettingsResolution = usesPreviewBuildSettings(buildType) - ? await resolveOrCreatePreviewBuildSettings({ - appPath: context.runtime.cwd, - buildType, - signal: context.runtime.signal, - }) - : null; - if (buildSettingsResolution) { - maybeRenderDeployBuildSettings(context, buildSettingsResolution); - } + const buildSettingsResolution = await resolveOrCreatePreviewBuildSettings({ + appPath: context.runtime.cwd, + buildType, + signal: context.runtime.signal, + }); + maybeRenderDeployBuildSettings(context, buildSettingsResolution); const portMapping = parseDeployPortMapping(String(runtime.port)); const progressState = createPreviewDeployProgressState(); @@ -343,7 +340,7 @@ export async function runAppDeploy( region: selectedApp.region, entrypoint, buildType, - buildSettings: buildSettingsResolution?.settings, + buildSettings: buildSettingsResolution.settings, portMapping, envVars, interaction: undefined, @@ -2597,7 +2594,7 @@ async function resolveGitHeadPath(gitPath: string, signal: AbortSignal): Promise interface ResolvedDeployFramework { key: string; - buildType: ResolvedPreviewBuildType; + buildType: PreviewBuildSettingsBuildType; displayName: string; annotation: string; } @@ -2868,10 +2865,6 @@ async function maybeRenderDeploySetupBlock( context.output.stderr.write(`${prefix} ${details.projectName} / ${details.branchName} / ${details.appName}\n\n`); } -function usesPreviewBuildSettings(buildType: ResolvedPreviewBuildType): boolean { - return buildType === "nextjs" || buildType === "tanstack-start" || buildType === "bun"; -} - function maybeRenderDeployBuildSettings( context: CommandContext, resolution: PreviewBuildSettingsResolution, diff --git a/packages/cli/src/lib/app/preview-build-settings.ts b/packages/cli/src/lib/app/preview-build-settings.ts index 331ecaa..de452e3 100644 --- a/packages/cli/src/lib/app/preview-build-settings.ts +++ b/packages/cli/src/lib/app/preview-build-settings.ts @@ -7,6 +7,7 @@ import { readBunPackageJson, type BunPackageJsonLike } from "./bun-project"; import type { ResolvedPreviewBuildType } from "./preview-build"; type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; +export type PreviewBuildSettingsBuildType = Extract; export const PRISMA_APP_CONFIG_FILENAME = "prisma.app.json"; export const PRISMA_APP_CONFIG_SCHEMA_URL = "https://pris.ly/schemas/prisma-app-config.v1.json"; @@ -32,7 +33,7 @@ export interface PreviewBuildSettingsResolution { export async function resolveOrCreatePreviewBuildSettings(options: { appPath: string; - buildType: ResolvedPreviewBuildType; + buildType: PreviewBuildSettingsBuildType; signal?: AbortSignal; }): Promise { const configPath = path.join(options.appPath, PRISMA_APP_CONFIG_FILENAME); @@ -95,7 +96,7 @@ export async function resolveOrCreatePreviewBuildSettings(options: { export async function resolvePreviewBuildSettings(options: { appPath: string; - buildType: ResolvedPreviewBuildType; + buildType: PreviewBuildSettingsBuildType; signal?: AbortSignal; }): Promise { switch (options.buildType) { @@ -128,7 +129,7 @@ export async function resolvePreviewBuildSettings(options: { outputDirectorySource: "TanStack Start output", }; } - default: { + case "bun": { const packageJson = await readBunPackageJson(options.appPath, options.signal); const buildCommand = await resolveFrameworkBuildCommand(options.appPath, packageJson, { command: null, diff --git a/packages/cli/src/lib/app/preview-build.ts b/packages/cli/src/lib/app/preview-build.ts index b4f02c8..cf2286a 100644 --- a/packages/cli/src/lib/app/preview-build.ts +++ b/packages/cli/src/lib/app/preview-build.ts @@ -27,6 +27,7 @@ export { PRISMA_APP_CONFIG_SCHEMA_URL, resolveOrCreatePreviewBuildSettings, resolvePreviewBuildSettings, + type PreviewBuildSettingsBuildType, type PreviewBuildSettings, type PreviewBuildSettingsResolution, } from "./preview-build-settings"; From eb71701bf226b8656cc3c9837e079f1b37f8726c Mon Sep 17 00:00:00 2001 From: Luan van der Westhuizen Date: Wed, 3 Jun 2026 11:19:32 +0200 Subject: [PATCH 3/3] fix(config): address app config review feedback --- docs/product/error-conventions.md | 1 + packages/cli/src/controllers/app.ts | 1 - .../cli/src/lib/app/preview-build-settings.ts | 240 +++++++++++++++--- packages/cli/src/lib/app/preview-build.ts | 1 - packages/cli/tests/app-build.test.ts | 128 ++++++++++ 5 files changed, 333 insertions(+), 38 deletions(-) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index b2fb71b..f4156e3 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -168,6 +168,7 @@ These codes are the minimum stable set for the MVP: - `APP_AMBIGUOUS` - `LOCAL_STATE_STALE` - `BRANCH_NOT_DEPLOYABLE` +- `APP_CONFIG_INVALID` - `FRAMEWORK_NOT_DETECTED` - `DEPLOYMENT_NOT_FOUND` - `NO_DEPLOYMENTS` diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index d7a2553..7dd727e 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -2739,7 +2739,6 @@ async function detectNextConfig(cwd: string, signal: AbortSignal): Promise<{ exi const candidates = [ "next.config.js", "next.config.mjs", - "next.config.cjs", "next.config.ts", "next.config.mts", ]; diff --git a/packages/cli/src/lib/app/preview-build-settings.ts b/packages/cli/src/lib/app/preview-build-settings.ts index de452e3..09b848e 100644 --- a/packages/cli/src/lib/app/preview-build-settings.ts +++ b/packages/cli/src/lib/app/preview-build-settings.ts @@ -2,6 +2,8 @@ import { exec } from "node:child_process"; import { readdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; +import { parseModule, type ASTNode } from "magicast"; + import { CliError } from "../../shell/errors"; import { readBunPackageJson, type BunPackageJsonLike } from "./bun-project"; import type { ResolvedPreviewBuildType } from "./preview-build"; @@ -17,6 +19,11 @@ interface ResolvedBuildCommand { source: string | null; } +interface StaticNextConfig { + distDir?: string; + output?: "standalone" | "export"; +} + export interface PreviewBuildSettings { buildCommand: string | null; buildCommandSource: string | null; @@ -214,24 +221,15 @@ function normalizeConfigOutputDirectory(configPath: string, value: unknown): str throw invalidPrismaAppConfigError(configPath, "The outputDirectory field must be a non-empty string."); } - const normalized = path.normalize(value.trim()); - if ( - path.isAbsolute(normalized) || - normalized === ".." || - normalized.startsWith(`..${path.sep}`) || - normalized.includes(`${path.sep}..${path.sep}`) - ) { + const normalized = normalizeRelativePath(value); + if (!normalized) { throw invalidPrismaAppConfigError( configPath, "The outputDirectory field must be a relative path inside the app directory.", ); } - if (normalized === ".") { - return "."; - } - - return normalized.split(path.sep).join("/"); + return normalized; } function invalidPrismaAppConfigError(configPath: string, why: string): CliError { @@ -296,6 +294,13 @@ async function resolveFrameworkBuildCommand( ): Promise { if (hasBuildScript(packageJson)) { const packageManager = await resolvePackageManager(appPath, packageJson, fallback.signal); + if (!packageManager) { + return { + command: fallback.command, + source: fallback.source, + }; + } + return { command: `${packageManager} run build`, source: "package.json scripts.build", @@ -321,7 +326,7 @@ async function resolvePackageManager( appPath: string, packageJson: BunPackageJsonLike | null, signal?: AbortSignal, -): Promise { +): Promise { const fromPackageManager = packageManagerFromPackageJson(packageJson?.packageManager); if (fromPackageManager) { return fromPackageManager; @@ -342,8 +347,6 @@ async function resolvePackageManager( if (await pathExists(path.join(appPath, "package-lock.json"), signal)) { return "npm"; } - - return "bun"; } function packageManagerFromPackageJson(value: unknown): PackageManager | null { @@ -407,7 +410,7 @@ async function resolveNextOutputRoot(appPath: string, signal?: AbortSignal): Pro return config.distDir ?? ".next"; } -async function readNextConfig(appPath: string, signal?: AbortSignal): Promise<{ distDir?: string }> { +async function readNextConfig(appPath: string, signal?: AbortSignal): Promise { for (const fileName of NEXT_CONFIG_FILENAMES) { const filePath = path.join(appPath, fileName); let content: string; @@ -421,9 +424,7 @@ async function readNextConfig(appPath: string, signal?: AbortSignal): Promise<{ throw error; } - return { - distDir: readStaticDistDir(content), - }; + return readStaticNextConfig(content); } return {}; @@ -432,29 +433,31 @@ async function readNextConfig(appPath: string, signal?: AbortSignal): Promise<{ const NEXT_CONFIG_FILENAMES = [ "next.config.js", "next.config.mjs", - "next.config.cjs", "next.config.ts", "next.config.mts", ] as const; -function readStaticDistDir(content: string): string | undefined { - const match = /\bdistDir\s*:\s*["'`]([^"'`]+)["'`]/.exec(content); - const rawDistDir = match?.[1]?.trim(); - if (!rawDistDir) { - return undefined; - } +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 normalized = path.normalize(rawDistDir); - if ( - path.isAbsolute(normalized) || - normalized === ".." || - normalized.startsWith(`..${path.sep}`) || - normalized.includes(`${path.sep}..${path.sep}`) - ) { - return undefined; - } + const rawDistDir = readStaticStringProperty(configObject, "distDir"); + const output = readStaticStringProperty(configObject, "output"); + const distDir = rawDistDir ? normalizeRelativePath(rawDistDir) : undefined; - return normalized.split(path.sep).join("/"); + return { + distDir, + output: output === "standalone" || output === "export" ? output : undefined, + }; + } catch { + return {}; + } } export function joinPosix(...parts: string[]): string { @@ -476,6 +479,171 @@ export function nextOutputRootFromStandaloneDirectory(outputDirectory: string): return dirname === "." ? "." : dirname; } +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; +} + +function normalizeRelativePath(value: string): string | undefined { + const raw = value.trim().replace(/\\/g, "/"); + if (raw.length === 0 || raw.split("/").includes("..")) { + return undefined; + } + + const normalized = path.posix.normalize(raw); + const segments = normalized.split("/"); + if ( + path.win32.isAbsolute(value) || + path.posix.isAbsolute(normalized) || + segments.includes("..") + ) { + return undefined; + } + + return normalized === "." ? "." : normalized; +} + async function pathExists(targetPath: string, signal?: AbortSignal): Promise { try { signal?.throwIfAborted(); diff --git a/packages/cli/src/lib/app/preview-build.ts b/packages/cli/src/lib/app/preview-build.ts index cf2286a..04f6fd6 100644 --- a/packages/cli/src/lib/app/preview-build.ts +++ b/packages/cli/src/lib/app/preview-build.ts @@ -363,7 +363,6 @@ class PreviewBunBuild implements BuildStrategy { const NEXT_CONFIG_FILENAMES = [ "next.config.js", "next.config.mjs", - "next.config.cjs", "next.config.ts", "next.config.mts", ] as const; diff --git a/packages/cli/tests/app-build.test.ts b/packages/cli/tests/app-build.test.ts index 055ba19..d9dd979 100644 --- a/packages/cli/tests/app-build.test.ts +++ b/packages/cli/tests/app-build.test.ts @@ -212,6 +212,82 @@ describe("preview build strategy", () => { }); }); + it("only reads Next.js distDir from the exported config object", async () => { + const { resolvePreviewBuildSettings } = await import("../src/lib/app/preview-build"); + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "app"); + + await mkdir(appPath, { recursive: true }); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify({ + packageManager: "pnpm@10.0.0", + scripts: { + build: "next build", + }, + dependencies: { + next: "15.0.0", + }, + }, null, 2), + "utf8", + ); + await writeFile( + path.join(appPath, "next.config.ts"), + [ + "const unrelated = { distDir: 'wrong' };", + "const nextConfig = { output: 'standalone', distDir: 'build' } satisfies object;", + "export default defineConfig(nextConfig);", + ].join("\n"), + "utf8", + ); + + await expect(resolvePreviewBuildSettings({ + appPath, + buildType: "nextjs", + })).resolves.toMatchObject({ + outputDirectory: "build/standalone", + outputDirectorySource: "next.config distDir", + }); + }); + + it("ignores commented or unrelated Next.js distDir values", async () => { + const { resolvePreviewBuildSettings } = await import("../src/lib/app/preview-build"); + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "app"); + + await mkdir(appPath, { recursive: true }); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify({ + packageManager: "pnpm@10.0.0", + scripts: { + build: "next build", + }, + dependencies: { + next: "15.0.0", + }, + }, null, 2), + "utf8", + ); + await writeFile( + path.join(appPath, "next.config.js"), + [ + "// distDir: 'commented'", + "const unrelated = { distDir: 'wrong' };", + "module.exports = { output: 'standalone' };", + ].join("\n"), + "utf8", + ); + + await expect(resolvePreviewBuildSettings({ + appPath, + buildType: "nextjs", + })).resolves.toMatchObject({ + outputDirectory: ".next/standalone", + outputDirectorySource: "Next.js output", + }); + }); + it("detects the package manager for package.json build scripts", async () => { const { resolvePreviewBuildSettings } = await import("../src/lib/app/preview-build"); const cases = [ @@ -250,6 +326,58 @@ describe("preview build strategy", () => { } }); + it("uses the framework build command when scripts.build exists but no package manager is detected", async () => { + const { resolvePreviewBuildSettings } = await import("../src/lib/app/preview-build"); + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "app"); + + await mkdir(appPath, { recursive: true }); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify({ + scripts: { + build: "custom-build", + }, + dependencies: { + next: "15.0.0", + }, + }, null, 2), + "utf8", + ); + + await expect(resolvePreviewBuildSettings({ + appPath, + buildType: "nextjs", + })).resolves.toMatchObject({ + buildCommand: "next build", + buildCommandSource: "Next.js default", + }); + }); + + it("does not detect unsupported next.config.cjs files as Next.js", async () => { + const { resolvePreviewBuildStrategy } = await import("../src/lib/app/preview-build"); + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "app"); + + await mkdir(appPath, { recursive: true }); + await writeFile( + path.join(appPath, "package.json"), + JSON.stringify({ + module: "index.ts", + }, null, 2), + "utf8", + ); + await writeFile(path.join(appPath, "index.ts"), "export default { fetch: () => new Response('ok') };\n", "utf8"); + await writeFile(path.join(appPath, "next.config.cjs"), "module.exports = { output: 'standalone' };\n", "utf8"); + + await expect(resolvePreviewBuildStrategy({ + appPath, + buildType: "auto", + })).resolves.toMatchObject({ + buildType: "bun", + }); + }); + it("runs package.json build scripts before staging Next.js output", async () => { const cwd = await createTempCwd(); const appPath = path.join(cwd, "app");