Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions src/clients/wroom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, unknown> = { 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 },
});
}
Expand Down
32 changes: 10 additions & 22 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
UnknownProjectRootError,
} from "../project";
import { checkIsTypeBuilderEnabled, TypeBuilderRequiredError } from "../project";
import { createRepo, repositoryNameSchema } from "./repo-create";
import { createRepo } from "./repo-create";

const config = {
name: "prismic init",
Expand All @@ -34,7 +34,7 @@ const config = {
migrated.
`,
options: {
repo: { type: "string", short: "r", description: "Repository name (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",
Expand Down Expand Up @@ -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 {
Expand Down
71 changes: 27 additions & 44 deletions src/commands/repo-create.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import * as z from "zod/mini";

import { getAdapter } from "../adapters";
import { getHost, getToken } from "../auth";
import { completeOnboardingStepsSilently } from "../clients/repository";
Expand All @@ -8,77 +6,45 @@ 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<string> {
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);
const framework = adapter?.id ?? "other";
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();
Expand All @@ -96,3 +62,20 @@ export async function createRepo(config: {

return domain;
}

async function findAvailableDomain(config: {
token: string | undefined;
host: string;
}): Promise<string | undefined> {
const { token, host } = config;
let domain;
for (let i = 0; i < MAX_DOMAIN_TRIES; i++) {
const candidate = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
const available = await checkIsDomainAvailable({ domain: candidate, token, host });
if (available) {
domain = candidate;
break;
}
}
return domain;
}
20 changes: 1 addition & 19 deletions src/lib/command.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,14 +9,7 @@ export type CommandConfig = {
description: string;
sections?: Record<string, string>;
positionals?: Record<string, { description: string; required?: boolean }>;
options?: Record<
string,
ParseArgsOptionDescriptor & {
description: string;
required?: boolean;
schema?: z.ZodMiniType<unknown, unknown>;
}
>;
options?: Record<string, ParseArgsOptionDescriptor & { description: string; required?: boolean }>;
};

type CommandHandlerArgs<T extends CommandConfig> = ParseArgsReturnType<T> & {
Expand Down Expand Up @@ -73,15 +64,6 @@ export function createCommand<T extends CommandConfig>(
if (config.required && !(name in result.values)) {
throw new CommandError(`Missing required option: --${name}`);
}
const optionValues = result.values as Record<string, unknown>;
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<T>);
Expand Down
36 changes: 8 additions & 28 deletions test/init.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
Expand All @@ -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 }));
Comment thread
jomifepe marked this conversation as resolved.

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,
Expand Down Expand Up @@ -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 }) => {
Expand Down
8 changes: 0 additions & 8 deletions test/prismic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,6 @@ export async function deleteRepository(
}
}

export async function cleanupRepository(
domain: string | undefined,
config: AuthConfig & { password: string },
): Promise<void> {
if (domain === undefined) return;
await deleteRepository(domain, config);
}

export async function getCustomTypes(config: RepoConfig): Promise<CustomType[]> {
const host = config.host ?? DEFAULT_HOST;
const url = new URL("customtypes", `https://customtypes.${host}/`);
Expand Down
Loading