From b62b1435a565340225f51cdeaa49d0f20da5f19a Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 7 Sep 2026 17:08:08 +0530 Subject: [PATCH 1/2] fix: protect migration history before forced scaffolding Signed-off-by: Aman Varshney --- README.md | 2 +- src/commands/create-context.ts | 14 ++++++++++++++ src/create-outcome.ts | 1 + src/index.ts | 2 +- src/telemetry/create.ts | 1 + tests/e2e/create-prisma.e2e.test.ts | 30 +++++++++++++++++++++++++++++ tests/telemetry.test.ts | 9 +++++++-- 7 files changed, 55 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ee2edd4..1ec8a72 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ older cached version. Prisma Compute does not support Deno deployments yet. - `--deploy` / `--no-deploy` - `--workspace ` - `--yes` -- `--force`: overwrite generated starter and Prisma files in a non-empty directory. This replaces existing Prisma config, contract, and database-client files; back up edits first. +- `--force`: overwrite generated starter and Prisma files in a non-empty directory without migration history. This replaces existing Prisma config, contract, and database-client files; back up edits first. A non-empty `migrations` path is protected: use a new directory for a fresh starter, or continue working in the existing project with the Prisma CLI. - `--verbose` - `--json` diff --git a/src/commands/create-context.ts b/src/commands/create-context.ts index d3ee642..3cd7b47 100644 --- a/src/commands/create-context.ts +++ b/src/commands/create-context.ts @@ -178,6 +178,20 @@ export const collectCreateContext = Effect.fn("Create.collectContext")(function* }); } + if (force && targetPathState.exists) { + const migrations = yield* inspectTargetPath(path.join(targetDirectory, "migrations")); + if (migrations.exists && !migrations.isEmptyDirectory) { + const message = `Target directory ${formatPathForDisplay(targetDirectory)} contains migration history. Create a starter in a new directory, or continue working in the existing project with the Prisma CLI. --force cannot overwrite a project with existing migrations.`; + yield* Effect.sync(() => cancel(message, { output })); + return yield* new CreateFailure({ + stage: "collect_context", + reason: "target_has_migrations", + message, + errorReported: true, + }); + } + } + const prismaSetupContext = yield* collectPrismaSetupContextEffect(input, { projectDir: targetDirectory, template, diff --git a/src/create-outcome.ts b/src/create-outcome.ts index ac2e933..7f7781a 100644 --- a/src/create-outcome.ts +++ b/src/create-outcome.ts @@ -26,6 +26,7 @@ export const CreateFailureReasonSchema = Schema.Literals([ "invalid_project_name", "target_path_not_directory", "target_directory_not_empty", + "target_has_migrations", "unsupported_configuration", "template_scaffold_failed", "prisma_init_failed", diff --git a/src/index.ts b/src/index.ts index 1bb3f0e..efa36dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,7 +66,7 @@ export const createPrismaCommand = Command.make( workspace: optionalString("workspace", "Prisma workspace id or name to deploy into"), force: optionalBoolean( "force", - "Overwrite generated starter and Prisma files in a non-empty directory", + "Overwrite generated starter and Prisma files in a directory without migration history", ), yes: optionalBoolean("yes", "Skip prompts and accept default choices"), verbose: optionalBoolean("verbose", "Show verbose command output during setup"), diff --git a/src/telemetry/create.ts b/src/telemetry/create.ts index 166b242..13d8c75 100644 --- a/src/telemetry/create.ts +++ b/src/telemetry/create.ts @@ -23,6 +23,7 @@ const expectedRejectionReasons = new Set([ "invalid_project_name", "target_path_not_directory", "target_directory_not_empty", + "target_has_migrations", "unsupported_configuration", "not_authenticated", "workspace_missing", diff --git a/tests/e2e/create-prisma.e2e.test.ts b/tests/e2e/create-prisma.e2e.test.ts index 111f07f..5c0bceb 100644 --- a/tests/e2e/create-prisma.e2e.test.ts +++ b/tests/e2e/create-prisma.e2e.test.ts @@ -7,6 +7,7 @@ import { readdir, realpath, rm, + stat, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -264,6 +265,35 @@ describe("create-prisma e2e", () => { "Unrelated user file\n", ); await runCommand(projectDir, ["bun", "run", "build"]); + + await writeFile(contractPath, `${TEST_PSL_CONTRACT}\n// Keep this existing project edit\n`); + const preservedPaths = ["package.json", "prisma.config.ts", "src/prisma/contract.prisma"]; + const migrationPaths = await readdir(path.join(projectDir, "migrations"), { + recursive: true, + }); + for (const relativePath of migrationPaths) { + const filePath = path.join("migrations", relativePath); + if ((await stat(path.join(projectDir, filePath))).isFile()) { + preservedPaths.push(filePath); + } + } + const before = await Promise.all( + preservedPaths.map((filePath) => readFile(path.join(projectDir, filePath), "utf8")), + ); + const completeRetry = await runCreatePrismaJson(rootDir, [...args, "--force"]); + expect(completeRetry.exitCode).toBe(1); + expect(completeRetry.result).toMatchObject({ + ok: false, + error: { stage: "collect_context", message: expect.stringContaining("migration history") }, + }); + expect(await readdir(path.join(projectDir, "migrations"), { recursive: true })).toEqual( + migrationPaths, + ); + expect( + await Promise.all( + preservedPaths.map((filePath) => readFile(path.join(projectDir, filePath), "utf8")), + ), + ).toEqual(before); }, TEST_TIMEOUT, ); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index 64e7820..ea2ea9a 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -84,7 +84,11 @@ describe("create telemetry", () => { }); test("separates expected input and environment rejections from technical failures", async () => { - for (const reason of ["target_directory_not_empty", "workspace_missing"] as const) { + for (const reason of [ + "target_directory_not_empty", + "target_has_migrations", + "workspace_missing", + ] as const) { await trackCreateFailed({ input: createInput, context: createContext, @@ -94,9 +98,10 @@ describe("create telemetry", () => { }); } const calls = trackCliTelemetry.mock.calls as Array<[string, Record]>; - expect(calls).toHaveLength(2); + expect(calls).toHaveLength(3); expect(calls.map(([, properties]) => properties["failure-reason"])).toEqual([ "target_directory_not_empty", + "target_has_migrations", "workspace_missing", ]); for (const [, properties] of calls) { From 688b97d05870a6111838ac751322f5c063cd0e41 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 7 Sep 2026 18:59:26 +0530 Subject: [PATCH 2/2] docs: clarify forced scaffold migration protection scope Signed-off-by: Aman Varshney --- README.md | 2 +- src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1ec8a72..a824399 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ older cached version. Prisma Compute does not support Deno deployments yet. - `--deploy` / `--no-deploy` - `--workspace ` - `--yes` -- `--force`: overwrite generated starter and Prisma files in a non-empty directory without migration history. This replaces existing Prisma config, contract, and database-client files; back up edits first. A non-empty `migrations` path is protected: use a new directory for a fresh starter, or continue working in the existing project with the Prisma CLI. +- `--force`: overwrite generated starter and Prisma files in a non-empty directory. This replaces existing Prisma config, contract, and database-client files; back up edits first. A non-empty standard `migrations` path is protected: use a new directory for a fresh starter, or continue working in the existing project with the Prisma CLI. Custom migration paths are not detected. - `--verbose` - `--json` diff --git a/src/index.ts b/src/index.ts index efa36dc..5529332 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,7 +66,7 @@ export const createPrismaCommand = Command.make( workspace: optionalString("workspace", "Prisma workspace id or name to deploy into"), force: optionalBoolean( "force", - "Overwrite generated starter and Prisma files in a directory without migration history", + "Overwrite generated starter and Prisma files; refuses a non-empty migrations path", ), yes: optionalBoolean("yes", "Skip prompts and accept default choices"), verbose: optionalBoolean("verbose", "Show verbose command output during setup"),