diff --git a/src/clients/wroom.ts b/src/clients/wroom.ts index 4e04add..408b5f0 100644 --- a/src/clients/wroom.ts +++ b/src/clients/wroom.ts @@ -318,7 +318,7 @@ export async function checkIsDomainAvailable(config: { export async function createRepository(config: { domain: string; - name?: string; + name: string; framework: string; agent: string | undefined; token: string | undefined; @@ -328,15 +328,9 @@ export async function createRepository(config: { const url = new URL("app/dashboard/repositories", getDashboardUrl(host)); url.searchParams.set("app", "cli"); if (agent) url.searchParams.set("agent", agent); - - const body: Record = { domain, framework, plan: "personal" }; - if (name) { - body.name = name; - } - await request(url, { method: "POST", - body, + body: { domain, name, framework, plan: "personal" }, credentials: { "prismic-auth": token }, }); } diff --git a/src/commands/init.ts b/src/commands/init.ts index 0e5b1e9..315a8bb 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -20,7 +20,7 @@ import { UnknownProjectRootError, } from "../project"; import { checkIsTypeBuilderEnabled, TypeBuilderRequiredError } from "../project"; -import { createRepo, repositoryNameSchema } from "./repo-create"; +import { createRepo } from "./repo-create"; const config = { name: "prismic init", @@ -34,7 +34,7 @@ const config = { migrated. `, options: { - repo: { type: "string", short: "r", description: "Repository name (created if it doesn't exist)" }, + repo: { type: "string", short: "r", description: "Repository name" }, "no-browser": { type: "boolean", description: "Skip opening the browser automatically during login", @@ -96,39 +96,27 @@ export default createCommand(config, async ({ values }) => { } } - let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase(); - if (!repo) { - throw new CommandError( - "Missing --repo. Provide the repository name to connect to (creating it if it doesn't exist yet).", - ); - } - - const repoExistsInAccount = profile.repositories.some((r) => r.domain === repo); - if (!repoExistsInAccount) { - const parsed = repositoryNameSchema.safeParse(repo); - if (!parsed.success) { + let repo = explicitRepo ?? legacySliceMachineConfig?.repositoryName; + if (repo) { + const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); + if (!hasRepoAccess) { throw new CommandError( - `Invalid repository name "${repo}": ${parsed.error.issues[0]?.message ?? "Invalid value"}`, + `Repository "${repo}" not found in your account. Check the name or request access to the repository.`, ); } - } else { + const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host }); if (!isTypeBuilderEnabled) { throw new TypeBuilderRequiredError(); } } - // getAdapter checks for a supported framework; calling it before createRepo const adapter = await getAdapter(); - if (!repoExistsInAccount) { - console.info( - `Repository "${repo}" was not found in your account. Creating it...`, - ); - repo = await createRepo({ name: repo, token, host }); + if (!repo) { + repo = await createRepo({ token, host }); console.info(`Created repository: ${repo}`); } - // Create prismic.config.json try { diff --git a/src/commands/repo-create.ts b/src/commands/repo-create.ts index b63a77a..fe1bcb8 100644 --- a/src/commands/repo-create.ts +++ b/src/commands/repo-create.ts @@ -1,5 +1,3 @@ -import * as z from "zod/mini"; - import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { completeOnboardingStepsSilently } from "../clients/repository"; @@ -8,62 +6,37 @@ import { detectAgent } from "../lib/ai"; 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 MAX_DOMAIN_TRIES = 5; const config = { name: "prismic repo create", description: "Create a new Prismic repository.", options: { - 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", - }, + name: { type: "string", short: "n", description: "Display name for the repository" }, }, } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { name, "display-name": displayName } = values; + const { name } = values; const token = await getToken(); const host = await getHost(); - const domain = await createRepo({ name, displayName, token, host }); + const domain = await createRepo({ name, token, host }); console.info(`Repository created: ${domain}`); console.info(`URL: https://${domain}.${host}/`); }); export async function createRepo(config: { - name: string; - displayName?: string; + name?: string; token: string | undefined; host: string; }): Promise { - const { name, displayName, token, host } = config; - - const domain = name.toLowerCase(); + const { name, token, host } = config; - 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 domain = await findAvailableDomain({ token, host }); + if (!domain) { + throw new CommandError("Failed to create a repository. Please try again."); } const adapter = await getAdapter().catch(() => undefined); @@ -71,14 +44,7 @@ export async function createRepo(config: { const agent = await detectAgent(); try { - await createRepository({ - domain, - name: displayName, - framework, - agent, - token, - host, - }); + await createRepository({ domain, name: name ?? domain, framework, agent, token, host }); } catch (error) { if (error instanceof UnknownRequestError) { const message = await error.text(); @@ -96,3 +62,20 @@ 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 cbf4b79..c5243dc 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -1,7 +1,5 @@ import type { ParseArgsOptionDescriptor } from "node:util"; -import type * as z from "zod/mini"; - import { parseArgs } from "node:util"; import { dedent, formatTable } from "./string"; @@ -11,14 +9,7 @@ export type CommandConfig = { description: string; sections?: Record; positionals?: Record; - options?: Record< - string, - ParseArgsOptionDescriptor & { - description: string; - required?: boolean; - schema?: z.ZodMiniType; - } - >; + options?: Record; }; type CommandHandlerArgs = ParseArgsReturnType & { @@ -73,15 +64,6 @@ export function createCommand( if (config.required && !(name in result.values)) { throw new CommandError(`Missing required option: --${name}`); } - const optionValues = result.values as Record; - if (config.schema && name in optionValues) { - const parsed = config.schema.safeParse(optionValues[name]); - if (!parsed.success) { - const message = parsed.error.issues[0]?.message ?? "Invalid value"; - throw new CommandError(`Invalid ${name}: ${message}`); - } - optionValues[name] = parsed.data; - } } await handler(result as CommandHandlerArgs); diff --git a/test/init.test.ts b/test/init.test.ts index 1060916..b154366 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -1,9 +1,6 @@ import { access, readFile, rm, writeFile } from "node:fs/promises"; -import { onTestFinished } from "vitest"; - import { captureOutput, it } from "./it"; -import { cleanupRepository } from "./prismic"; it("supports --help", async ({ expect, prismic }) => { const { stdout, exitCode } = await prismic("init", ["--help"]); @@ -17,36 +14,22 @@ it("fails if prismic.config.json already exists", async ({ expect, prismic }) => expect(stderr).toContain("already initialized"); }); -it("creates a repo when --repo doesn't exist yet", async ({ +it("creates a repo if --repo is not provided and no legacy config exists", async ({ expect, project, prismic, - token, - host, - password, }) => { await rm(new URL("prismic.config.json", project)); - const rawName = `CLI-Test-${crypto.randomUUID().slice(0, 8)}`; - const name = rawName.toLowerCase(); - onTestFinished(() => cleanupRepository(name, { token, password, host })); - - const { exitCode, stdout } = await prismic("init", ["--repo", rawName]); + const { exitCode, stdout } = await prismic("init"); expect(exitCode).toBe(0); - expect(stdout).toContain(`Created repository: ${name}`); - expect(stdout).toContain(`Initialized Prismic for repository "${name}"`); + expect(stdout).toContain("Created repository:"); + expect(stdout).toContain("Initialized Prismic for repository"); const configRaw = await readFile(new URL("prismic.config.json", project), "utf-8"); const config = JSON.parse(configRaw); - expect(config.repositoryName).toBe(name); + expect(config.repositoryName).toMatch(/^[a-f0-9]{8}$/); }, 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, @@ -76,14 +59,11 @@ it("triggers login flow when not logged in", async ({ expect, project, prismic, proc.kill(); }); -it("fails if --repo is taken by another account", async ({ expect, project, prismic }) => { +it("fails if repo is not in the user's account", async ({ expect, project, prismic }) => { await rm(new URL("prismic.config.json", project)); - // "prismic" is reserved/taken and will fail availability check. - const { exitCode, stderr } = await prismic("init", ["--repo", "prismic"]); + const { exitCode, stderr } = await prismic("init", ["--repo", "nonexistent-repo-xyz-12345"]); expect(exitCode).toBe(1); - expect(stderr).toContain( - 'Repository name "prismic" is already taken. Choose a different name or request access to it.', - ); + expect(stderr).toContain("not found in your account"); }); it("migrates slicemachine.config.json", async ({ expect, project, prismic, repo }) => { diff --git a/test/prismic.ts b/test/prismic.ts index eb59a77..941baac 100644 --- a/test/prismic.ts +++ b/test/prismic.ts @@ -49,14 +49,6 @@ export async function deleteRepository( } } -export async function cleanupRepository( - domain: string | undefined, - config: AuthConfig & { password: string }, -): Promise { - if (domain === undefined) return; - await deleteRepository(domain, config); -} - export async function getCustomTypes(config: RepoConfig): Promise { const host = config.host ?? DEFAULT_HOST; const url = new URL("customtypes", `https://customtypes.${host}/`); diff --git a/test/repo-create.test.ts b/test/repo-create.test.ts index 8d6582b..8402575 100644 --- a/test/repo-create.test.ts +++ b/test/repo-create.test.ts @@ -1,7 +1,7 @@ import { onTestFinished } from "vitest"; import { it } from "./it"; -import { cleanupRepository, getRepository } from "./prismic"; +import { deleteRepository, getRepository } from "./prismic"; it("supports --help", async ({ expect, prismic }) => { const { stdout, exitCode } = await prismic("repo", ["create", "--help"]); @@ -9,87 +9,31 @@ it("supports --help", async ({ expect, prismic }) => { expect(stdout).toContain("prismic repo create [options]"); }); -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)}`; - onTestFinished(() => cleanupRepository(name, { token, password, host })); - - const { stdout, exitCode } = await prismic("repo", ["create", "--name", name]); +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: ${name}`); + expect(stdout).toContain("Repository created:"); - 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(); - onTestFinished(() => cleanupRepository(expectedDomain, { token, password, host })); + onTestFinished(() => deleteRepository(domain!, { token, password, host })); - const { stdout, exitCode } = await prismic("repo", ["create", "--name", name]); - expect(exitCode).toBe(0); - expect(stdout).toContain(`Repository created: ${expectedDomain}`); - - const repository = await getRepository({ repo: expectedDomain, token, host }); - expect(repository.name).toBe(expectedDomain); -}); - -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:"); + const repository = await getRepository({ repo: domain!, token, host }); + expect(repository).toBeDefined(); }); -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"; - onTestFinished(() => cleanupRepository(name, { token, password, host })); - - const { stdout, exitCode } = await prismic("repo", [ - "create", - "--name", - name, - "--display-name", - displayName, - ]); +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]); expect(exitCode).toBe(0); - expect(stdout).toContain(`Repository created: ${name}`); + expect(stdout).toContain("Repository created:"); - const repository = await getRepository({ repo: name, token, host }); - expect(repository.name).toBe(displayName); -}); + const domain = stdout.match(/Repository created: (\S+)/)?.[1]; + expect(domain).toBeDefined(); -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:"); + onTestFinished(() => deleteRepository(domain!, { token, password, host })); - const tooLong = await prismic("repo", ["create", "--name", `a${"x".repeat(62)}z`]); - expect(tooLong.exitCode).toBe(1); - expect(tooLong.stderr).toContain("Invalid name:"); + const repository = await getRepository({ repo: domain!, token, host }); + expect(repository.name).toBe(name); });