Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
aeb446a
fix: use --name as repository domain in repo create and init
jomifepe May 7, 2026
ba8a947
refactor: require --name to be kebab-case instead of auto-formatting
jomifepe May 7, 2026
ad84956
refactor: align domain regex with Wroom/Dashboard
jomifepe May 7, 2026
6607b4f
fix: lowercase --name before submitting to match server behavior
jomifepe May 7, 2026
a371fcc
refactor: remove --name from init, have --repo create if missing
jomifepe May 7, 2026
ec80744
refactor: minimize init changes for new --repo-creates-if-missing flow
jomifepe May 7, 2026
b65a47a
refactor: inline validateRepositoryDomain into repo-create.ts
jomifepe May 7, 2026
10dd57b
feat: use zod schema
jomifepe May 7, 2026
525298c
fix: test
jomifepe May 7, 2026
4d1e26a
fix: validate --repo
jomifepe May 7, 2026
b163f83
refactor: move code
jomifepe May 7, 2026
08566de
feat: logging
jomifepe May 8, 2026
e0b7557
feat: logging
jomifepe May 8, 2026
8c64c1d
Apply suggestions from code review
jomifepe May 8, 2026
bef603a
refactor: redundant check
jomifepe May 8, 2026
6019802
refactor: rename
jomifepe May 8, 2026
913749f
refactor: cleanup
jomifepe May 8, 2026
51aabe9
test: max
jomifepe May 8, 2026
2d36ecd
refactor: remove casting
jomifepe May 8, 2026
d088ee3
Merge remote-tracking branch 'origin/main' into jp/repo-display-name
jomifepe May 8, 2026
e28f384
fix: call order
jomifepe May 8, 2026
2896d8f
test: move cleanup
jomifepe May 8, 2026
4cd5804
feat: match dashboard behavior omitting name
jomifepe May 8, 2026
89c8bd4
test: lowercase conversion
jomifepe May 8, 2026
428aab2
refactor: cleanup
jomifepe May 8, 2026
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: 8 additions & 2 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,9 +328,15 @@ 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: { domain, name, framework, plan: "personal" },
body,
credentials: { "prismic-auth": token },
});
}
Expand Down
32 changes: 22 additions & 10 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 } from "./repo-create";
import { createRepo, repositoryNameSchema } 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" },
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",
Expand Down Expand Up @@ -96,27 +96,39 @@ 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();
Comment thread
jomifepe marked this conversation as resolved.
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) {
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"}`,
);
}

} 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 (!repo) {
repo = await createRepo({ token, host });
if (!repoExistsInAccount) {
console.info(
`Repository "${repo}" was not found in your account. Creating it...`,
);
Comment thread
jomifepe marked this conversation as resolved.
repo = await createRepo({ name: repo, token, host });
console.info(`Created repository: ${repo}`);
}


// Create prismic.config.json
try {
Expand Down
71 changes: 44 additions & 27 deletions src/commands/repo-create.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as z from "zod/mini";

import { getAdapter } from "../adapters";
import { getHost, getToken } from "../auth";
import { completeOnboardingStepsSilently } from "../clients/repository";
Expand All @@ -6,45 +8,77 @@ import { detectAgent } from "../lib/ai";
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",
},
},
} 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<string> {
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 a different name or request access to it.`,
);
}

const adapter = await getAdapter().catch(() => undefined);
const framework = adapter?.id ?? "other";
const agent = await detectAgent();

try {
await createRepository({ domain, name: name ?? domain, framework, agent, token, host });
await createRepository({
domain,
name: displayName,
framework,
agent,
token,
host,
});
} catch (error) {
if (error instanceof UnknownRequestError) {
const message = await error.text();
Expand All @@ -62,20 +96,3 @@ 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: 19 additions & 1 deletion src/lib/command.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,7 +11,14 @@ export type CommandConfig = {
description: string;
sections?: Record<string, string>;
positionals?: Record<string, { description: string; required?: boolean }>;
options?: Record<string, ParseArgsOptionDescriptor & { description: string; required?: boolean }>;
options?: Record<
string,
ParseArgsOptionDescriptor & {
description: string;
required?: boolean;
schema?: z.ZodMiniType<unknown, unknown>;
}
>;
};

type CommandHandlerArgs<T extends CommandConfig> = ParseArgsReturnType<T> & {
Expand Down Expand Up @@ -64,6 +73,15 @@ 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: 28 additions & 8 deletions test/init.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
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 @@ -14,22 +17,36 @@ 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 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]);
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,
Expand Down Expand Up @@ -59,11 +76,14 @@ 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(
'Repository name "prismic" is already taken. Choose a different name or request access to it.',
);
});

it("migrates slicemachine.config.json", async ({ expect, project, prismic, repo }) => {
Expand Down
8 changes: 8 additions & 0 deletions test/prismic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ 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