diff --git a/src/commands/init.ts b/src/commands/init.ts index 315a8bb..28c22f4 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"; @@ -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", @@ -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", @@ -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).", + ); + } + + 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 }); + 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(); @@ -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}`); } diff --git a/src/commands/repo-create.ts b/src/commands/repo-create.ts index 3e66ef7..ec0ebaa 100644 --- a/src/commands/repo-create.ts +++ b/src/commands/repo-create.ts @@ -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 { - 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(); @@ -52,20 +85,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/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/init.test.ts b/test/init.test.ts index b154366..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,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, @@ -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 }) => { diff --git a/test/repo-create.test.ts b/test/repo-create.test.ts index 8402575..8f29502 100644 --- a/test/repo-create.test.ts +++ b/test/repo-create.test.ts @@ -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:"); });