From aeb446ac020c680c1a98a11a22efc97a3434fbc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 7 May 2026 15:46:06 +0100 Subject: [PATCH 01/11] fix: use --name as repository domain in repo create and init Previously `--name` was only sent as the display label while the domain was a random 8-char hex slug. The dashboard API accepts a caller-chosen domain, so derive it from user input (kebab-cased) and add a separate `--display-name` flag for the label. - `--name` is now required and defines the domain - Adds `--display-name` to set the display label (defaults to `--name`) - Validates the domain against the dashboard rule before POSTing - `prismic init` requires `--name` unless `--repo` is provided Co-Authored-By: Claude Opus 4.7 --- src/commands/init.ts | 26 +++++++++++-- src/commands/repo-create.ts | 60 +++++++++++++++-------------- src/lib/repositoryDomain.ts | 22 +++++++++++ test/init.test.ts | 18 ++++++--- test/repo-create.test.ts | 77 +++++++++++++++++++++++++++++-------- 5 files changed, 150 insertions(+), 53 deletions(-) create mode 100644 src/lib/repositoryDomain.ts diff --git a/src/commands/init.ts b/src/commands/init.ts index 315a8bb..73d7d61 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -34,7 +34,17 @@ const config = { migrated. `, options: { - repo: { type: "string", short: "r", description: "Repository name" }, + repo: { type: "string", short: "r", description: "Existing repository to connect to" }, + name: { + type: "string", + short: "n", + description: "Name for a new repository (used as the domain). Required unless --repo is provided", + }, + "display-name": { + type: "string", + short: "d", + description: "Display name for the new repository (defaults to --name)", + }, "no-browser": { type: "boolean", description: "Skip opening the browser automatically during login", @@ -43,7 +53,12 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { repo: explicitRepo, "no-browser": noBrowser } = values; + const { + repo: explicitRepo, + name, + "display-name": displayName, + "no-browser": noBrowser, + } = values; // Check for existing prismic.config.json try { @@ -114,7 +129,12 @@ export default createCommand(config, async ({ values }) => { const adapter = await getAdapter(); if (!repo) { - repo = await createRepo({ token, host }); + if (!name) { + throw new CommandError( + "Missing repository. Provide --repo to connect to an existing repository, or --name to create a new one.", + ); + } + repo = await createRepo({ name, displayName, token, host }); console.info(`Created repository: ${repo}`); } diff --git a/src/commands/repo-create.ts b/src/commands/repo-create.ts index 3e66ef7..18e3b04 100644 --- a/src/commands/repo-create.ts +++ b/src/commands/repo-create.ts @@ -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 { formatRepositoryDomain, 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 { - const { name, token, host } = config; + const { name, displayName, token, host } = config; + + const domain = formatRepositoryDomain(name); + validateRepositoryDomain(domain); - 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(); @@ -52,20 +73,3 @@ export async function createRepo(config: { return domain; } - -async function findAvailableDomain(config: { - token: string | undefined; - host: string; -}): Promise { - 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; -} diff --git a/src/lib/repositoryDomain.ts b/src/lib/repositoryDomain.ts new file mode 100644 index 0000000..0cb9138 --- /dev/null +++ b/src/lib/repositoryDomain.ts @@ -0,0 +1,22 @@ +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 formatRepositoryDomain(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function validateRepositoryDomain(domain: string): void { + if (domain.length < MIN_LENGTH || domain.length > MAX_LENGTH || !DOMAIN_REGEX.test(domain)) { + throw new CommandError( + `Invalid repository name "${domain}". Must be ${MIN_LENGTH}–${MAX_LENGTH} characters, contain only letters, numbers, and hyphens, and start and end with a letter or number.`, + ); + } +} diff --git a/test/init.test.ts b/test/init.test.ts index b154366..f88e63a 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -14,22 +14,30 @@ 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 from --name if --repo is not provided", async ({ expect, project, prismic, }) => { await rm(new URL("prismic.config.json", project)); - const { exitCode, stdout } = await prismic("init"); + const name = `cli-test-${crypto.randomUUID().slice(0, 8)}`; + const { exitCode, stdout } = await prismic("init", ["--name", 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 neither --repo nor --name is 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 repository"); +}); + it("initializes a project with --repo when logged in", async ({ expect, project, diff --git a/test/repo-create.test.ts b/test/repo-create.test.ts index 8402575..45f3ab6 100644 --- a/test/repo-create.test.ts +++ b/test/repo-create.test.ts @@ -9,31 +9,74 @@ 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"]); - expect(exitCode).toBe(0); - expect(stdout).toContain("Repository created:"); +it("requires --name", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("repo", ["create"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Missing required option: --name"); +}); - const domain = stdout.match(/Repository created: (\S+)/)?.[1]; - expect(domain).toBeDefined(); +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: ${name}`); - onTestFinished(() => deleteRepository(domain!, { token, password, host })); + onTestFinished(() => deleteRepository(name, { token, password, host })); - const repository = await getRepository({ repo: domain!, token, host }); - expect(repository).toBeDefined(); + const repository = await getRepository({ repo: name, token, host }); + expect(repository.name).toBe(name); }); -it("creates a repository with a name", async ({ expect, prismic, token, host, password }) => { - const name = `Test ${crypto.randomUUID().slice(0, 8)}`; +it("kebab-cases --name into a valid domain", async ({ + expect, + prismic, + token, + host, + password, +}) => { + const suffix = crypto.randomUUID().slice(0, 8); + const name = `CLI Test ${suffix}`; + const expectedDomain = `cli-test-${suffix}`; const { stdout, exitCode } = await prismic("repo", ["create", "--name", name]); expect(exitCode).toBe(0); - expect(stdout).toContain("Repository created:"); + expect(stdout).toContain(`Repository created: ${expectedDomain}`); - const domain = stdout.match(/Repository created: (\S+)/)?.[1]; - expect(domain).toBeDefined(); + onTestFinished(() => deleteRepository(expectedDomain, { token, password, host })); +}); - onTestFinished(() => deleteRepository(domain!, { token, password, host })); +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: ${name}`); - const repository = await getRepository({ repo: domain!, token, host }); - expect(repository.name).toBe(name); + onTestFinished(() => deleteRepository(name, { token, password, host })); + + const repository = await getRepository({ repo: name, token, host }); + expect(repository.name).toBe(displayName); +}); + +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"); }); From ba8a9479a666c34430cd4aedc37f30868b995a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 7 May 2026 15:52:29 +0100 Subject: [PATCH 02/11] refactor: require --name to be kebab-case instead of auto-formatting Drop the silent kebab-casing of --name. Validate it directly against the dashboard rule and error with guidance when it doesn't match. Users pass the exact domain they want. Co-Authored-By: Claude Opus 4.7 --- src/commands/repo-create.ts | 13 ++++++------- src/lib/repositoryDomain.ts | 17 ++++------------- test/repo-create.test.ts | 19 ++++--------------- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/src/commands/repo-create.ts b/src/commands/repo-create.ts index 18e3b04..ad49f1e 100644 --- a/src/commands/repo-create.ts +++ b/src/commands/repo-create.ts @@ -3,7 +3,7 @@ import { getHost, getToken } from "../auth"; import { checkIsDomainAvailable, createRepository } from "../clients/wroom"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { formatRepositoryDomain, validateRepositoryDomain } from "../lib/repositoryDomain"; +import { validateRepositoryDomain } from "../lib/repositoryDomain"; const config = { name: "prismic repo create", @@ -42,13 +42,12 @@ export async function createRepo(config: { }): Promise { const { name, displayName, token, host } = config; - const domain = formatRepositoryDomain(name); - validateRepositoryDomain(domain); + validateRepositoryDomain(name); - const available = await checkIsDomainAvailable({ domain, token, host }); + const available = await checkIsDomainAvailable({ domain: name, token, host }); if (!available) { throw new CommandError( - `Repository name "${domain}" is already taken. Choose another.`, + `Repository name "${name}" is already taken. Choose another.`, ); } @@ -57,7 +56,7 @@ export async function createRepo(config: { try { await createRepository({ - domain, + domain: name, name: displayName ?? name, framework, token, @@ -71,5 +70,5 @@ export async function createRepo(config: { throw error; } - return domain; + return name; } diff --git a/src/lib/repositoryDomain.ts b/src/lib/repositoryDomain.ts index 0cb9138..e03e921 100644 --- a/src/lib/repositoryDomain.ts +++ b/src/lib/repositoryDomain.ts @@ -1,22 +1,13 @@ import { CommandError } from "./command"; -const DOMAIN_REGEX = /^[a-zA-Z0-9][-a-zA-Z0-9]{2,}[a-zA-Z0-9]$/; +const DOMAIN_REGEX = /^[a-z0-9][-a-z0-9]{2,}[a-z0-9]$/; const MIN_LENGTH = 4; const MAX_LENGTH = 63; -export function formatRepositoryDomain(input: string): string { - return input - .trim() - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^-+|-+$/g, ""); -} - -export function validateRepositoryDomain(domain: string): void { - if (domain.length < MIN_LENGTH || domain.length > MAX_LENGTH || !DOMAIN_REGEX.test(domain)) { +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 "${domain}". Must be ${MIN_LENGTH}–${MAX_LENGTH} characters, contain only letters, numbers, and hyphens, and start and end with a letter or number.`, + `Invalid repository name "${name}". Must be ${MIN_LENGTH}–${MAX_LENGTH} characters, lowercase letters/numbers/hyphens only, and start and end with a letter or number.`, ); } } diff --git a/test/repo-create.test.ts b/test/repo-create.test.ts index 45f3ab6..01cce33 100644 --- a/test/repo-create.test.ts +++ b/test/repo-create.test.ts @@ -33,21 +33,10 @@ it("creates a repository using --name as the domain", async ({ expect(repository.name).toBe(name); }); -it("kebab-cases --name into a valid domain", async ({ - expect, - prismic, - token, - host, - password, -}) => { - const suffix = crypto.randomUUID().slice(0, 8); - const name = `CLI Test ${suffix}`; - const expectedDomain = `cli-test-${suffix}`; - const { stdout, exitCode } = await prismic("repo", ["create", "--name", name]); - expect(exitCode).toBe(0); - expect(stdout).toContain(`Repository created: ${expectedDomain}`); - - onTestFinished(() => deleteRepository(expectedDomain, { token, password, host })); +it("rejects a non-kebab-case --name", 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 ({ From ad849563cfc0ccdc9468fe4ae0761067c6c65650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 7 May 2026 16:23:28 +0100 Subject: [PATCH 03/11] refactor: align domain regex with Wroom/Dashboard Accept uppercase letters in --name to match the server-side rule exactly (`^[a-zA-Z0-9][-a-zA-Z0-9]{2,}[a-zA-Z0-9]$`). Wroom lowercases the domain server-side, so any value the CLI accepts will be accepted by the API. Co-Authored-By: Claude Opus 4.7 --- src/lib/repositoryDomain.ts | 4 ++-- test/repo-create.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/repositoryDomain.ts b/src/lib/repositoryDomain.ts index e03e921..8805486 100644 --- a/src/lib/repositoryDomain.ts +++ b/src/lib/repositoryDomain.ts @@ -1,13 +1,13 @@ import { CommandError } from "./command"; -const DOMAIN_REGEX = /^[a-z0-9][-a-z0-9]{2,}[a-z0-9]$/; +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, lowercase letters/numbers/hyphens only, and start and end with a letter or number.`, + `Invalid repository name "${name}". Must be ${MIN_LENGTH}–${MAX_LENGTH} characters, letters/numbers/hyphens only, and start and end with a letter or number.`, ); } } diff --git a/test/repo-create.test.ts b/test/repo-create.test.ts index 01cce33..f92c6cd 100644 --- a/test/repo-create.test.ts +++ b/test/repo-create.test.ts @@ -33,7 +33,7 @@ it("creates a repository using --name as the domain", async ({ expect(repository.name).toBe(name); }); -it("rejects a non-kebab-case --name", async ({ expect, prismic }) => { +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"); From 6607b4ffb8274f357b2daddd8f501200e6cdca9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 7 May 2026 16:28:27 +0100 Subject: [PATCH 04/11] fix: lowercase --name before submitting to match server behavior Wroom lowercases the domain server-side. If the user passes `MyRepo`, the CLI was printing `Repository created: MyRepo` and `URL: https://MyRepo.prismic.io/`, both wrong. Lowercase after validation so the displayed output matches what was actually created. Co-Authored-By: Claude Opus 4.7 --- src/commands/repo-create.ts | 9 +++++---- test/repo-create.test.ts | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/commands/repo-create.ts b/src/commands/repo-create.ts index ad49f1e..d3e22fe 100644 --- a/src/commands/repo-create.ts +++ b/src/commands/repo-create.ts @@ -43,11 +43,12 @@ export async function createRepo(config: { const { name, displayName, token, host } = config; validateRepositoryDomain(name); + const domain = name.toLowerCase(); - const available = await checkIsDomainAvailable({ domain: name, token, host }); + const available = await checkIsDomainAvailable({ domain, token, host }); if (!available) { throw new CommandError( - `Repository name "${name}" is already taken. Choose another.`, + `Repository name "${domain}" is already taken. Choose another.`, ); } @@ -56,7 +57,7 @@ export async function createRepo(config: { try { await createRepository({ - domain: name, + domain, name: displayName ?? name, framework, token, @@ -70,5 +71,5 @@ export async function createRepo(config: { throw error; } - return name; + return domain; } diff --git a/test/repo-create.test.ts b/test/repo-create.test.ts index f92c6cd..c6834c3 100644 --- a/test/repo-create.test.ts +++ b/test/repo-create.test.ts @@ -33,6 +33,26 @@ it("creates a repository using --name as the domain", async ({ expect(repository.name).toBe(name); }); +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(expectedDomain, { token, password, host })); + + const repository = await getRepository({ repo: expectedDomain, token, host }); + expect(repository).toBeDefined(); +}); + it("rejects --name with disallowed characters", async ({ expect, prismic }) => { const { stderr, exitCode } = await prismic("repo", ["create", "--name", "My Test Repo"]); expect(exitCode).toBe(1); From a371fcc8688e13d43499f5d8bba641a1ce5819e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 7 May 2026 18:42:28 +0100 Subject: [PATCH 05/11] refactor: remove --name from init, have --repo create if missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init is about connecting a project to a repository. Drop the --name/--display-name options added to init — repo creation with a custom display label belongs on `prismic repo create`. Instead, if --repo points to a domain the user doesn't have access to, check if it's available and create it with that name; if it's taken by another account, error clearly. Co-Authored-By: Claude Opus 4.7 --- src/commands/init.ts | 43 +++++++++++++++---------------------------- test/init.test.ts | 23 ++++++++++++++++------- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 73d7d61..31b4db0 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -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"; @@ -34,17 +35,7 @@ const config = { migrated. `, options: { - repo: { type: "string", short: "r", description: "Existing repository to connect to" }, - name: { - type: "string", - short: "n", - description: "Name for a new repository (used as the domain). Required unless --repo is provided", - }, - "display-name": { - type: "string", - short: "d", - description: "Display name for the new repository (defaults to --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", @@ -53,12 +44,7 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { - repo: explicitRepo, - name, - "display-name": displayName, - "no-browser": noBrowser, - } = values; + const { repo: explicitRepo, "no-browser": noBrowser } = values; // Check for existing prismic.config.json try { @@ -112,14 +98,14 @@ export default createCommand(config, async ({ values }) => { } let repo = explicitRepo ?? legacySliceMachineConfig?.repositoryName; - if (repo) { - const 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.`, - ); - } + if (!repo) { + throw new CommandError( + "Missing --repo. Provide the repository to connect to (or to create if it doesn't exist).", + ); + } + const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); + if (hasRepoAccess) { const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host }); if (!isTypeBuilderEnabled) { throw new TypeBuilderRequiredError(); @@ -128,13 +114,14 @@ export default createCommand(config, async ({ values }) => { const adapter = await getAdapter(); - if (!repo) { - if (!name) { + if (!hasRepoAccess) { + const available = await checkIsDomainAvailable({ domain: repo, token, host }); + if (!available) { throw new CommandError( - "Missing repository. Provide --repo to connect to an existing repository, or --name to create a new one.", + `Repository "${repo}" exists but is not in your account. Request access, or choose a different name.`, ); } - repo = await createRepo({ name, displayName, token, host }); + repo = await createRepo({ name: repo, token, host }); console.info(`Created repository: ${repo}`); } diff --git a/test/init.test.ts b/test/init.test.ts index f88e63a..dfc8188 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -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"]); @@ -14,14 +17,19 @@ it("fails if prismic.config.json already exists", async ({ expect, prismic }) => expect(stderr).toContain("already initialized"); }); -it("creates a repo from --name if --repo is not provided", 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 name = `cli-test-${crypto.randomUUID().slice(0, 8)}`; - const { exitCode, stdout } = await prismic("init", ["--name", name]); + onTestFinished(() => deleteRepository(name, { token, password, host })); + + const { exitCode, stdout } = await prismic("init", ["--repo", name]); expect(exitCode).toBe(0); expect(stdout).toContain(`Created repository: ${name}`); expect(stdout).toContain(`Initialized Prismic for repository "${name}"`); @@ -31,11 +39,11 @@ it("creates a repo from --name if --repo is not provided", async ({ expect(config.repositoryName).toBe(name); }, 60_000); -it("fails when neither --repo nor --name is provided", async ({ expect, project, prismic }) => { +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 repository"); + expect(stderr).toContain("Missing --repo"); }); it("initializes a project with --repo when logged in", async ({ @@ -67,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 }) => { From ec80744767ff66df0b126a073ab5ab35f0c6275d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 7 May 2026 18:49:27 +0100 Subject: [PATCH 06/11] refactor: minimize init changes for new --repo-creates-if-missing flow Keep the original shape of the access check; just branch on availability when the user lacks access, and thread the name into createRepo. Co-Authored-By: Claude Opus 4.7 --- src/commands/init.ts | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 31b4db0..ef9c55a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -98,29 +98,32 @@ export default createCommand(config, async ({ values }) => { } let repo = explicitRepo ?? legacySliceMachineConfig?.repositoryName; - if (!repo) { - throw new CommandError( - "Missing --repo. Provide the repository to connect to (or to create if it doesn't exist).", - ); - } - - const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); - if (hasRepoAccess) { - const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host }); - if (!isTypeBuilderEnabled) { - throw new TypeBuilderRequiredError(); + let hasRepoAccess = false; + if (repo) { + hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); + if (!hasRepoAccess) { + const available = await checkIsDomainAvailable({ domain: repo, token, host }); + 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(); + } } } const adapter = await getAdapter(); + if (!repo) { + throw new CommandError( + "Missing --repo. Provide the repository to connect to (or to create if it doesn't exist).", + ); + } if (!hasRepoAccess) { - const available = await checkIsDomainAvailable({ domain: repo, token, host }); - if (!available) { - throw new CommandError( - `Repository "${repo}" exists but is not in your account. Request access, or choose a different name.`, - ); - } repo = await createRepo({ name: repo, token, host }); console.info(`Created repository: ${repo}`); } From b65a47a47116b3497c1128c882b63e72de10f09b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 7 May 2026 18:55:28 +0100 Subject: [PATCH 07/11] refactor: inline validateRepositoryDomain into repo-create.ts It's only used in one place (the createRepo function), so it doesn't need its own lib file. Co-Authored-By: Claude Opus 4.7 --- src/commands/repo-create.ts | 13 ++++++++++++- src/lib/repositoryDomain.ts | 13 ------------- 2 files changed, 12 insertions(+), 14 deletions(-) delete mode 100644 src/lib/repositoryDomain.ts diff --git a/src/commands/repo-create.ts b/src/commands/repo-create.ts index d3e22fe..db820f7 100644 --- a/src/commands/repo-create.ts +++ b/src/commands/repo-create.ts @@ -3,7 +3,18 @@ import { getHost, getToken } from "../auth"; import { checkIsDomainAvailable, createRepository } from "../clients/wroom"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; -import { validateRepositoryDomain } from "../lib/repositoryDomain"; + +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.`, + ); + } +} const config = { name: "prismic repo create", diff --git a/src/lib/repositoryDomain.ts b/src/lib/repositoryDomain.ts deleted file mode 100644 index 8805486..0000000 --- a/src/lib/repositoryDomain.ts +++ /dev/null @@ -1,13 +0,0 @@ -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.`, - ); - } -} From 10dd57b3dd87d5636e04e650e43b089be43e44d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 7 May 2026 19:20:37 +0100 Subject: [PATCH 08/11] feat: use zod schema --- src/commands/repo-create.ts | 25 ++++++++++++------------- src/lib/command.ts | 21 ++++++++++++++++++++- test/repo-create.test.ts | 4 ++-- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/commands/repo-create.ts b/src/commands/repo-create.ts index db820f7..75da0ca 100644 --- a/src/commands/repo-create.ts +++ b/src/commands/repo-create.ts @@ -1,21 +1,11 @@ +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 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.`, - ); - } -} - const config = { name: "prismic repo create", description: "Create a new Prismic repository.", @@ -25,6 +15,16 @@ const config = { short: "n", description: "Repository name (used as the domain)", required: true, + schema: 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", + ), + ), }, "display-name": { type: "string", @@ -53,7 +53,6 @@ export async function createRepo(config: { }): Promise { const { name, displayName, token, host } = config; - validateRepositoryDomain(name); const domain = name.toLowerCase(); const available = await checkIsDomainAvailable({ domain, token, host }); diff --git a/src/lib/command.ts b/src/lib/command.ts index c5243dc..68e8c44 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -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"; @@ -9,7 +11,14 @@ export type CommandConfig = { description: string; sections?: Record; positionals?: Record; - options?: Record; + options?: Record< + string, + ParseArgsOptionDescriptor & { + description: string; + required?: boolean; + schema?: z.ZodMiniType; + } + >; }; type CommandHandlerArgs = ParseArgsReturnType & { @@ -64,6 +73,16 @@ export function createCommand( 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)[name], + ); + if (!parsed.success) { + const message = parsed.error.issues[0]?.message ?? "Invalid value"; + throw new CommandError(`Invalid ${name}: ${message}`); + } + (result.values as Record)[name] = parsed.data; + } } await handler(result as CommandHandlerArgs); diff --git a/test/repo-create.test.ts b/test/repo-create.test.ts index c6834c3..1f4fbe4 100644 --- a/test/repo-create.test.ts +++ b/test/repo-create.test.ts @@ -56,7 +56,7 @@ it("lowercases --name before submitting", async ({ 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"); + expect(stderr).toContain("Invalid --name:"); }); it("uses --display-name as the repository label", async ({ @@ -87,5 +87,5 @@ it("uses --display-name as the repository label", async ({ 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"); + expect(stderr).toContain("Invalid --name:"); }); From 525298c30b275d219dab4e27a2b09dd264cccb55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 7 May 2026 19:22:11 +0100 Subject: [PATCH 09/11] fix: test --- test/repo-create.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/repo-create.test.ts b/test/repo-create.test.ts index 1f4fbe4..8f29502 100644 --- a/test/repo-create.test.ts +++ b/test/repo-create.test.ts @@ -56,7 +56,7 @@ it("lowercases --name before submitting", async ({ 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:"); + expect(stderr).toContain("Invalid name:"); }); it("uses --display-name as the repository label", async ({ @@ -87,5 +87,5 @@ it("uses --display-name as the repository label", async ({ 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:"); + expect(stderr).toContain("Invalid name:"); }); From 4d1e26a860160cd305a42e3132438c0af7047306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 7 May 2026 19:31:43 +0100 Subject: [PATCH 10/11] fix: validate --repo --- src/commands/init.ts | 10 ++++++++-- src/commands/repo-create.ts | 22 ++++++++++++---------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index ef9c55a..f30cd5e 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -21,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", @@ -97,11 +97,17 @@ export default createCommand(config, async ({ values }) => { } } - let repo = explicitRepo ?? legacySliceMachineConfig?.repositoryName; + let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase(); let hasRepoAccess = false; if (repo) { hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); if (!hasRepoAccess) { + const parsed = repositoryNameSchema.safeParse(repo); + if (!parsed.success) { + throw new CommandError( + `Invalid repository name "${repo}": ${parsed.error.issues[0]?.message ?? "Invalid value"}`, + ); + } const available = await checkIsDomainAvailable({ domain: repo, token, host }); if (!available) { throw new CommandError( diff --git a/src/commands/repo-create.ts b/src/commands/repo-create.ts index 75da0ca..ec0ebaa 100644 --- a/src/commands/repo-create.ts +++ b/src/commands/repo-create.ts @@ -6,6 +6,17 @@ import { checkIsDomainAvailable, createRepository } from "../clients/wroom"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { UnknownRequestError } from "../lib/request"; +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.", @@ -15,16 +26,7 @@ const config = { short: "n", description: "Repository name (used as the domain)", required: true, - schema: 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", - ), - ), + schema: repositoryNameSchema, }, "display-name": { type: "string", From b163f8372c4246de92e48c39dda2c8adf6d0825f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 7 May 2026 19:32:57 +0100 Subject: [PATCH 11/11] refactor: move code --- src/commands/init.ts | 50 +++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index f30cd5e..28c22f4 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -98,37 +98,35 @@ export default createCommand(config, async ({ values }) => { } let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase(); - let hasRepoAccess = false; - if (repo) { - hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); - if (!hasRepoAccess) { - const parsed = repositoryNameSchema.safeParse(repo); - if (!parsed.success) { - throw new CommandError( - `Invalid repository name "${repo}": ${parsed.error.issues[0]?.message ?? "Invalid value"}`, - ); - } - const available = await checkIsDomainAvailable({ domain: repo, token, host }); - 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(); - } - } - } - - const adapter = await getAdapter(); - if (!repo) { throw new CommandError( "Missing --repo. Provide the repository to connect to (or to create if it doesn't exist).", ); } + + const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); + if (!hasRepoAccess) { + const parsed = repositoryNameSchema.safeParse(repo); + if (!parsed.success) { + throw new CommandError( + `Invalid repository name "${repo}": ${parsed.error.issues[0]?.message ?? "Invalid value"}`, + ); + } + const available = await checkIsDomainAvailable({ domain: repo, token, host }); + 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(); + } + } + + const adapter = await getAdapter(); + if (!hasRepoAccess) { repo = await createRepo({ name: repo, token, host }); console.info(`Created repository: ${repo}`);