Skip to content

Commit 683504d

Browse files
Merge pull request #82 from prisma/feat/sandbox-build-fallbacks
feat(app): degrade gracefully when standalone output or the prisma CLI is missing
2 parents f4631c4 + d3ec9c6 commit 683504d

4 files changed

Lines changed: 313 additions & 12 deletions

File tree

packages/cli/src/lib/app/branch-database.ts

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ export async function runBranchDatabaseSchemaSetup(options: {
134134
directUrl: string | null;
135135
}): Promise<BranchDatabaseSchemaSetupResult> {
136136
const schemaPath = path.relative(options.context.runtime.cwd, options.schema.path) || defaultSchemaSourcePath(options.schema);
137-
const commands = buildSchemaSetupCommands(options.schema, schemaPath, options.databaseUrl);
137+
const prisma = await resolvePrismaInvocation(options.context.runtime.cwd);
138+
const commands = buildSchemaSetupCommands(options.schema, schemaPath, options.databaseUrl, prisma);
138139

139140
for (const command of commands) {
140141
await runPrismaCommand({
@@ -411,21 +412,92 @@ async function readTextFileIfSmall(filePath: string, signal: AbortSignal): Promi
411412
return readFile(filePath, { encoding: "utf8", signal });
412413
}
413414

414-
function buildSchemaSetupCommands(schema: BranchDatabaseSchema, schemaPath: string, databaseUrl: string): Array<{
415+
// Last resort for repos that ship a schema with no Prisma packages
416+
// installed at all. Pinned to the 6.x line: Prisma 7 rejects the classic
417+
// `url = env(...)` datasource form (P1012), which is exactly the schema
418+
// shape such repos have. Bump deliberately, never to `latest`.
419+
const FALLBACK_PRISMA_CLI_VERSION = "6.19.3";
420+
421+
interface PrismaInvocation {
422+
argsPrefix: string[];
423+
displayPrefix: string;
424+
}
425+
426+
/**
427+
* Picks how `prisma` CLI commands are invoked for schema setup. Projects
428+
* with the CLI installed run their own binary (version-exact). Projects
429+
* without it fall back to a versioned `npx prisma@<x>` pinned to the
430+
* installed `@prisma/client` — never bare `npx prisma`, which resolves to
431+
* latest and can be a major version ahead of the project's schema.
432+
*/
433+
async function resolvePrismaInvocation(cwd: string): Promise<PrismaInvocation> {
434+
if (await localPrismaBinExists(cwd)) {
435+
return {
436+
argsPrefix: ["--no-install", "prisma"],
437+
displayPrefix: "npx --no-install prisma",
438+
};
439+
}
440+
441+
const clientVersion = await readInstalledPrismaClientVersion(cwd);
442+
const pinned = clientVersion ?? FALLBACK_PRISMA_CLI_VERSION;
443+
return {
444+
argsPrefix: ["--yes", `prisma@${pinned}`],
445+
displayPrefix: `npx prisma@${pinned}`,
446+
};
447+
}
448+
449+
/** npm/pnpm name the local CLI shim `prisma` on POSIX and `prisma.cmd`/`prisma.ps1` on Windows. */
450+
async function localPrismaBinExists(cwd: string): Promise<boolean> {
451+
const binDir = path.join(cwd, "node_modules", ".bin");
452+
const checks = await Promise.all(
453+
["prisma", "prisma.cmd", "prisma.ps1"].map((name) =>
454+
fileExists(path.join(binDir, name)),
455+
),
456+
);
457+
return checks.some(Boolean);
458+
}
459+
460+
async function fileExists(filePath: string): Promise<boolean> {
461+
try {
462+
await access(filePath);
463+
return true;
464+
} catch {
465+
return false;
466+
}
467+
}
468+
469+
async function readInstalledPrismaClientVersion(cwd: string): Promise<string | null> {
470+
try {
471+
const raw = await readFile(
472+
path.join(cwd, "node_modules", "@prisma", "client", "package.json"),
473+
{ encoding: "utf8" },
474+
);
475+
const parsed: unknown = JSON.parse(raw);
476+
if (typeof parsed !== "object" || parsed === null) {
477+
return null;
478+
}
479+
const version = (parsed as { version?: unknown }).version;
480+
return typeof version === "string" && version.length > 0 ? version : null;
481+
} catch {
482+
return null;
483+
}
484+
}
485+
486+
function buildSchemaSetupCommands(schema: BranchDatabaseSchema, schemaPath: string, databaseUrl: string, prisma: PrismaInvocation): Array<{
415487
args: string[];
416488
displayCommand: string;
417489
}> {
418490
if (schema.command === "migrate-deploy") {
419491
return [{
420-
args: ["--no-install", "prisma", "migrate", "deploy", "--schema", schemaPath],
421-
displayCommand: "npx --no-install prisma migrate deploy",
492+
args: [...prisma.argsPrefix, "migrate", "deploy", "--schema", schemaPath],
493+
displayCommand: `${prisma.displayPrefix} migrate deploy`,
422494
}];
423495
}
424496

425497
if (schema.command === "db-push") {
426498
return [{
427-
args: ["--no-install", "prisma", "db", "push", "--schema", schemaPath],
428-
displayCommand: "npx --no-install prisma db push",
499+
args: [...prisma.argsPrefix, "db", "push", "--schema", schemaPath],
500+
displayCommand: `${prisma.displayPrefix} db push`,
429501
}];
430502
}
431503

packages/cli/src/lib/app/preview-build.ts

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { chmod, copyFile, cp, lstat, mkdir, mkdtemp, readdir, readFile, readlink, rm, stat } from "node:fs/promises";
1+
import { chmod, copyFile, cp, lstat, mkdir, mkdtemp, readdir, readFile, readlink, rm, stat, writeFile } from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
44

@@ -236,10 +236,10 @@ class PreviewNextjsBuild implements BuildStrategy {
236236

237237
const standaloneDir = path.join(this.#appPath, settings.outputDirectory);
238238
if (!await directoryExists(standaloneDir, signal)) {
239-
throw new Error(
240-
`Next.js build did not produce standalone output at ${settings.outputDirectory}. `
241-
+ `Add output: "standalone" to your next.config file, or update ${PRISMA_APP_CONFIG_FILENAME}.`,
242-
);
239+
// No `output: "standalone"` in next.config: the build itself
240+
// succeeded, so package the full tree and serve with `next start`
241+
// instead of failing. Bigger artifact, same running app.
242+
return stageNextjsFullTreeFallbackArtifact(this.#appPath, signal);
243243
}
244244

245245
const outDir = await unsupportedFilesystemBoundary(signal, () => mkdtemp(path.join(os.tmpdir(), "compute-build-")));
@@ -274,6 +274,66 @@ class PreviewNextjsBuild implements BuildStrategy {
274274
}
275275
}
276276

277+
const FULL_TREE_NEXT_START_ENTRYPOINT = "prisma-next-start.cjs";
278+
279+
// Bootstrap for apps built without `output: "standalone"`. Entering through
280+
// the CLI bin (not `next/dist/server/lib/start-server`) keeps Next in charge
281+
// of config loading, which is the part that drifts across Next majors.
282+
const FULL_TREE_NEXT_START_SOURCE = [
283+
// The runtime starts us at the unpack root, but `next start` resolves `.next` from cwd.
284+
"process.chdir(__dirname);",
285+
'process.env.NODE_ENV = "production";',
286+
'process.argv.push("start", "-p", process.env.PORT ?? "3000");',
287+
'require("next/dist/bin/next");',
288+
"",
289+
].join("\n");
290+
291+
async function stageNextjsFullTreeFallbackArtifact(appPath: string, signal?: AbortSignal): Promise<BuildArtifact> {
292+
const outDir = await unsupportedFilesystemBoundary(signal, () => mkdtemp(path.join(os.tmpdir(), "compute-build-")));
293+
294+
try {
295+
const artifactDir = path.join(outDir, "app");
296+
// node_modules ships on purpose: without standalone output the artifact
297+
// must carry the full runtime dependency tree for `next start`.
298+
await unsupportedFilesystemBoundary(signal, () =>
299+
cp(appPath, artifactDir, {
300+
recursive: true,
301+
// Keep relative symlinks relative (node_modules/.bin/*): the default
302+
// rewrites them to absolute paths into the source tree, which the
303+
// archiver rejects as escaping the artifact root.
304+
verbatimSymlinks: true,
305+
filter: (source) =>
306+
!isExcludedFromFullTreeArtifact(path.basename(source)),
307+
}),
308+
);
309+
await unsupportedFilesystemBoundary(signal, () =>
310+
writeFile(
311+
path.join(artifactDir, FULL_TREE_NEXT_START_ENTRYPOINT),
312+
FULL_TREE_NEXT_START_SOURCE,
313+
),
314+
);
315+
316+
return {
317+
directory: artifactDir,
318+
entrypoint: FULL_TREE_NEXT_START_ENTRYPOINT,
319+
defaultPortMapping: { http: 3000 },
320+
cleanup: () => rm(outDir, { recursive: true, force: true }),
321+
};
322+
} catch (error) {
323+
await rm(outDir, { recursive: true, force: true });
324+
throw error;
325+
}
326+
}
327+
328+
/** Excludes VCS internals and dotenv files (local secrets, superseded by the deploy env). */
329+
function isExcludedFromFullTreeArtifact(basename: string): boolean {
330+
return (
331+
basename === ".git" ||
332+
basename === ".env" ||
333+
basename.startsWith(".env.")
334+
);
335+
}
336+
277337
class PreviewTanstackStartBuild implements BuildStrategy {
278338
readonly #appPath: string;
279339
readonly #buildSettings?: PreviewBuildSettings;

packages/cli/tests/app-branch-database.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ describe("app deploy branch database setup", () => {
123123
);
124124

125125
spawn.mockClear();
126+
await mkdir(path.join(cwd, "node_modules/.bin"), { recursive: true });
127+
await writeFile(path.join(cwd, "node_modules/.bin/prisma"), "");
126128
await runBranchDatabaseSchemaSetup({
127129
context,
128130
schema: {
@@ -150,6 +152,113 @@ describe("app deploy branch database setup", () => {
150152
expect(spawn.mock.calls[0]?.[1]).not.toContain("--skip-generate");
151153
});
152154

155+
it("falls back to a versioned npx prisma matched to @prisma/client when the prisma CLI is not installed", async () => {
156+
const spawn = vi.fn().mockImplementation(() => {
157+
const child = new EventEmitter();
158+
queueMicrotask(() => child.emit("close", 0, null));
159+
return child;
160+
});
161+
vi.doMock("node:child_process", async (importOriginal) => {
162+
const actual = await importOriginal<typeof import("node:child_process")>();
163+
return {
164+
...actual,
165+
spawn,
166+
};
167+
});
168+
169+
const { createTempCwd, createTestCommandContext } = await import("./helpers");
170+
const { runBranchDatabaseSchemaSetup } = await import("../src/lib/app/branch-database");
171+
const cwd = await createTempCwd();
172+
await mkdir(path.join(cwd, "prisma"), { recursive: true });
173+
await writeFile(path.join(cwd, "prisma/schema.prisma"), "datasource db { provider = \"postgresql\" url = env(\"DATABASE_URL\") }\n");
174+
await mkdir(path.join(cwd, "node_modules/@prisma/client"), { recursive: true });
175+
await writeFile(
176+
path.join(cwd, "node_modules/@prisma/client/package.json"),
177+
JSON.stringify({ name: "@prisma/client", version: "5.22.0" }),
178+
);
179+
const { context } = await createTestCommandContext({
180+
cwd,
181+
stateDir: path.join(cwd, ".state"),
182+
flags: {
183+
quiet: true,
184+
},
185+
env: {
186+
...process.env,
187+
PRISMA_CLI_MOCK_FIXTURE_PATH: undefined,
188+
},
189+
});
190+
191+
await runBranchDatabaseSchemaSetup({
192+
context,
193+
schema: {
194+
kind: "prisma-orm",
195+
path: path.join(cwd, "prisma/schema.prisma"),
196+
command: "db-push",
197+
hasMigrations: false,
198+
target: "postgresql",
199+
},
200+
databaseUrl: "postgres://pooled",
201+
directUrl: null,
202+
});
203+
204+
expect(spawn).toHaveBeenCalledWith(
205+
"npx",
206+
["--yes", "prisma@5.22.0", "db", "push", "--schema", "prisma/schema.prisma"],
207+
expect.objectContaining({ cwd }),
208+
);
209+
});
210+
211+
it("falls back to the pinned prisma version when neither the CLI nor @prisma/client is installed", async () => {
212+
const spawn = vi.fn().mockImplementation(() => {
213+
const child = new EventEmitter();
214+
queueMicrotask(() => child.emit("close", 0, null));
215+
return child;
216+
});
217+
vi.doMock("node:child_process", async (importOriginal) => {
218+
const actual = await importOriginal<typeof import("node:child_process")>();
219+
return {
220+
...actual,
221+
spawn,
222+
};
223+
});
224+
225+
const { createTempCwd, createTestCommandContext } = await import("./helpers");
226+
const { runBranchDatabaseSchemaSetup } = await import("../src/lib/app/branch-database");
227+
const cwd = await createTempCwd();
228+
await mkdir(path.join(cwd, "prisma"), { recursive: true });
229+
await writeFile(path.join(cwd, "prisma/schema.prisma"), "datasource db { provider = \"postgresql\" url = env(\"DATABASE_URL\") }\n");
230+
const { context } = await createTestCommandContext({
231+
cwd,
232+
stateDir: path.join(cwd, ".state"),
233+
flags: {
234+
quiet: true,
235+
},
236+
env: {
237+
...process.env,
238+
PRISMA_CLI_MOCK_FIXTURE_PATH: undefined,
239+
},
240+
});
241+
242+
await runBranchDatabaseSchemaSetup({
243+
context,
244+
schema: {
245+
kind: "prisma-orm",
246+
path: path.join(cwd, "prisma/schema.prisma"),
247+
command: "migrate-deploy",
248+
hasMigrations: true,
249+
target: "postgresql",
250+
},
251+
databaseUrl: "postgres://pooled",
252+
directUrl: null,
253+
});
254+
255+
expect(spawn).toHaveBeenCalledWith(
256+
"npx",
257+
["--yes", "prisma@6.19.3", "migrate", "deploy", "--schema", "prisma/schema.prisma"],
258+
expect.objectContaining({ cwd }),
259+
);
260+
});
261+
153262
it("deploy --db creates a branch database, applies schema, and writes branch env overrides before deploying", async () => {
154263
const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient());
155264
const branchId = "branch_feature_db";

packages/cli/tests/app-build.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { lstat, mkdir, readFile, symlink, writeFile } from "node:fs/promises";
1+
import { access, lstat, mkdir, readFile, readlink, symlink, writeFile } from "node:fs/promises";
22
import { createRequire } from "node:module";
33
import path from "node:path";
44

@@ -52,6 +52,66 @@ describe("preview build strategy", () => {
5252
}, null, 2)}\n`);
5353
});
5454

55+
it("packages the full tree with a next start launcher when the build produces no standalone output", async () => {
56+
const { PreviewBuildStrategy } = await import("../src/lib/app/preview-build");
57+
const cwd = await createTempCwd();
58+
const appPath = path.join(cwd, "app");
59+
60+
await mkdir(path.join(appPath, ".next"), { recursive: true });
61+
await mkdir(path.join(appPath, "node_modules/next"), { recursive: true });
62+
await writeFile(
63+
path.join(appPath, "package.json"),
64+
JSON.stringify({ dependencies: { next: "15.0.0" } }),
65+
"utf8",
66+
);
67+
await writeFile(path.join(appPath, ".next/BUILD_ID"), "fallback-test", "utf8");
68+
await writeFile(path.join(appPath, ".env"), "SECRET=should-not-ship", "utf8");
69+
await writeFile(path.join(appPath, ".env.local"), "SECRET=should-not-ship", "utf8");
70+
await writeFile(
71+
path.join(appPath, "node_modules/next/package.json"),
72+
JSON.stringify({ name: "next", version: "15.0.0" }),
73+
"utf8",
74+
);
75+
await mkdir(path.join(appPath, "node_modules/.bin"), { recursive: true });
76+
await symlink("../next/package.json", path.join(appPath, "node_modules/.bin/next-link"));
77+
78+
const strategy = new PreviewBuildStrategy({
79+
appPath,
80+
buildType: "nextjs",
81+
buildSettings: {
82+
buildCommand: null,
83+
buildCommandSource: null,
84+
outputDirectory: ".next/standalone",
85+
outputDirectorySource: null,
86+
},
87+
});
88+
89+
const artifact = await strategy.execute();
90+
try {
91+
expect(artifact.entrypoint).toBe("prisma-next-start.cjs");
92+
expect(artifact.defaultPortMapping).toEqual({ http: 3000 });
93+
94+
const launcher = await readFile(path.join(artifact.directory, "prisma-next-start.cjs"), "utf8");
95+
expect(launcher).toContain('require("next/dist/bin/next")');
96+
expect(launcher).toContain('process.argv.push("start"');
97+
expect(launcher).toContain("process.chdir(__dirname)");
98+
99+
await expect(readFile(path.join(artifact.directory, ".next/BUILD_ID"), "utf8")).resolves.toBe("fallback-test");
100+
await expect(readFile(path.join(artifact.directory, "node_modules/next/package.json"), "utf8")).resolves.toContain("15.0.0");
101+
102+
const linkPath = path.join(artifact.directory, "node_modules/.bin/next-link");
103+
expect((await lstat(linkPath)).isSymbolicLink()).toBe(true);
104+
await expect(readlink(linkPath)).resolves.toBe("../next/package.json");
105+
106+
await expect(access(path.join(artifact.directory, ".env"))).rejects.toThrow();
107+
await expect(access(path.join(artifact.directory, ".env.local"))).rejects.toThrow();
108+
} finally {
109+
const stagedDir = artifact.directory;
110+
await artifact.cleanup?.();
111+
await expect(access(stagedDir)).rejects.toThrow();
112+
}
113+
});
114+
55115
it("creates TanStack and Hono build config defaults", async () => {
56116
const { resolveOrCreatePreviewBuildSettings } = await import("../src/lib/app/preview-build");
57117
const cwd = await createTempCwd();

0 commit comments

Comments
 (0)