From c72552a2b602d5754b789d2cb41c59f9f659c19d Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Tue, 24 Mar 2026 18:05:31 -1000 Subject: [PATCH] feat: add repo commands Re-enable repository management commands: create, list, view, set-name, and set-api-access. Each command connects to the Prismic APIs for repository CRUD operations. Resolves #16 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/clients/user.ts | 1 + src/clients/wroom.ts | 92 +++++++++++++++++++++++++++++ src/commands/repo-create.ts | 60 +++++++++++++++++++ src/commands/repo-list.ts | 58 ++++++++++++++++++ src/commands/repo-set-api-access.ts | 53 +++++++++++++++++ src/commands/repo-set-name.ts | 46 +++++++++++++++ src/commands/repo-view.ts | 74 +++++++++++++++++++++++ src/commands/repo.ts | 33 +++++++++++ src/index.ts | 5 ++ test/it.ts | 5 ++ test/preview-set-simulator.test.ts | 2 +- test/prismic.ts | 21 ++++++- test/repo-create.test.ts | 39 ++++++++++++ test/repo-list.test.ts | 22 +++++++ test/repo-set-api-access.test.ts | 16 +++++ test/repo-set-name.test.ts | 18 ++++++ test/repo-view.test.ts | 26 ++++++++ test/repo.test.ts | 13 ++++ 18 files changed, 580 insertions(+), 4 deletions(-) create mode 100644 src/commands/repo-create.ts create mode 100644 src/commands/repo-list.ts create mode 100644 src/commands/repo-set-api-access.ts create mode 100644 src/commands/repo-set-name.ts create mode 100644 src/commands/repo-view.ts create mode 100644 src/commands/repo.ts create mode 100644 test/repo-create.test.ts create mode 100644 test/repo-list.test.ts create mode 100644 test/repo-set-api-access.test.ts create mode 100644 test/repo-set-name.test.ts create mode 100644 test/repo-view.test.ts create mode 100644 test/repo.test.ts diff --git a/src/clients/user.ts b/src/clients/user.ts index 9391710..d30e630 100644 --- a/src/clients/user.ts +++ b/src/clients/user.ts @@ -10,6 +10,7 @@ const ProfileSchema = z.object({ z.object({ domain: z.string(), name: z.optional(z.string()), + role: z.optional(z.string()), }), ), }); diff --git a/src/clients/wroom.ts b/src/clients/wroom.ts index 52ac530..69ab897 100644 --- a/src/clients/wroom.ts +++ b/src/clients/wroom.ts @@ -226,6 +226,98 @@ export async function deleteWriteToken( }); } +export async function checkIsDomainAvailable(config: { + domain: string; + token: string | undefined; + host: string; +}): Promise { + const { domain, token, host } = config; + const url = new URL(`app/dashboard/repositories/${domain}/exists`, getDashboardUrl(host)); + const response = await request(url, { + credentials: { "prismic-auth": token }, + schema: z.boolean(), + }); + return response; +} + +export async function createRepository(config: { + domain: string; + name: string; + framework: string; + token: string | undefined; + host: string; +}): Promise { + const { domain, name, framework, token, host } = config; + const url = new URL("app/dashboard/repositories", getDashboardUrl(host)); + await request(url, { + method: "POST", + body: { domain, name, framework, plan: "personal" }, + credentials: { "prismic-auth": token }, + }); +} + +const SyncStateSchema = z.object({ + repository: z.object({ + api_access: z.string(), + }), +}); + +export async function getRepositoryAccess(config: { + repo: string; + token: string | undefined; + host: string; +}): Promise { + const { repo, token, host } = config; + const url = new URL("syncState", getWroomUrl(repo, host)); + const response = await request(url, { + credentials: { "prismic-auth": token }, + schema: SyncStateSchema, + }); + return response.repository.api_access; +} + +export type RepositoryAccessLevel = "private" | "public" | "open"; + +export async function setRepositoryAccess( + level: RepositoryAccessLevel, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const url = new URL("settings/security/apiaccess", getWroomUrl(repo, host)); + await request(url, { + method: "POST", + body: { api_access: level }, + credentials: { "prismic-auth": token }, + }); +} + +const SetNameResponseSchema = z.object({ + repository: z.object({ + name: z.string(), + }), +}); + +export async function setRepositoryName( + name: string, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const url = new URL("app/settings/repository", getWroomUrl(repo, host)); + const formData = new FormData(); + formData.set("displayname", name); + const response = await request(url, { + method: "POST", + body: formData, + credentials: { "prismic-auth": token }, + schema: SetNameResponseSchema, + }); + return response.repository.name; +} + +function getDashboardUrl(host: string): URL { + return new URL(`https://${host}/`); +} + function getWroomUrl(repo: string, host: string): URL { return new URL(`https://${repo}.${host}/`); } diff --git a/src/commands/repo-create.ts b/src/commands/repo-create.ts new file mode 100644 index 0000000..d0ea8f8 --- /dev/null +++ b/src/commands/repo-create.ts @@ -0,0 +1,60 @@ +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; + +const config = { + name: "prismic repo create", + description: "Create a new Prismic repository.", + options: { + name: { type: "string", short: "n", description: "Display name for the repository" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { name } = values; + + const token = await getToken(); + const host = await getHost(); + + 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); + const framework = adapter?.id ?? "other"; + + try { + await createRepository({ domain, name: name ?? domain, framework, token, host }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to create repository: ${message}`); + } + throw error; + } + + console.info(`Repository created: ${domain}`); + console.info(`URL: https://${domain}.${host}/`); +}); + +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/commands/repo-list.ts b/src/commands/repo-list.ts new file mode 100644 index 0000000..db67818 --- /dev/null +++ b/src/commands/repo-list.ts @@ -0,0 +1,58 @@ +import { getHost, getToken } from "../auth"; +import { getProfile } from "../clients/user"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; +import { UnknownRequestError } from "../lib/request"; + +const config = { + name: "prismic repo list", + description: "List all Prismic repositories associated with your account.", + options: { + json: { type: "boolean", description: "Output as JSON" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { json } = values; + + const token = await getToken(); + const host = await getHost(); + + let profile; + try { + profile = await getProfile({ token, host }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to list repositories: ${message}`); + } + throw error; + } + + const repos = profile.repositories; + + if (json) { + console.info( + stringify( + repos.map((repo) => ({ + domain: repo.domain, + name: repo.name ?? null, + role: repo.role ?? null, + url: `https://${repo.domain}.${host}/`, + })), + ), + ); + return; + } + + if (repos.length === 0) { + console.info("No repositories found."); + return; + } + + for (const repo of repos) { + const name = repo.name || "(no name)"; + const role = repo.role ? ` ${repo.role}` : ""; + console.info(`${repo.domain} ${name}${role}`); + } +}); diff --git a/src/commands/repo-set-api-access.ts b/src/commands/repo-set-api-access.ts new file mode 100644 index 0000000..40bfb0e --- /dev/null +++ b/src/commands/repo-set-api-access.ts @@ -0,0 +1,53 @@ +import { getHost, getToken } from "../auth"; +import { type RepositoryAccessLevel, setRepositoryAccess } from "../clients/wroom"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const VALID_LEVELS: RepositoryAccessLevel[] = ["private", "public", "open"]; + +const config = { + name: "prismic repo set-api-access", + description: ` + Set the Content API access level of a Prismic repository. + + By default, this command reads the repository from prismic.config.json at the + project root. + `, + positionals: { + level: { description: `Access level (${VALID_LEVELS.join(", ")})` }, + }, + options: { + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [level] = positionals; + const { repo = await getRepositoryName() } = values; + + if (!level) { + throw new CommandError("Missing required argument: "); + } + + if (!VALID_LEVELS.includes(level as RepositoryAccessLevel)) { + throw new CommandError( + `Invalid access level: ${level}. Must be one of: ${VALID_LEVELS.join(", ")}`, + ); + } + + const token = await getToken(); + const host = await getHost(); + + try { + await setRepositoryAccess(level as RepositoryAccessLevel, { repo, token, host }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to set repository access: ${message}`); + } + throw error; + } + + console.info(`Repository access set to: ${level}`); +}); diff --git a/src/commands/repo-set-name.ts b/src/commands/repo-set-name.ts new file mode 100644 index 0000000..92181bc --- /dev/null +++ b/src/commands/repo-set-name.ts @@ -0,0 +1,46 @@ +import { getHost, getToken } from "../auth"; +import { setRepositoryName } from "../clients/wroom"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic repo set-name", + description: ` + Set the display name of a Prismic repository. + + By default, this command reads the repository from prismic.config.json at the + project root. + `, + positionals: { + name: { description: "Display name for the repository" }, + }, + options: { + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [displayName] = positionals; + const { repo = await getRepositoryName() } = values; + + if (!displayName) { + throw new CommandError("Missing required argument: "); + } + + const token = await getToken(); + const host = await getHost(); + + let confirmedName; + try { + confirmedName = await setRepositoryName(displayName, { repo, token, host }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to set repository name: ${message}`); + } + throw error; + } + + console.info(`Repository name set to: ${confirmedName}`); +}); diff --git a/src/commands/repo-view.ts b/src/commands/repo-view.ts new file mode 100644 index 0000000..85051e1 --- /dev/null +++ b/src/commands/repo-view.ts @@ -0,0 +1,74 @@ +import { getHost, getToken } from "../auth"; +import { openBrowser } from "../lib/browser"; +import { getProfile } from "../clients/user"; +import { getRepositoryAccess } from "../clients/wroom"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic repo view", + description: ` + View details of a Prismic repository. + + By default, this command reads the repository from prismic.config.json at the + project root. + `, + options: { + web: { type: "boolean", short: "w", description: "Open repository in browser" }, + json: { type: "boolean", description: "Output as JSON" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { repo = await getRepositoryName(), web, json } = values; + + const token = await getToken(); + const host = await getHost(); + const url = `https://${repo}.${host}/`; + + if (web) { + openBrowser(new URL(url)); + console.info(`Opening ${url}`); + return; + } + + let profile; + let access; + try { + [profile, access] = await Promise.all([ + getProfile({ token, host }), + getRepositoryAccess({ repo, token, host }), + ]); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to fetch repository details: ${message}`); + } + throw error; + } + + const repoData = profile.repositories.find((r) => r.domain === repo); + if (!repoData) { + throw new CommandError(`Repository not found: ${repo}`); + } + + if (json) { + console.info( + stringify({ + domain: repoData.domain, + name: repoData.name ?? null, + url, + apiAccess: access, + }), + ); + return; + } + + const name = repoData.name || "(no name)"; + console.info(`Name: ${name}`); + console.info(`URL: ${url}`); + console.info(`Content API: ${access}`); +}); diff --git a/src/commands/repo.ts b/src/commands/repo.ts new file mode 100644 index 0000000..94ee3d1 --- /dev/null +++ b/src/commands/repo.ts @@ -0,0 +1,33 @@ +import { createCommandRouter } from "../lib/command"; +import repoCreate from "./repo-create"; +import repoList from "./repo-list"; +import repoSetApiAccess from "./repo-set-api-access"; +import repoSetName from "./repo-set-name"; +import repoView from "./repo-view"; + +export default createCommandRouter({ + name: "prismic repo", + description: "Manage Prismic repositories.", + commands: { + create: { + handler: repoCreate, + description: "Create a new repository", + }, + list: { + handler: repoList, + description: "List repositories", + }, + view: { + handler: repoView, + description: "View repository details", + }, + "set-name": { + handler: repoSetName, + description: "Set repository display name", + }, + "set-api-access": { + handler: repoSetApiAccess, + description: "Set Content API access level", + }, + }, +}); diff --git a/src/index.ts b/src/index.ts index b616da9..696df1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import locale from "./commands/locale"; import login from "./commands/login"; import logout from "./commands/logout"; import preview from "./commands/preview"; +import repo from "./commands/repo"; import sync from "./commands/sync"; import token from "./commands/token"; import webhook from "./commands/webhook"; @@ -58,6 +59,10 @@ const router = createCommandRouter({ handler: locale, description: "Manage locales", }, + repo: { + handler: repo, + description: "Manage repositories", + }, preview: { handler: preview, description: "Manage preview configurations", diff --git a/test/it.ts b/test/it.ts index d1133db..49c43ee 100644 --- a/test/it.ts +++ b/test/it.ts @@ -20,6 +20,7 @@ export type Fixtures = { login: () => Promise<{ token: string; email: string }>; logout: () => Promise; token: string; + password: string; repo: string; }; @@ -108,6 +109,10 @@ export const it = test.extend({ } }, // oxlint-disable-next-line no-empty-pattern + password: async ({}, use) => { + await use(process.env.E2E_PRISMIC_PASSWORD!); + }, + // oxlint-disable-next-line no-empty-pattern repo: async ({}, use) => { await use(inject("repo")); }, diff --git a/test/preview-set-simulator.test.ts b/test/preview-set-simulator.test.ts index 1c70bca..dc274de 100644 --- a/test/preview-set-simulator.test.ts +++ b/test/preview-set-simulator.test.ts @@ -22,7 +22,7 @@ it.sequential("sets simulator URL", async ({ expect, prismic, repo, token, host expect(stdout).toContain(`Simulator URL set: ${simulatorUrl}`); const repository = await getRepository({ repo, token, host }); - expect(repository.simulator_url).toBe(simulatorUrl); + expect(repository.simulatorUrl).toBe(simulatorUrl); }); // Must be sequential because the repo only has one simulator URL. diff --git a/test/prismic.ts b/test/prismic.ts index 9fbef41..3a1c50b 100644 --- a/test/prismic.ts +++ b/test/prismic.ts @@ -325,12 +325,27 @@ export async function deleteWriteToken(tokenValue: string, config: RepoConfig): throw new Error(`Failed to delete write token: ${res.status} ${await res.text()}`); } -export async function getRepository(config: RepoConfig): Promise<{ simulator_url?: string }> { +export async function getRepository( + config: RepoConfig, +): Promise<{ name: string; framework: string; simulatorUrl?: string }> { const host = config.host ?? DEFAULT_HOST; - const url = new URL("core/repository", `https://${config.repo}.${host}/`); + const url = new URL("repository/", `https://api.internal.${host}/`); + url.searchParams.set("repository", config.repo); const res = await fetch(url, { - headers: { Cookie: `prismic-auth=${config.token}` }, + headers: { Authorization: `Bearer ${config.token}` }, }); if (!res.ok) throw new Error(`Failed to get repository: ${res.status} ${await res.text()}`); return await res.json(); } + +export async function getRepositoryAccess(config: RepoConfig): Promise { + const host = config.host ?? DEFAULT_HOST; + const url = new URL("syncState", `https://${config.repo}.${host}/`); + const res = await fetch(url, { + headers: { Cookie: `prismic-auth=${config.token}` }, + }); + if (!res.ok) + throw new Error(`Failed to get repository access: ${res.status} ${await res.text()}`); + const data = await res.json(); + return data.repository.api_access; +} diff --git a/test/repo-create.test.ts b/test/repo-create.test.ts new file mode 100644 index 0000000..8402575 --- /dev/null +++ b/test/repo-create.test.ts @@ -0,0 +1,39 @@ +import { onTestFinished } from "vitest"; + +import { it } from "./it"; +import { deleteRepository, getRepository } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("repo", ["create", "--help"]); + expect(exitCode).toBe(0); + 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:"); + + const domain = stdout.match(/Repository created: (\S+)/)?.[1]; + expect(domain).toBeDefined(); + + onTestFinished(() => deleteRepository(domain!, { token, password, host })); + + const repository = await getRepository({ repo: domain!, 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]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Repository created:"); + + const domain = stdout.match(/Repository created: (\S+)/)?.[1]; + expect(domain).toBeDefined(); + + onTestFinished(() => deleteRepository(domain!, { token, password, host })); + + const repository = await getRepository({ repo: domain!, token, host }); + expect(repository.name).toBe(name); +}); diff --git a/test/repo-list.test.ts b/test/repo-list.test.ts new file mode 100644 index 0000000..fd4b6b8 --- /dev/null +++ b/test/repo-list.test.ts @@ -0,0 +1,22 @@ +import { it } from "./it"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("repo", ["list", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic repo list [options]"); +}); + +it("lists repositories", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("repo", ["list"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(repo); +}); + +it("lists repositories as JSON", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("repo", ["list", "--json"]); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed).toEqual( + expect.arrayContaining([expect.objectContaining({ domain: repo })]), + ); +}); diff --git a/test/repo-set-api-access.test.ts b/test/repo-set-api-access.test.ts new file mode 100644 index 0000000..793a67f --- /dev/null +++ b/test/repo-set-api-access.test.ts @@ -0,0 +1,16 @@ +import { it } from "./it"; +import { getRepositoryAccess } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("repo", ["set-api-access", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic repo set-api-access [options]"); +}); + +it("sets the repository API access level", async ({ expect, prismic, repo, token, host }) => { + const { exitCode } = await prismic("repo", ["set-api-access", "open"]); + expect(exitCode).toBe(0); + + const access = await getRepositoryAccess({ repo, token, host }); + expect(access).toBe("open"); +}); diff --git a/test/repo-set-name.test.ts b/test/repo-set-name.test.ts new file mode 100644 index 0000000..caeeb78 --- /dev/null +++ b/test/repo-set-name.test.ts @@ -0,0 +1,18 @@ +import { it } from "./it"; +import { getRepository } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("repo", ["set-name", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic repo set-name [options]"); +}); + +it("sets the repository display name", async ({ expect, prismic, repo, token, host }) => { + const name = `Test ${crypto.randomUUID().slice(0, 8)}`; + const { stdout, exitCode } = await prismic("repo", ["set-name", name]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Repository name set to:"); + + const repository = await getRepository({ repo, token, host }); + expect(repository.name).toBe(name); +}); diff --git a/test/repo-view.test.ts b/test/repo-view.test.ts new file mode 100644 index 0000000..263cb4e --- /dev/null +++ b/test/repo-view.test.ts @@ -0,0 +1,26 @@ +import { it } from "./it"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("repo", ["view", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic repo view [options]"); +}); + +it("views repository details", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("repo", ["view"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(repo); +}); + +it("views repository details as JSON", async ({ expect, prismic, repo }) => { + const { stdout, exitCode } = await prismic("repo", ["view", "--json"]); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed).toEqual( + expect.objectContaining({ + domain: repo, + url: expect.stringContaining(repo), + apiAccess: expect.any(String), + }), + ); +}); diff --git a/test/repo.test.ts b/test/repo.test.ts new file mode 100644 index 0000000..c0b4be9 --- /dev/null +++ b/test/repo.test.ts @@ -0,0 +1,13 @@ +import { it } from "./it"; + +it("prints help by default", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("repo"); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic repo [options]"); +}); + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("repo", ["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic repo [options]"); +});