Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
aeb446a
fix: use --name as repository domain in repo create and init
jomifepe May 7, 2026
ba8a947
refactor: require --name to be kebab-case instead of auto-formatting
jomifepe May 7, 2026
ad84956
refactor: align domain regex with Wroom/Dashboard
jomifepe May 7, 2026
6607b4f
fix: lowercase --name before submitting to match server behavior
jomifepe May 7, 2026
a371fcc
refactor: remove --name from init, have --repo create if missing
jomifepe May 7, 2026
ec80744
refactor: minimize init changes for new --repo-creates-if-missing flow
jomifepe May 7, 2026
b65a47a
refactor: inline validateRepositoryDomain into repo-create.ts
jomifepe May 7, 2026
10dd57b
feat: use zod schema
jomifepe May 7, 2026
525298c
fix: test
jomifepe May 7, 2026
4d1e26a
fix: validate --repo
jomifepe May 7, 2026
b163f83
refactor: move code
jomifepe May 7, 2026
08566de
feat: logging
jomifepe May 8, 2026
e0b7557
feat: logging
jomifepe May 8, 2026
8c64c1d
Apply suggestions from code review
jomifepe May 8, 2026
bef603a
refactor: redundant check
jomifepe May 8, 2026
6019802
refactor: rename
jomifepe May 8, 2026
913749f
refactor: cleanup
jomifepe May 8, 2026
51aabe9
test: max
jomifepe May 8, 2026
2d36ecd
refactor: remove casting
jomifepe May 8, 2026
d088ee3
Merge remote-tracking branch 'origin/main' into jp/repo-display-name
jomifepe May 8, 2026
e28f384
fix: call order
jomifepe May 8, 2026
2896d8f
test: move cleanup
jomifepe May 8, 2026
4cd5804
feat: match dashboard behavior omitting name
jomifepe May 8, 2026
89c8bd4
test: lowercase conversion
jomifepe May 8, 2026
428aab2
refactor: cleanup
jomifepe May 8, 2026
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
32 changes: 20 additions & 12 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,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 +34,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 +96,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();
Comment thread
jomifepe marked this conversation as resolved.
if (!repo) {
throw new CommandError(
"Missing --repo. Provide the repository name to connect to (creating it if it doesn't exist yet).",
);
Comment thread
jomifepe marked this conversation as resolved.
}

const repoExistsInAccount = profile.repositories.some((r) => r.domain === repo);
if (!repoExistsInAccount) {
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"}`,
);
}

console.info(
`Repository "${repo}" was not found in your account. Creating it...`,
);
repo = await createRepo({ name: repo, token, host });
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
console.info(`Created repository: ${repo}`);
} else {
const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host });
if (!isTypeBuilderEnabled) {
throw new TypeBuilderRequiredError();
Expand All @@ -113,11 +126,6 @@ export default createCommand(config, async ({ values }) => {

const adapter = await getAdapter();

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

// Create prismic.config.json
try {
const documentAPIEndpoint =
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 a different name or request access to it.`,
);
}

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
35 changes: 27 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,14 @@ 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(
'Repository name "prismic" is already taken. Choose a different name or request access to it.',
);
});

it("migrates slicemachine.config.json", async ({ expect, project, prismic, repo }) => {
Expand Down
88 changes: 72 additions & 16 deletions test/repo-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,87 @@ 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 }));
Comment thread
jomifepe marked this conversation as resolved.
Outdated

const domain = stdout.match(/Repository created: (\S+)/)?.[1];
expect(domain).toBeDefined();
const repository = await getRepository({ repo: name, token, host });
expect(repository.name).toBe(name);
});

onTestFinished(() => deleteRepository(domain!, { token, password, host }));
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}`);

const repository = await getRepository({ repo: domain!, token, host });
onTestFinished(() => deleteRepository(expectedDomain, { token, password, 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 tooShort = await prismic("repo", ["create", "--name", "!!"]);
expect(tooShort.exitCode).toBe(1);
expect(tooShort.stderr).toContain("Invalid name:");

const tooLong = await prismic("repo", ["create", "--name", `a${"x".repeat(62)}z`]);
expect(tooLong.exitCode).toBe(1);
expect(tooLong.stderr).toContain("Invalid name:");
});