From 75290160a5fe8fe1c72c8b45ffccbb294bec25ee Mon Sep 17 00:00:00 2001 From: Kristof Siket Date: Fri, 25 Sep 2026 17:18:26 +0200 Subject: [PATCH] feat(cli): send the project name as logicalId on project create `project create ` is a user naming a project, which is a declaration, so the CLI now sends the name as the project's logicalId when it fits the format the Management API validates (^[a-z0-9][-_a-z0-9]*$). A name outside that format creates the project as before, with no logicalId. The provider now posts to /v1/projects itself, because ComputeClient.createProject does not forward logicalId. `project link` still sends no logicalId. A duplicate logicalId in the workspace returns 409, which the existing PROJECT.CREATE_FAILED mapping already shows with the API's reason. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/project/create.ts | 5 ++ packages/cli/src/lib/app/app-provider.ts | 39 ++++++++--- packages/cli/tests/project.test.ts | 78 ++++++++++++++++++++- 3 files changed, 112 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index a0f8626f..d7f820b2 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -11,6 +11,10 @@ import { resolveActiveWorkspace } from "../resources-shared/workspace"; import { bindDirectoryToProject } from "./context"; import { setupPresentations } from "./presentation"; +/** The Management API's format for a project `logicalId`. A name outside it + * still creates the project, just without a declared identity. */ +const PROJECT_LOGICAL_ID = /^[a-z0-9][-_a-z0-9]*$/; + export const projectCreateCommand = defineCommand({ args: { positionals: { @@ -46,6 +50,7 @@ export const projectCreateCommand = defineCommand({ .createProject({ name, region: args.flags.region, + ...(PROJECT_LOGICAL_ID.test(name) ? { logicalId: name } : {}), signal: ctx.signal, }) .catch((error: unknown) => { diff --git a/packages/cli/src/lib/app/app-provider.ts b/packages/cli/src/lib/app/app-provider.ts index bc1176cc..2fc9fa6e 100644 --- a/packages/cli/src/lib/app/app-provider.ts +++ b/packages/cli/src/lib/app/app-provider.ts @@ -131,6 +131,7 @@ export interface AppProvider { createProject(options: { name: string; region?: string; + logicalId?: string; signal?: AbortSignal; }): Promise; resolveBranch( @@ -272,20 +273,40 @@ export function createAppProvider( const sdk = new ComputeClient(client); return { + /** Posts directly because `ComputeClient.createProject` does not forward + * `logicalId`. The signal is checked but not passed: aborting a create + * mid-flight would leave its outcome unknown. */ async createProject(options) { - const projectResult = await sdk.createProject({ - name: options.name, - region: options.region, - signal: options.signal, + options.signal?.throwIfAborted(); + const { data, error, response } = await client.POST("/v1/projects", { + body: { + name: options.name, + createDatabase: false, + ...(options.region !== undefined + ? { region: options.region as never } + : {}), + ...(options.logicalId !== undefined + ? { logicalId: options.logicalId } + : {}), + }, }); - if (projectResult.isErr()) { - throw new Error(projectResult.error.message); + if (error || !data) { + throw new ApiError({ + statusCode: response.status, + statusText: response.statusText, + code: error?.error.code, + message: + error?.error.message ?? + `Management API returned HTTP ${response.status}.`, + hint: error?.error.hint, + traceHeaders: {}, + }); } return { - id: projectResult.value.id, - name: projectResult.value.name, - defaultRegion: projectResult.value.defaultRegion, + id: data.data.id, + name: data.data.name, + defaultRegion: data.data.defaultRegion ?? undefined, }; }, diff --git a/packages/cli/tests/project.test.ts b/packages/cli/tests/project.test.ts index 618b57c0..d07f92ed 100644 --- a/packages/cli/tests/project.test.ts +++ b/packages/cli/tests/project.test.ts @@ -567,6 +567,80 @@ describe("prisma project create", () => { ]); }); + it("declares the name as the project's logicalId when it fits the logicalId format", async () => { + const post = vi.fn(() => ({ + data: { data: { id: "proj_new", name: "my-app_2" } }, + })); + const result = await makeCli(fakeClient({ post })).run( + ["project", "create", "my-app_2", "--region", "eu-central-1", "--json"], + { cwd: await tempCwd() }, + ); + + expect(result.exitCode).toBe(0); + expect(post).toHaveBeenCalledWith("/v1/projects", { + body: { + name: "my-app_2", + createDatabase: false, + region: "eu-central-1", + logicalId: "my-app_2", + }, + }); + }); + + it.each([ + "My App", + "My-App", + "_app", + "app.v2", + ])("creates %j without a logicalId because the name does not fit the logicalId format", async (name) => { + const post = vi.fn(() => ({ + data: { data: { id: "proj_new", name } }, + })); + const result = await makeCli(fakeClient({ post })).run( + ["project", "create", name, "--json"], + { cwd: await tempCwd() }, + ); + + expect(result.exitCode).toBe(0); + expect(post).toHaveBeenCalledWith("/v1/projects", { + body: { name, createDatabase: false }, + }); + }); + + it("maps a logicalId already taken in the workspace to PROJECT.CREATE_FAILED with the API's reason", async () => { + const result = await makeCli( + fakeClient({ + post: () => + apiFailure(409, { + error: { + code: "conflict", + message: + 'A project with logicalId "my-app" already exists in this workspace', + }, + }), + }), + ).run(["project", "create", "my-app", "--json"], { cwd: await tempCwd() }); + + expect(result.exitCode).toBe(2); + expect(resultFrame(result.json).envelope).toMatchObject({ + ok: false, + error: { + code: "PROJECT.CREATE_FAILED", + summary: 'Could not create Project "my-app"', + why: 'A project with logicalId "my-app" already exists in this workspace', + nextActions: [ + { + kind: "user-choice", + label: + "Retry the command, or choose an existing Project with prisma project link .", + }, + { kind: "run-command", command: "prisma project list" }, + { kind: "run-command", command: "prisma project link " }, + ], + }, + }); + }); + it("rejects a whitespace-only name as a usage error", async () => { const result = await makeCli(fakeClient()).run( ["project", "create", " ", "--json"], @@ -794,7 +868,9 @@ describe("prisma project link", () => { }); expect(result.exitCode).toBe(0); - expect(seen).toMatchObject([{ body: { name: path.basename(cwd) } }]); + expect(seen).toEqual([ + { body: { name: path.basename(cwd), createDatabase: false } }, + ]); expect(result.presented?.data).toMatchObject({ project: { id: "proj_new" }, action: "created",