Skip to content
Open
32 changes: 21 additions & 11 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 Down Expand Up @@ -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 @@ -97,24 +98,33 @@ export default createCommand(config, async ({ values }) => {
}

let repo = explicitRepo ?? legacySliceMachineConfig?.repositoryName;
let hasRepoAccess = false;
if (repo) {
const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo);
hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo);
if (!hasRepoAccess) {
throw new CommandError(
`Repository "${repo}" not found in your account. Check the name or request access to the repository.`,
);
}

const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host });
if (!isTypeBuilderEnabled) {
throw new TypeBuilderRequiredError();
const available = await checkIsDomainAvailable({ domain: repo, token, host });
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
if (!available) {
throw new CommandError(
`Repository "${repo}" is not in your account. Request access, or choose a different name.`,
);
}
Comment thread
jomifepe marked this conversation as resolved.
Outdated
} else {
const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host });
if (!isTypeBuilderEnabled) {
throw new TypeBuilderRequiredError();
}
}
}

const adapter = await getAdapter();

if (!repo) {
repo = await createRepo({ token, host });
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.
}
if (!hasRepoAccess) {
repo = await createRepo({ name: repo, token, host });
console.info(`Created repository: ${repo}`);
}

Expand Down
60 changes: 32 additions & 28 deletions src/commands/repo-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,66 @@ 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;
import { validateRepositoryDomain } from "../lib/repositoryDomain";

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,
},
"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;

validateRepositoryDomain(name);
Comment thread
jomifepe marked this conversation as resolved.
Outdated
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 +73,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;
}
13 changes: 13 additions & 0 deletions src/lib/repositoryDomain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { CommandError } from "./command";

const DOMAIN_REGEX = /^[a-zA-Z0-9][-a-zA-Z0-9]{2,}[a-zA-Z0-9]$/;
const MIN_LENGTH = 4;
const MAX_LENGTH = 63;

export function validateRepositoryDomain(name: string): void {
if (name.length < MIN_LENGTH || name.length > MAX_LENGTH || !DOMAIN_REGEX.test(name)) {
throw new CommandError(
`Invalid repository name "${name}". Must be ${MIN_LENGTH}–${MAX_LENGTH} characters, letters/numbers/hyphens only, and start and end with a letter or number.`,
);
}
}
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 repository 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 repository name");
});