Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/cli/src/commands/project/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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) => {
Expand Down
39 changes: 30 additions & 9 deletions packages/cli/src/lib/app/app-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export interface AppProvider {
createProject(options: {
name: string;
region?: string;
logicalId?: string;
signal?: AbortSignal;
}): Promise<ProjectRecord>;
resolveBranch(
Expand Down Expand Up @@ -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,
};
},

Expand Down
78 changes: 77 additions & 1 deletion packages/cli/tests/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id-or-name>.",
},
{ kind: "run-command", command: "prisma project list" },
{ kind: "run-command", command: "prisma project link <id-or-name>" },
],
},
});
});

it("rejects a whitespace-only name as a usage error", async () => {
const result = await makeCli(fakeClient()).run(
["project", "create", " ", "--json"],
Expand Down Expand Up @@ -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",
Expand Down
Loading