Skip to content
Open
34 changes: 24 additions & 10 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getAdapter } from "../adapters";
import { createLoginSession, getHost, getToken } from "../auth";
import { getCustomTypes, getSlices } from "../clients/custom-types";
import { getProfile } from "../clients/user";
import { checkIsDomainAvailable } from "../clients/wroom";
import { DEFAULT_PRISMIC_HOST } from "../env";
import { openBrowser } from "../lib/browser";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
Expand All @@ -20,7 +21,7 @@ import {
UnknownProjectRootError,
} from "../project";
import { checkIsTypeBuilderEnabled, TypeBuilderRequiredError } from "../project";
import { createRepo } from "./repo-create";
import { createRepo, repositoryNameSchema } from "./repo-create";

const config = {
name: "prismic init",
Expand All @@ -34,7 +35,7 @@ const config = {
migrated.
`,
options: {
repo: { type: "string", short: "r", description: "Repository name" },
repo: { type: "string", short: "r", description: "Repository name (created if it doesn't exist)" },
"no-browser": {
type: "boolean",
description: "Skip opening the browser automatically during login",
Expand Down Expand Up @@ -96,15 +97,28 @@ export default createCommand(config, async ({ values }) => {
}
}

let repo = explicitRepo ?? legacySliceMachineConfig?.repositoryName;
if (repo) {
const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo);
if (!hasRepoAccess) {
let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase();
if (!repo) {
throw new CommandError(
"Missing --repo. Provide the repository to connect to (or to create if it doesn't exist).",
);
Comment thread
jomifepe marked this conversation as resolved.
}

const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo);
if (!hasRepoAccess) {
const parsed = repositoryNameSchema.safeParse(repo);
if (!parsed.success) {
throw new CommandError(
`Repository "${repo}" not found in your account. Check the name or request access to the repository.`,
`Invalid repository name "${repo}": ${parsed.error.issues[0]?.message ?? "Invalid value"}`,
);
}

const available = await checkIsDomainAvailable({ domain: repo, token, host });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant domain availability check in init flow

Low Severity

When init creates a new repo, checkIsDomainAvailable is called at init.ts line 115, then createRepo at line 131 calls it a second time internally at repo-create.ts line 60. This is a redundant HTTP request on every init --repo <new-name> run. The first check provides a contextual error message, and the second is a general safety check inside createRepo — but both hit the same API endpoint with the same arguments, with getAdapter() running in between.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b163f83. Configure here.

if (!available) {
throw new CommandError(
`Repository "${repo}" is not in your account. Request access, or choose a different name.`,
);
}
} else {
const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host });
if (!isTypeBuilderEnabled) {
throw new TypeBuilderRequiredError();
Expand All @@ -113,8 +127,8 @@ export default createCommand(config, async ({ values }) => {

const adapter = await getAdapter();

if (!repo) {
repo = await createRepo({ token, host });
if (!hasRepoAccess) {
repo = await createRepo({ name: repo, token, host });
console.info(`Created repository: ${repo}`);
}

Expand Down
70 changes: 43 additions & 27 deletions src/commands/repo-create.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,80 @@
import * as z from "zod/mini";

import { getAdapter } from "../adapters";
import { getHost, getToken } from "../auth";
import { checkIsDomainAvailable, createRepository } from "../clients/wroom";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { UnknownRequestError } from "../lib/request";

const MAX_DOMAIN_TRIES = 5;
export const repositoryNameSchema = z
.string()
.check(
z.minLength(4, "Must be at least 4 characters"),
z.maxLength(63, "Must be at most 63 characters"),
z.regex(
/^[a-zA-Z0-9][-a-zA-Z0-9]{2,}[a-zA-Z0-9]$/,
"Must contain only letters, numbers, and hyphens, and start and end with a letter or number",
),
);

const config = {
name: "prismic repo create",
description: "Create a new Prismic repository.",
options: {
name: { type: "string", short: "n", description: "Display name for the repository" },
name: {
type: "string",
short: "n",
description: "Repository name (used as the domain)",
required: true,
schema: repositoryNameSchema,
},
"display-name": {
type: "string",
short: "d",
description: "Display name for the repository (defaults to --name)",
},
},
} satisfies CommandConfig;

export default createCommand(config, async ({ values }) => {
const { name } = values;
const { name, "display-name": displayName } = values;

const token = await getToken();
const host = await getHost();
const domain = await createRepo({ name, token, host });
const domain = await createRepo({ name, displayName, token, host });

console.info(`Repository created: ${domain}`);
console.info(`URL: https://${domain}.${host}/`);
});

export async function createRepo(config: {
name?: string;
name: string;
displayName?: string;
token: string | undefined;
host: string;
}): Promise<string> {
const { name, token, host } = config;
const { name, displayName, token, host } = config;

const domain = name.toLowerCase();

const domain = await findAvailableDomain({ token, host });
if (!domain) {
throw new CommandError("Failed to create a repository. Please try again.");
const available = await checkIsDomainAvailable({ domain, token, host });
if (!available) {
throw new CommandError(
`Repository name "${domain}" is already taken. Choose another.`,
);
}

const adapter = await getAdapter().catch(() => undefined);
const framework = adapter?.id ?? "other";

try {
await createRepository({ domain, name: name ?? domain, framework, token, host });
await createRepository({
domain,
name: displayName ?? name,
framework,
token,
host,
});
} catch (error) {
if (error instanceof UnknownRequestError) {
const message = await error.text();
Expand All @@ -52,20 +85,3 @@ export async function createRepo(config: {

return domain;
}

async function findAvailableDomain(config: {
token: string | undefined;
host: string;
}): Promise<string | undefined> {
const { token, host } = config;
let domain;
for (let i = 0; i < MAX_DOMAIN_TRIES; i++) {
const candidate = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
const available = await checkIsDomainAvailable({ domain: candidate, token, host });
if (available) {
domain = candidate;
break;
}
}
return domain;
}
21 changes: 20 additions & 1 deletion src/lib/command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ParseArgsOptionDescriptor } from "node:util";

import type * as z from "zod/mini";

import { parseArgs } from "node:util";

import { dedent, formatTable } from "./string";
Expand All @@ -9,7 +11,14 @@ export type CommandConfig = {
description: string;
sections?: Record<string, string>;
positionals?: Record<string, { description: string; required?: boolean }>;
options?: Record<string, ParseArgsOptionDescriptor & { description: string; required?: boolean }>;
options?: Record<
string,
ParseArgsOptionDescriptor & {
description: string;
required?: boolean;
schema?: z.ZodMiniType<unknown, unknown>;
}
>;
};

type CommandHandlerArgs<T extends CommandConfig> = ParseArgsReturnType<T> & {
Expand Down Expand Up @@ -64,6 +73,16 @@ export function createCommand<T extends CommandConfig>(
if (config.required && !(name in result.values)) {
throw new CommandError(`Missing required option: --${name}`);
}
if (config.schema && name in result.values) {
const parsed = config.schema.safeParse(
(result.values as Record<string, unknown>)[name],
);
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "Invalid value";
throw new CommandError(`Invalid ${name}: ${message}`);
}
(result.values as Record<string, unknown>)[name] = parsed.data;
}
}

await handler(result as CommandHandlerArgs<T>);
Expand Down
33 changes: 25 additions & 8 deletions test/init.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { access, readFile, rm, writeFile } from "node:fs/promises";

import { onTestFinished } from "vitest";

import { captureOutput, it } from "./it";
import { deleteRepository } from "./prismic";

it("supports --help", async ({ expect, prismic }) => {
const { stdout, exitCode } = await prismic("init", ["--help"]);
Expand All @@ -14,22 +17,35 @@ it("fails if prismic.config.json already exists", async ({ expect, prismic }) =>
expect(stderr).toContain("already initialized");
});

it("creates a repo if --repo is not provided and no legacy config exists", async ({
it("creates a repo when --repo doesn't exist yet", async ({
expect,
project,
prismic,
token,
host,
password,
}) => {
await rm(new URL("prismic.config.json", project));
const { exitCode, stdout } = await prismic("init");
const name = `cli-test-${crypto.randomUUID().slice(0, 8)}`;
onTestFinished(() => deleteRepository(name, { token, password, host }));

const { exitCode, stdout } = await prismic("init", ["--repo", name]);
expect(exitCode).toBe(0);
expect(stdout).toContain("Created repository:");
expect(stdout).toContain("Initialized Prismic for repository");
expect(stdout).toContain(`Created repository: ${name}`);
expect(stdout).toContain(`Initialized Prismic for repository "${name}"`);

const configRaw = await readFile(new URL("prismic.config.json", project), "utf-8");
const config = JSON.parse(configRaw);
expect(config.repositoryName).toMatch(/^[a-f0-9]{8}$/);
expect(config.repositoryName).toBe(name);
}, 60_000);

it("fails when --repo is not provided", async ({ expect, project, prismic }) => {
await rm(new URL("prismic.config.json", project));
const { exitCode, stderr } = await prismic("init");
expect(exitCode).toBe(1);
expect(stderr).toContain("Missing --repo");
});

it("initializes a project with --repo when logged in", async ({
expect,
project,
Expand Down Expand Up @@ -59,11 +75,12 @@ it("triggers login flow when not logged in", async ({ expect, project, prismic,
proc.kill();
});

it("fails if repo is not in the user's account", async ({ expect, project, prismic }) => {
it("fails if --repo is taken by another account", async ({ expect, project, prismic }) => {
await rm(new URL("prismic.config.json", project));
const { exitCode, stderr } = await prismic("init", ["--repo", "nonexistent-repo-xyz-12345"]);
// "prismic" is reserved/taken and will fail availability check.
const { exitCode, stderr } = await prismic("init", ["--repo", "prismic"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("not found in your account");
expect(stderr).toContain("not in your account");
});

it("migrates slicemachine.config.json", async ({ expect, project, prismic, repo }) => {
Expand Down
84 changes: 68 additions & 16 deletions test/repo-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,83 @@ it("supports --help", async ({ expect, prismic }) => {
expect(stdout).toContain("prismic repo create [options]");
});

it("creates a repository", async ({ expect, prismic, token, host, password }) => {
const { stdout, exitCode } = await prismic("repo", ["create"]);
it("requires --name", async ({ expect, prismic }) => {
const { stderr, exitCode } = await prismic("repo", ["create"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("Missing required option: --name");
});

it("creates a repository using --name as the domain", async ({
expect,
prismic,
token,
host,
password,
}) => {
const name = `cli-test-${crypto.randomUUID().slice(0, 8)}`;
const { stdout, exitCode } = await prismic("repo", ["create", "--name", name]);
expect(exitCode).toBe(0);
expect(stdout).toContain("Repository created:");
expect(stdout).toContain(`Repository created: ${name}`);

onTestFinished(() => deleteRepository(name, { token, password, host }));

const repository = await getRepository({ repo: name, token, host });
expect(repository.name).toBe(name);
});

const domain = stdout.match(/Repository created: (\S+)/)?.[1];
expect(domain).toBeDefined();
it("lowercases --name before submitting", async ({
expect,
prismic,
token,
host,
password,
}) => {
const suffix = crypto.randomUUID().slice(0, 8);
const name = `CLI-Test-${suffix}`;
const expectedDomain = name.toLowerCase();
const { stdout, exitCode } = await prismic("repo", ["create", "--name", name]);
expect(exitCode).toBe(0);
expect(stdout).toContain(`Repository created: ${expectedDomain}`);

onTestFinished(() => deleteRepository(domain!, { token, password, host }));
onTestFinished(() => deleteRepository(expectedDomain, { token, password, host }));

const repository = await getRepository({ repo: domain!, token, host });
const repository = await getRepository({ repo: expectedDomain, token, host });
expect(repository).toBeDefined();
});

it("creates a repository with a name", async ({ expect, prismic, token, host, password }) => {
const name = `Test ${crypto.randomUUID().slice(0, 8)}`;
const { stdout, exitCode } = await prismic("repo", ["create", "--name", name]);
it("rejects --name with disallowed characters", async ({ expect, prismic }) => {
const { stderr, exitCode } = await prismic("repo", ["create", "--name", "My Test Repo"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("Invalid name:");
});

it("uses --display-name as the repository label", async ({
expect,
prismic,
token,
host,
password,
}) => {
const name = `cli-test-${crypto.randomUUID().slice(0, 8)}`;
const displayName = "My Display Name";
const { stdout, exitCode } = await prismic("repo", [
"create",
"--name",
name,
"--display-name",
displayName,
]);
expect(exitCode).toBe(0);
expect(stdout).toContain("Repository created:");
expect(stdout).toContain(`Repository created: ${name}`);

const domain = stdout.match(/Repository created: (\S+)/)?.[1];
expect(domain).toBeDefined();
onTestFinished(() => deleteRepository(name, { token, password, host }));

onTestFinished(() => deleteRepository(domain!, { token, password, host }));
const repository = await getRepository({ repo: name, token, host });
expect(repository.name).toBe(displayName);
});

const repository = await getRepository({ repo: domain!, token, host });
expect(repository.name).toBe(name);
it("fails with guidance when --name is invalid", async ({ expect, prismic }) => {
const { stderr, exitCode } = await prismic("repo", ["create", "--name", "!!"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("Invalid name:");
});