Skip to content

Commit f011d2c

Browse files
fix(cli): reject resolution flags during conversion; handle rollback double-fault
Review findings: init --format ts with an existing prisma.compute.json silently ignored --framework/--entry/--name/--http-port/--region; they now fail as a usage error since conversion transports values and never re-resolves them. If the post-write JSON delete fails AND the rollback delete also fails, the raw fs error escaped while two config files were left coexisting; that now surfaces as structured INIT_CONVERT_INCOMPLETE naming both files. Adds conversion tests for full field preservation (env, build, root, project region) and multi-app configs including a null build command.
1 parent 43c3f0f commit f011d2c

3 files changed

Lines changed: 196 additions & 1 deletion

File tree

docs/product/error-conventions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ These codes are the minimum stable set for the MVP:
189189
- `FRAMEWORK_NOT_DETECTED`
190190
- `INIT_CONFIG_EXISTS`
191191
- `INIT_CONVERT_UNSUPPORTED`
192+
- `INIT_CONVERT_INCOMPLETE`
192193
- `INIT_DETECTION_FAILED`
193194
- `DEPLOYMENT_NOT_FOUND`
194195
- `NO_DEPLOYMENTS`
@@ -263,6 +264,7 @@ Recommended meanings:
263264
- `FRAMEWORK_NOT_DETECTED`: app deploy could not detect a supported Beta framework and no explicit framework/build type was provided
264265
- `INIT_CONFIG_EXISTS`: a compute config already exists in this directory or an ancestor; init never overwrites or merges
265266
- `INIT_CONVERT_UNSUPPORTED`: `init --format json` found an existing TypeScript config; TypeScript configs may contain logic, so converting them to JSON automatically would be lossy and the rewrite is manual
267+
- `INIT_CONVERT_INCOMPLETE`: a conversion wrote prisma.compute.ts but could not delete prisma.compute.json, and rolling back the write also failed; both files exist and one must be deleted by hand
266268
- `INIT_DETECTION_FAILED`: no supported framework detected and no --framework passed; `meta.frameworks` lists the valid values
267269
- `DEPLOYMENT_NOT_FOUND`: requested deployment id does not exist
268270
- `NO_DEPLOYMENTS`: command resolved a branch or app but found no deployments

packages/cli/src/controllers/init.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export async function runInit(
101101
solePath !== undefined && path.extname(solePath) === ".json";
102102
// Conversion must be explicit: plain init refuses every existing config.
103103
if (soleIsJson && format.value === "typescript" && format.explicit) {
104+
rejectConversionResolutionFlags(flags, formatCommand);
104105
return runInitConversion(context, flags, solePath, formatCommand);
105106
}
106107
if (solePath && !soleIsJson && format.value === "json") {
@@ -460,6 +461,49 @@ function parseInitFormat(
460461
);
461462
}
462463

464+
/**
465+
* Conversion transports the existing config's values; it never re-resolves
466+
* settings. Refusing resolution flags beats silently ignoring them.
467+
*/
468+
function rejectConversionResolutionFlags(
469+
flags: InitFlags,
470+
formatCommand: PrismaCliPackageCommandFormatter,
471+
): void {
472+
const passed = [
473+
flags.framework !== undefined ? "--framework" : null,
474+
flags.entry !== undefined ? "--entry" : null,
475+
flags.httpPort !== undefined ? "--http-port" : null,
476+
flags.name !== undefined ? "--name" : null,
477+
flags.region !== undefined ? "--region" : null,
478+
].filter((flag): flag is string => flag !== null);
479+
if (passed.length === 0) {
480+
return;
481+
}
482+
throw usageError(
483+
`${passed.join(", ")} ${passed.length === 1 ? "does" : "do"} not apply when converting an existing config`,
484+
`--format ts with an existing ${COMPUTE_CONFIG_JSON_FILENAME} converts it as-is; settings are transported, never re-resolved.`,
485+
`Convert first, then edit ${COMPUTE_CONFIG_FILENAME} directly.`,
486+
[formatCommand(["init", "--format", "ts"])],
487+
"app",
488+
);
489+
}
490+
491+
function initConvertIncompleteError(
492+
jsonConfigPath: string,
493+
tsConfigPath: string,
494+
): CliError {
495+
return new CliError({
496+
code: "INIT_CONVERT_INCOMPLETE",
497+
domain: "app",
498+
summary: "Conversion left two config files behind",
499+
why: `${path.basename(tsConfigPath)} was written but ${path.basename(jsonConfigPath)} could not be deleted, and rolling back the write also failed. Commands refuse to load a directory with two config files.`,
500+
fix: `Delete one file by hand: keep ${path.basename(tsConfigPath)} to finish the conversion, or keep ${path.basename(jsonConfigPath)} to undo it.`,
501+
exitCode: 1,
502+
nextSteps: [],
503+
meta: { jsonConfigPath, tsConfigPath },
504+
});
505+
}
506+
463507
function initConvertUnsupportedError(existingPath: string): CliError {
464508
return new CliError({
465509
code: "INIT_CONVERT_UNSUPPORTED",
@@ -529,7 +573,11 @@ async function runInitConversion(
529573
} catch (error) {
530574
// Two coexisting config files are a hard loader error, so a failed
531575
// delete rolls the write back and leaves the JSON config untouched.
532-
await rm(tsConfigPath, { force: true });
576+
try {
577+
await rm(tsConfigPath, { force: true });
578+
} catch {
579+
throw initConvertIncompleteError(jsonConfigPath, tsConfigPath);
580+
}
533581
throw error;
534582
}
535583

packages/cli/tests/init.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,151 @@ describe("init config format", () => {
715715
});
716716
});
717717

718+
it("preserves env, build, root, and the project region through conversion", async () => {
719+
const cwd = await createTempCwd();
720+
const stateDir = path.join(cwd, ".state");
721+
await writeFile(
722+
path.join(cwd, "prisma.compute.json"),
723+
`${JSON.stringify({
724+
region: "eu-central-1",
725+
app: {
726+
name: "web",
727+
framework: "nextjs",
728+
root: "apps/web",
729+
httpPort: 3000,
730+
env: { file: ".env.production", vars: { NODE_ENV: "production" } },
731+
build: { command: "pnpm build", outputDirectory: ".next" },
732+
},
733+
})}\n`,
734+
);
735+
736+
const result = await executeCli({
737+
argv: ["init", "--format", "ts", "--json"],
738+
cwd,
739+
stateDir,
740+
fixturePath,
741+
});
742+
const payload = JSON.parse(result.stdout);
743+
744+
expect(result.exitCode).toBe(0);
745+
expect(payload.result.converted).toBe(true);
746+
747+
const config = await readConfig(cwd);
748+
expect(config).toContain('region: "eu-central-1"');
749+
expect(config).toContain('root: "apps/web"');
750+
expect(config).toContain('file: ".env.production"');
751+
expect(config).toContain('NODE_ENV: "production"');
752+
expect(config).toContain('command: "pnpm build"');
753+
expect(config).toContain('outputDirectory: ".next"');
754+
755+
const loaded = await loadComputeConfig(cwd);
756+
expect(loaded.isOk() && loaded.value?.targets[0]).toMatchObject({
757+
name: "web",
758+
framework: "nextjs",
759+
root: "apps/web",
760+
region: "eu-central-1",
761+
envInputs: [".env.production", "NODE_ENV=production"],
762+
build: {
763+
command: "pnpm build",
764+
outputDirectory: ".next",
765+
entrypoint: undefined,
766+
},
767+
});
768+
});
769+
770+
it("converts a multi-app config, including a null build command", async () => {
771+
const cwd = await createTempCwd();
772+
const stateDir = path.join(cwd, ".state");
773+
await writeFile(
774+
path.join(cwd, "prisma.compute.json"),
775+
`${JSON.stringify({
776+
apps: {
777+
web: {
778+
framework: "nextjs",
779+
root: "apps/web",
780+
build: { command: null },
781+
},
782+
api: {
783+
framework: "hono",
784+
root: "apps/api",
785+
entry: "src/index.ts",
786+
httpPort: 8080,
787+
},
788+
},
789+
})}\n`,
790+
);
791+
792+
const result = await executeCli({
793+
argv: ["init", "--format", "ts", "--json"],
794+
cwd,
795+
stateDir,
796+
fixturePath,
797+
});
798+
const payload = JSON.parse(result.stdout);
799+
800+
expect(result.exitCode).toBe(0);
801+
expect(payload.result.converted).toBe(true);
802+
// Multi-app configs carry no single app identity.
803+
expect(payload.result.app).toBeNull();
804+
805+
const config = await readConfig(cwd);
806+
expect(config).toContain("command: null");
807+
await expect(readJsonConfig(cwd)).rejects.toMatchObject({
808+
code: "ENOENT",
809+
});
810+
811+
const loaded = await loadComputeConfig(cwd);
812+
expect(loaded.isOk() && loaded.value?.kind).toBe("multi");
813+
expect(loaded.isOk() && loaded.value?.targets).toEqual([
814+
expect.objectContaining({
815+
key: "web",
816+
framework: "nextjs",
817+
build: expect.objectContaining({ command: null }),
818+
}),
819+
expect.objectContaining({
820+
key: "api",
821+
framework: "hono",
822+
entry: "src/index.ts",
823+
httpPort: 8080,
824+
}),
825+
]);
826+
});
827+
828+
it("rejects resolution flags during conversion instead of ignoring them", async () => {
829+
const cwd = await createTempCwd();
830+
const stateDir = path.join(cwd, ".state");
831+
const jsonSource = `${JSON.stringify({
832+
app: { name: "api", framework: "hono", httpPort: 8080 },
833+
})}\n`;
834+
await writeFile(path.join(cwd, "prisma.compute.json"), jsonSource);
835+
836+
const result = await executeCli({
837+
argv: [
838+
"init",
839+
"--format",
840+
"ts",
841+
"--framework",
842+
"nextjs",
843+
"--http-port",
844+
"3000",
845+
"--json",
846+
],
847+
cwd,
848+
stateDir,
849+
fixturePath,
850+
});
851+
const payload = JSON.parse(result.stdout);
852+
853+
expect(result.exitCode).toBe(2);
854+
expect(payload.error.code).toBe("USAGE_ERROR");
855+
expect(payload.error.summary).toContain("--framework");
856+
expect(payload.error.summary).toContain("--http-port");
857+
858+
// Nothing on disk changed: no TS config, JSON untouched.
859+
await expect(readConfig(cwd)).rejects.toMatchObject({ code: "ENOENT" });
860+
expect(await readJsonConfig(cwd)).toBe(jsonSource);
861+
});
862+
718863
it("runs the types install step when converting with --install", async () => {
719864
const cwd = await createTempCwd();
720865
const stateDir = path.join(cwd, ".state");

0 commit comments

Comments
 (0)