From d3d842857e7b872b2c7c1fe879063722be8d3a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:45:48 +0100 Subject: [PATCH 01/20] feat: add instant-start CLI command Provide a single CLI flow to provision or export an Instant Start repository, download the starter, and set up local development. Co-authored-by: Cursor --- package-lock.json | 9 ++ package.json | 3 + src/commands/index.ts | 5 + src/commands/instant-start.ts | 115 ++++++++++++++ src/lib/packageJson.ts | 27 ++-- src/lib/prismic/clients/website-generator.ts | 100 ++++++++++++ src/lib/zip.ts | 78 +++++++++ test/instant-start-command.test.ts | 108 +++++++++++++ test/instant-start.test.ts | 158 +++++++++++++++++++ 9 files changed, 591 insertions(+), 12 deletions(-) create mode 100644 src/commands/instant-start.ts create mode 100644 src/lib/prismic/clients/website-generator.ts create mode 100644 src/lib/zip.ts create mode 100644 test/instant-start-command.test.ts create mode 100644 test/instant-start.test.ts diff --git a/package-lock.json b/package-lock.json index f9fd32e..cb38c87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "prismic", "version": "1.12.2", "license": "Apache-2.0", + "dependencies": { + "fflate": "^0.8.3" + }, "bin": { "prismic": "dist/index.mjs" }, @@ -2339,6 +2342,12 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/package.json b/package.json index cbf7ac3..c03994a 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "unit:watch": "concurrently --raw --kill-others \"cross-env MODE=test tsdown --watch --logLevel=warn\" \"vitest watch\"", "test": "npm run lint && npm run types && npm run unit" }, + "dependencies": { + "fflate": "^0.8.3" + }, "devDependencies": { "@prismicio/types-internal": "3.16.1", "@types/node": "25.0.9", diff --git a/src/commands/index.ts b/src/commands/index.ts index cccbbdf..1d6aed9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -3,6 +3,7 @@ import docs from "./docs"; import field from "./field"; import gen from "./gen"; import init from "./init"; +import instantStart from "./instant-start"; import locale from "./locale"; import login from "./login"; import logout from "./logout"; @@ -32,6 +33,10 @@ export default createCommandRouter({ handler: init, description: "Initialize a Prismic project", }, + "instant-start": { + handler: instantStart, + description: "Create and set up an Instant Start project", + }, docs: { handler: docs, description: "Browse Prismic documentation", diff --git a/src/commands/instant-start.ts b/src/commands/instant-start.ts new file mode 100644 index 0000000..70a9ac8 --- /dev/null +++ b/src/commands/instant-start.ts @@ -0,0 +1,115 @@ +import { mkdir, rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { getCredentials } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { exists, readURLFile } from "../lib/file"; +import { installDependencies } from "../lib/packageJson"; +import { setSimulatorUrl } from "../lib/prismic/clients/core"; +import { getProfile } from "../lib/prismic/clients/user"; +import { + getOrCreateInstantStartExport, + provisionInstantStart, +} from "../lib/prismic/clients/website-generator"; +import { extractZip } from "../lib/zip"; + +const config = { + name: "prismic instant-start", + description: ` + Create a ready-to-run Prismic website from the Instant Start template. + + Use --export to download and set up an existing repository instead of + creating a new one. + `, + options: { + export: { + type: "string", + description: "Existing repository to export", + }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { export: repositoryToExport } = values; + await runInstantStart({ repositoryToExport }); +}); + +export async function runInstantStart(options: { repositoryToExport?: string }): Promise { + const { repositoryToExport } = options; + const { token, host } = await getCredentials(); + + console.info("Checking Prismic login..."); + const profile = await getProfile({ token, host }); + console.info(`Logged in as ${profile.email}`); + + let repositoryId = repositoryToExport?.toLowerCase(); + let createdRepository = false; + + if (!repositoryId) { + console.info("Creating your Instant Start repository..."); + const provisioned = await provisionInstantStart({ token, host }); + repositoryId = provisioned.repositoryId; + createdRepository = true; + console.info(`Created repository: ${repositoryId}`); + } + + assertRepositoryName(repositoryId); + + let extractedProject: { destination: string; destinationExisted: boolean } | undefined; + + try { + console.info("Preparing the project export..."); + const readyExport = await getOrCreateInstantStartExport(repositoryId, { + token, + host, + }); + + console.info("Downloading the project..."); + const archive = await readURLFile(new URL(readyExport.downloadUrl)); + const destination = resolve(process.cwd(), repositoryId); + const destinationExisted = await exists(pathToFileURL(destination)); + await extractZip(new Uint8Array(await archive.arrayBuffer()), destination); + extractedProject = { destination, destinationExisted }; + + console.info("Installing dependencies..."); + await installDependencies({ start: pathToFileURL(destination) }); + + console.info("Setting local simulator URL..."); + await setSimulatorUrl("http://localhost:3000/slice-simulator", { + repo: repositoryId, + token, + host, + }); + + console.info(` +Your project is ready. + +Project: ${destination} +Page Builder: https://${repositoryId}.${host}/builder + +Run: + cd ${repositoryId} + npm run dev +`); + } catch (error) { + if (extractedProject) { + await rm(extractedProject.destination, { recursive: true, force: true }); + if (extractedProject.destinationExisted) { + await mkdir(extractedProject.destination); + } + } + if (createdRepository) { + console.error( + `Repository "${repositoryId}" was created. Retry setup with:\n npx prismic@latest instant-start --export ${repositoryId}`, + ); + } + throw error; + } +} + +function assertRepositoryName(repositoryId: string): void { + if (!/^[a-z0-9][a-z0-9-]*$/.test(repositoryId)) { + throw new CommandError(`Invalid repository name: ${repositoryId}`); + } +} diff --git a/src/lib/packageJson.ts b/src/lib/packageJson.ts index 9b71f11..69de377 100644 --- a/src/lib/packageJson.ts +++ b/src/lib/packageJson.ts @@ -15,14 +15,14 @@ const PackageJsonSchema = z.object({ }); type PackageJson = z.infer; -export async function readPackageJson(): Promise { - const packageJsonPath = await findPackageJson(); +export async function readPackageJson(config: { start?: URL } = {}): Promise { + const packageJsonPath = await findPackageJson(config); const packageJson = await readJsonFile(packageJsonPath, { schema: PackageJsonSchema }); return packageJson; } -export async function findPackageJson(): Promise { - const packageJsonPath = await findUpward("package.json"); +export async function findPackageJson(config: { start?: URL } = {}): Promise { + const packageJsonPath = await findUpward("package.json", config); if (!packageJsonPath) throw new MissingPackageJson(); return packageJsonPath; } @@ -75,10 +75,10 @@ const INSTALL_COMMANDS = { bun: ["bun", "install"], }; -export async function installDependencies(): Promise { - const packageJsonPath = await findPackageJson(); +export async function installDependencies(config: { start?: URL } = {}): Promise { + const packageJsonPath = await findPackageJson(config); const cwd = new URL(".", packageJsonPath); - const packageManager = await detectPackageManager(); + const packageManager = await detectPackageManager(packageJsonPath); const [command, ...args] = INSTALL_COMMANDS[packageManager]; await x(command, args, { nodeOptions: { cwd: fileURLToPath(cwd), stdio: "inherit" }, @@ -94,11 +94,10 @@ const PACKAGE_MANAGER_LOCKFILES: Record = "package-lock.json": "npm", }; -async function detectPackageManager(): Promise { - const packageManager = await readPackageManager(); +async function detectPackageManager(packageJsonPath: URL): Promise { + const packageManager = await readPackageManager(packageJsonPath); if (packageManager) return packageManager; - const packageJsonPath = await findPackageJson(); for (const file in PACKAGE_MANAGER_LOCKFILES) { const packageManager = PACKAGE_MANAGER_LOCKFILES[file]; const hasLockfile = await exists(new URL(file, packageJsonPath)); @@ -108,9 +107,13 @@ async function detectPackageManager(): Promise { return "npm"; } -async function readPackageManager(): Promise { +async function readPackageManager( + packageJsonPath: URL, +): Promise { try { - const packageJson = await readPackageJson(); + const packageJson = await readJsonFile(packageJsonPath, { + schema: PackageJsonSchema, + }); if (!packageJson.packageManager) return; const packageManager = packageJson.packageManager.split("@")[0]; if (packageManager in INSTALL_COMMANDS) return packageManager as keyof typeof INSTALL_COMMANDS; diff --git a/src/lib/prismic/clients/website-generator.ts b/src/lib/prismic/clients/website-generator.ts new file mode 100644 index 0000000..03e5625 --- /dev/null +++ b/src/lib/prismic/clients/website-generator.ts @@ -0,0 +1,100 @@ +import * as z from "zod/mini"; + +import { request, type RequestOptions } from "../../request"; + +export type WebsiteGeneratorConfig = { + token: string | undefined; + host: string; +}; + +const ProvisionInstantStartResponseSchema = z.object({ + repositoryId: z.string(), + repositoryUrl: z.url(), + redirectDocumentId: z.optional(z.string()), +}); +export type ProvisionInstantStartResponse = z.infer; + +const InstantStartExportNotPreparedSchema = z.object({ + status: z.literal("not-prepared"), +}); + +const InstantStartExportReadySchema = z.object({ + status: z.literal("ready"), + framework: z.literal("next"), + preparedAt: z.string(), + downloadUrl: z.url(), +}); +export type InstantStartExportReady = z.infer; + +const InstantStartExportStatusSchema = z.union([ + InstantStartExportNotPreparedSchema, + InstantStartExportReadySchema, +]); +export type InstantStartExportStatus = z.infer; + +export function provisionInstantStart( + config: WebsiteGeneratorConfig, +): Promise { + const url = new URL("instant-start", getWebsiteGeneratorServiceUrl(config.host)); + return websiteGeneratorRequest(url, config, { + method: "POST", + schema: ProvisionInstantStartResponseSchema, + unknownErrorMessage: "Failed to create an Instant Start repository", + }); +} + +export function getInstantStartExportStatus( + repositoryId: string, + config: WebsiteGeneratorConfig, +): Promise { + const url = getInstantStartExportUrl(repositoryId, config.host); + return websiteGeneratorRequest(url, config, { + schema: InstantStartExportStatusSchema, + notFoundMessage: `Repository not found: ${repositoryId}`, + unknownErrorMessage: "Failed to check the Instant Start export", + }); +} + +export function createInstantStartExport( + repositoryId: string, + config: WebsiteGeneratorConfig, +): Promise { + const url = getInstantStartExportUrl(repositoryId, config.host); + return websiteGeneratorRequest(url, config, { + method: "POST", + json: { framework: "next", replace: false }, + schema: InstantStartExportReadySchema, + notFoundMessage: `Repository not found: ${repositoryId}`, + unknownErrorMessage: "Failed to create the Instant Start export", + }); +} + +export async function getOrCreateInstantStartExport( + repositoryId: string, + config: WebsiteGeneratorConfig, +): Promise { + const status = await getInstantStartExportStatus(repositoryId, config); + return status.status === "ready" ? status : createInstantStartExport(repositoryId, config); +} + +function websiteGeneratorRequest( + url: URL, + config: WebsiteGeneratorConfig, + options: RequestOptions, +): Promise { + return request(url, { + headers: { Authorization: `Bearer ${config.token ?? ""}` }, + ...options, + }); +} + +function getInstantStartExportUrl(repositoryId: string, host: string): URL { + return new URL( + `instant-start/${encodeURIComponent(repositoryId)}/export`, + getWebsiteGeneratorServiceUrl(host), + ); +} + +function getWebsiteGeneratorServiceUrl(host: string): URL { + return new URL(`https://api.internal.${host}/website-generator/`); +} diff --git a/src/lib/zip.ts b/src/lib/zip.ts new file mode 100644 index 0000000..32f88f5 --- /dev/null +++ b/src/lib/zip.ts @@ -0,0 +1,78 @@ +import { unzip } from "fflate"; +import { mkdir, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, win32 } from "node:path"; + +export async function extractZip(data: Uint8Array, destination: string): Promise { + await assertEmptyOrMissingDirectory(destination); + + const parent = dirname(destination); + await mkdir(parent, { recursive: true }); + const temporaryDirectory = await mkdtemp(join(parent, `.${basename(destination)}-`)); + + try { + const files = await unzipArchive(data); + for (const [entryName, contents] of Object.entries(files)) { + const relativePath = normalizeEntryPath(entryName); + if (!relativePath) continue; + + const path = join(temporaryDirectory, relativePath); + if (entryName.endsWith("/") || entryName.endsWith("\\")) { + await mkdir(path, { recursive: true }); + } else { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, contents); + } + } + + await rm(destination, { recursive: true, force: true }); + await rename(temporaryDirectory, destination); + } catch (error) { + await rm(temporaryDirectory, { recursive: true, force: true }); + throw error; + } +} + +async function assertEmptyOrMissingDirectory(destination: string): Promise { + try { + const destinationStat = await stat(destination); + if (!destinationStat.isDirectory()) { + throw new Error(`Destination already exists and is not a directory: ${destination}`); + } + if ((await readdir(destination)).length > 0) { + throw new Error(`Destination directory is not empty: ${destination}`); + } + } catch (error) { + if (isMissingFileError(error)) return; + throw error; + } +} + +function unzipArchive(data: Uint8Array): Promise> { + return new Promise((resolve, reject) => { + unzip(data, (error, files) => { + if (error) { + reject(error); + } else { + resolve(files); + } + }); + }); +} + +function normalizeEntryPath(entryName: string): string { + const normalized = entryName.replaceAll("\\", "/"); + if (isAbsolute(normalized) || win32.isAbsolute(normalized)) { + throw new Error(`ZIP entry has an absolute path: ${entryName}`); + } + + const segments = normalized.split("/").filter((segment) => segment && segment !== "."); + if (segments.includes("..")) { + throw new Error(`ZIP entry escapes the destination: ${entryName}`); + } + + return segments.join("/"); +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} diff --git a/test/instant-start-command.test.ts b/test/instant-start-command.test.ts new file mode 100644 index 0000000..68961c8 --- /dev/null +++ b/test/instant-start-command.test.ts @@ -0,0 +1,108 @@ +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + extractZip: vi.fn(), + exists: vi.fn(), + getCredentials: vi.fn(), + getOrCreateInstantStartExport: vi.fn(), + getProfile: vi.fn(), + installDependencies: vi.fn(), + mkdir: vi.fn(), + provisionInstantStart: vi.fn(), + readURLFile: vi.fn(), + rm: vi.fn(), + setSimulatorUrl: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ mkdir: mocks.mkdir, rm: mocks.rm })); +vi.mock("../src/auth", () => ({ getCredentials: mocks.getCredentials })); +vi.mock("../src/lib/file", () => ({ + exists: mocks.exists, + readURLFile: mocks.readURLFile, +})); +vi.mock("../src/lib/packageJson", () => ({ + installDependencies: mocks.installDependencies, +})); +vi.mock("../src/lib/prismic/clients/core", () => ({ + setSimulatorUrl: mocks.setSimulatorUrl, +})); +vi.mock("../src/lib/prismic/clients/user", () => ({ + getProfile: mocks.getProfile, +})); +vi.mock("../src/lib/prismic/clients/website-generator", () => ({ + getOrCreateInstantStartExport: mocks.getOrCreateInstantStartExport, + provisionInstantStart: mocks.provisionInstantStart, +})); +vi.mock("../src/lib/zip", () => ({ extractZip: mocks.extractZip })); + +import { runInstantStart } from "../src/commands/instant-start"; + +describe.sequential("Instant Start command", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(console, "info").mockImplementation(() => {}); + mocks.getCredentials.mockResolvedValue({ + token: "test-token", + host: "prismic.io", + }); + mocks.getProfile.mockResolvedValue({ email: "test@example.com" }); + mocks.exists.mockResolvedValue(false); + mocks.getOrCreateInstantStartExport.mockResolvedValue({ + status: "ready", + framework: "next", + preparedAt: "2026-07-17T10:00:00.000Z", + downloadUrl: "https://cdn.example.com/my-repo/.exports/instant-start.zip", + }); + mocks.readURLFile.mockResolvedValue(new Blob(["zip"])); + }); + + it("sets up an existing repository without provisioning another one", async () => { + await runInstantStart({ repositoryToExport: "My-Repo" }); + + expect(mocks.provisionInstantStart).not.toHaveBeenCalled(); + expect(mocks.getOrCreateInstantStartExport).toHaveBeenCalledWith("my-repo", { + token: "test-token", + host: "prismic.io", + }); + const destination = resolve(process.cwd(), "my-repo"); + expect(mocks.extractZip).toHaveBeenCalledWith( + new Uint8Array(new TextEncoder().encode("zip")), + destination, + ); + expect(mocks.installDependencies).toHaveBeenCalledWith({ + start: pathToFileURL(destination), + }); + expect(mocks.setSimulatorUrl).toHaveBeenCalledWith("http://localhost:3000/slice-simulator", { + repo: "my-repo", + token: "test-token", + host: "prismic.io", + }); + }); + + it("prints a recovery command when local setup fails after provisioning", async () => { + mocks.provisionInstantStart.mockResolvedValue({ + repositoryId: "new-repo", + repositoryUrl: "https://new-repo.prismic.io", + }); + mocks.getOrCreateInstantStartExport.mockResolvedValue({ + status: "ready", + framework: "next", + preparedAt: "2026-07-17T10:00:00.000Z", + downloadUrl: "https://cdn.example.com/new-repo/.exports/instant-start.zip", + }); + mocks.installDependencies.mockRejectedValue(new Error("install failed")); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runInstantStart({})).rejects.toThrow("install failed"); + expect(consoleError).toHaveBeenCalledWith( + 'Repository "new-repo" was created. Retry setup with:\n' + + " npx prismic@latest instant-start --export new-repo", + ); + expect(mocks.rm).toHaveBeenCalledWith(resolve(process.cwd(), "new-repo"), { + recursive: true, + force: true, + }); + }); +}); diff --git a/test/instant-start.test.ts b/test/instant-start.test.ts new file mode 100644 index 0000000..1a7fa6d --- /dev/null +++ b/test/instant-start.test.ts @@ -0,0 +1,158 @@ +import { zipSync } from "fflate"; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it as unitTest, onTestFinished, vi } from "vitest"; + +import { + getOrCreateInstantStartExport, + provisionInstantStart, +} from "../src/lib/prismic/clients/website-generator"; +import { extractZip } from "../src/lib/zip"; +import { it } from "./it"; + +it("supports instant-start --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("instant-start", ["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic instant-start [options]"); + expect(stdout).toContain("--export string"); +}); + +describe.sequential("Instant Start API client", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + unitTest("provisions an Instant Start repository", async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + repositoryId: "my-repo", + repositoryUrl: "https://my-repo.prismic.io", + redirectDocumentId: "document-id", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const provisioned = await provisionInstantStart({ + token: "test-token", + host: "prismic.io", + }); + + expect(provisioned.repositoryId).toBe("my-repo"); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url.toString()).toBe("https://api.internal.prismic.io/website-generator/instant-start"); + expect(init?.method).toBe("POST"); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer test-token"); + }); + + unitTest("reuses an export that is already ready", async () => { + const readyExport = { + status: "ready", + framework: "next", + preparedAt: "2026-07-17T10:00:00.000Z", + downloadUrl: "https://cdn.example.com/my-repo/.exports/instant-start.zip", + }; + const fetchMock = vi.fn(async () => jsonResponse(readyExport)); + vi.stubGlobal("fetch", fetchMock); + + await expect( + getOrCreateInstantStartExport("my-repo", { + token: "test-token", + host: "prismic.io", + }), + ).resolves.toEqual(readyExport); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + unitTest("creates an export when none is prepared", async () => { + const readyExport = { + status: "ready", + framework: "next", + preparedAt: "2026-07-17T10:00:00.000Z", + downloadUrl: "https://cdn.example.com/my-repo/.exports/instant-start.zip", + }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ status: "not-prepared" })) + .mockResolvedValueOnce(jsonResponse(readyExport)); + vi.stubGlobal("fetch", fetchMock); + + await expect( + getOrCreateInstantStartExport("my-repo", { + token: "test-token", + host: "prismic.io", + }), + ).resolves.toEqual(readyExport); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, init] = fetchMock.mock.calls[1]; + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ framework: "next", replace: false })); + }); +}); + +describe("ZIP extraction", () => { + unitTest("extracts nested files into an empty destination", async () => { + const root = await makeTemporaryDirectory(); + const destination = join(root, "my-repo"); + await mkdir(destination); + + await extractZip( + zipSync({ + "package.json": new TextEncoder().encode('{"name":"starter"}'), + "src/app/page.tsx": new TextEncoder().encode("export default Page"), + }), + destination, + ); + + await expect(readFile(join(destination, "package.json"), "utf8")).resolves.toBe( + '{"name":"starter"}', + ); + await expect(readFile(join(destination, "src/app/page.tsx"), "utf8")).resolves.toBe( + "export default Page", + ); + }); + + unitTest("does not overwrite a non-empty destination", async () => { + const root = await makeTemporaryDirectory(); + const destination = join(root, "my-repo"); + await mkdir(destination); + await writeFile(join(destination, "keep.txt"), "keep"); + + await expect( + extractZip(zipSync({ "package.json": new TextEncoder().encode("{}") }), destination), + ).rejects.toThrow("Destination directory is not empty"); + await expect(readFile(join(destination, "keep.txt"), "utf8")).resolves.toBe("keep"); + }); + + unitTest("rejects path traversal without leaving partial output", async () => { + const root = await makeTemporaryDirectory(); + const destination = join(root, "my-repo"); + + await expect( + extractZip( + zipSync({ + "package.json": new TextEncoder().encode("{}"), + "../outside.txt": new TextEncoder().encode("outside"), + }), + destination, + ), + ).rejects.toThrow("ZIP entry escapes the destination"); + await expect(access(destination)).rejects.toThrow(); + await expect(access(join(root, "outside.txt"))).rejects.toThrow(); + }); +}); + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +async function makeTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "prismic-instant-start-")); + onTestFinished(() => rm(directory, { recursive: true, force: true })); + return directory; +} From 9a217beebb14e17565914fd515cfe8ab102b9833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:48:55 +0100 Subject: [PATCH 02/20] refactor: simplify instant-start implementation Inline the command handler, use synchronous ZIP extraction, and drop the mock-heavy orchestration test while keeping focused API and filesystem coverage. Co-authored-by: Cursor --- src/commands/instant-start.ts | 7 +- src/lib/packageJson.ts | 4 +- src/lib/zip.ts | 16 +---- test/instant-start-command.test.ts | 108 ----------------------------- 4 files changed, 5 insertions(+), 130 deletions(-) delete mode 100644 test/instant-start-command.test.ts diff --git a/src/commands/instant-start.ts b/src/commands/instant-start.ts index 70a9ac8..1381cb7 100644 --- a/src/commands/instant-start.ts +++ b/src/commands/instant-start.ts @@ -32,11 +32,6 @@ const config = { export default createCommand(config, async ({ values }) => { const { export: repositoryToExport } = values; - await runInstantStart({ repositoryToExport }); -}); - -export async function runInstantStart(options: { repositoryToExport?: string }): Promise { - const { repositoryToExport } = options; const { token, host } = await getCredentials(); console.info("Checking Prismic login..."); @@ -106,7 +101,7 @@ Run: } throw error; } -} +}); function assertRepositoryName(repositoryId: string): void { if (!/^[a-z0-9][a-z0-9-]*$/.test(repositoryId)) { diff --git a/src/lib/packageJson.ts b/src/lib/packageJson.ts index 69de377..88a7682 100644 --- a/src/lib/packageJson.ts +++ b/src/lib/packageJson.ts @@ -15,8 +15,8 @@ const PackageJsonSchema = z.object({ }); type PackageJson = z.infer; -export async function readPackageJson(config: { start?: URL } = {}): Promise { - const packageJsonPath = await findPackageJson(config); +export async function readPackageJson(): Promise { + const packageJsonPath = await findPackageJson(); const packageJson = await readJsonFile(packageJsonPath, { schema: PackageJsonSchema }); return packageJson; } diff --git a/src/lib/zip.ts b/src/lib/zip.ts index 32f88f5..0fbee08 100644 --- a/src/lib/zip.ts +++ b/src/lib/zip.ts @@ -1,4 +1,4 @@ -import { unzip } from "fflate"; +import { unzipSync } from "fflate"; import { mkdir, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, win32 } from "node:path"; @@ -10,7 +10,7 @@ export async function extractZip(data: Uint8Array, destination: string): Promise const temporaryDirectory = await mkdtemp(join(parent, `.${basename(destination)}-`)); try { - const files = await unzipArchive(data); + const files = unzipSync(data); for (const [entryName, contents] of Object.entries(files)) { const relativePath = normalizeEntryPath(entryName); if (!relativePath) continue; @@ -47,18 +47,6 @@ async function assertEmptyOrMissingDirectory(destination: string): Promise } } -function unzipArchive(data: Uint8Array): Promise> { - return new Promise((resolve, reject) => { - unzip(data, (error, files) => { - if (error) { - reject(error); - } else { - resolve(files); - } - }); - }); -} - function normalizeEntryPath(entryName: string): string { const normalized = entryName.replaceAll("\\", "/"); if (isAbsolute(normalized) || win32.isAbsolute(normalized)) { diff --git a/test/instant-start-command.test.ts b/test/instant-start-command.test.ts deleted file mode 100644 index 68961c8..0000000 --- a/test/instant-start-command.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - extractZip: vi.fn(), - exists: vi.fn(), - getCredentials: vi.fn(), - getOrCreateInstantStartExport: vi.fn(), - getProfile: vi.fn(), - installDependencies: vi.fn(), - mkdir: vi.fn(), - provisionInstantStart: vi.fn(), - readURLFile: vi.fn(), - rm: vi.fn(), - setSimulatorUrl: vi.fn(), -})); - -vi.mock("node:fs/promises", () => ({ mkdir: mocks.mkdir, rm: mocks.rm })); -vi.mock("../src/auth", () => ({ getCredentials: mocks.getCredentials })); -vi.mock("../src/lib/file", () => ({ - exists: mocks.exists, - readURLFile: mocks.readURLFile, -})); -vi.mock("../src/lib/packageJson", () => ({ - installDependencies: mocks.installDependencies, -})); -vi.mock("../src/lib/prismic/clients/core", () => ({ - setSimulatorUrl: mocks.setSimulatorUrl, -})); -vi.mock("../src/lib/prismic/clients/user", () => ({ - getProfile: mocks.getProfile, -})); -vi.mock("../src/lib/prismic/clients/website-generator", () => ({ - getOrCreateInstantStartExport: mocks.getOrCreateInstantStartExport, - provisionInstantStart: mocks.provisionInstantStart, -})); -vi.mock("../src/lib/zip", () => ({ extractZip: mocks.extractZip })); - -import { runInstantStart } from "../src/commands/instant-start"; - -describe.sequential("Instant Start command", () => { - beforeEach(() => { - vi.resetAllMocks(); - vi.spyOn(console, "info").mockImplementation(() => {}); - mocks.getCredentials.mockResolvedValue({ - token: "test-token", - host: "prismic.io", - }); - mocks.getProfile.mockResolvedValue({ email: "test@example.com" }); - mocks.exists.mockResolvedValue(false); - mocks.getOrCreateInstantStartExport.mockResolvedValue({ - status: "ready", - framework: "next", - preparedAt: "2026-07-17T10:00:00.000Z", - downloadUrl: "https://cdn.example.com/my-repo/.exports/instant-start.zip", - }); - mocks.readURLFile.mockResolvedValue(new Blob(["zip"])); - }); - - it("sets up an existing repository without provisioning another one", async () => { - await runInstantStart({ repositoryToExport: "My-Repo" }); - - expect(mocks.provisionInstantStart).not.toHaveBeenCalled(); - expect(mocks.getOrCreateInstantStartExport).toHaveBeenCalledWith("my-repo", { - token: "test-token", - host: "prismic.io", - }); - const destination = resolve(process.cwd(), "my-repo"); - expect(mocks.extractZip).toHaveBeenCalledWith( - new Uint8Array(new TextEncoder().encode("zip")), - destination, - ); - expect(mocks.installDependencies).toHaveBeenCalledWith({ - start: pathToFileURL(destination), - }); - expect(mocks.setSimulatorUrl).toHaveBeenCalledWith("http://localhost:3000/slice-simulator", { - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }); - }); - - it("prints a recovery command when local setup fails after provisioning", async () => { - mocks.provisionInstantStart.mockResolvedValue({ - repositoryId: "new-repo", - repositoryUrl: "https://new-repo.prismic.io", - }); - mocks.getOrCreateInstantStartExport.mockResolvedValue({ - status: "ready", - framework: "next", - preparedAt: "2026-07-17T10:00:00.000Z", - downloadUrl: "https://cdn.example.com/new-repo/.exports/instant-start.zip", - }); - mocks.installDependencies.mockRejectedValue(new Error("install failed")); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - - await expect(runInstantStart({})).rejects.toThrow("install failed"); - expect(consoleError).toHaveBeenCalledWith( - 'Repository "new-repo" was created. Retry setup with:\n' + - " npx prismic@latest instant-start --export new-repo", - ); - expect(mocks.rm).toHaveBeenCalledWith(resolve(process.cwd(), "new-repo"), { - recursive: true, - force: true, - }); - }); -}); From 70949f0cff8a515caa741ec32aebb8ebb90d810e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:25:44 +0100 Subject: [PATCH 03/20] chore: improve Instant Start completion message Co-authored-by: Cursor --- src/commands/instant-start.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/commands/instant-start.ts b/src/commands/instant-start.ts index 1381cb7..e44096f 100644 --- a/src/commands/instant-start.ts +++ b/src/commands/instant-start.ts @@ -78,14 +78,17 @@ export default createCommand(config, async ({ values }) => { }); console.info(` -Your project is ready. +Your project is ready 🎉 -Project: ${destination} -Page Builder: https://${repositoryId}.${host}/builder +Here's what you can do next: -Run: - cd ${repositoryId} +1. Start the development server: + cd ${destination} npm run dev + +2. Preview your pages live at https://${repositoryId}.${host}/builder + +Start building 🚀 `); } catch (error) { if (extractedProject) { From df3284645da6999b188335333b94a37d4313a306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:35:52 +0100 Subject: [PATCH 04/20] refactor: limit instant-start to exports Co-authored-by: Cursor --- src/commands/instant-start.ts | 28 +++------------- src/lib/prismic/clients/website-generator.ts | 18 ----------- test/instant-start.test.ts | 34 ++++---------------- 3 files changed, 11 insertions(+), 69 deletions(-) diff --git a/src/commands/instant-start.ts b/src/commands/instant-start.ts index e44096f..8c8c145 100644 --- a/src/commands/instant-start.ts +++ b/src/commands/instant-start.ts @@ -8,24 +8,19 @@ import { exists, readURLFile } from "../lib/file"; import { installDependencies } from "../lib/packageJson"; import { setSimulatorUrl } from "../lib/prismic/clients/core"; import { getProfile } from "../lib/prismic/clients/user"; -import { - getOrCreateInstantStartExport, - provisionInstantStart, -} from "../lib/prismic/clients/website-generator"; +import { getOrCreateInstantStartExport } from "../lib/prismic/clients/website-generator"; import { extractZip } from "../lib/zip"; const config = { name: "prismic instant-start", description: ` - Create a ready-to-run Prismic website from the Instant Start template. - - Use --export to download and set up an existing repository instead of - creating a new one. + Download and set up an existing Instant Start repository. `, options: { export: { type: "string", description: "Existing repository to export", + required: true, }, }, } satisfies CommandConfig; @@ -38,17 +33,7 @@ export default createCommand(config, async ({ values }) => { const profile = await getProfile({ token, host }); console.info(`Logged in as ${profile.email}`); - let repositoryId = repositoryToExport?.toLowerCase(); - let createdRepository = false; - - if (!repositoryId) { - console.info("Creating your Instant Start repository..."); - const provisioned = await provisionInstantStart({ token, host }); - repositoryId = provisioned.repositoryId; - createdRepository = true; - console.info(`Created repository: ${repositoryId}`); - } - + const repositoryId = repositoryToExport.toLowerCase(); assertRepositoryName(repositoryId); let extractedProject: { destination: string; destinationExisted: boolean } | undefined; @@ -97,11 +82,6 @@ Start building 🚀 await mkdir(extractedProject.destination); } } - if (createdRepository) { - console.error( - `Repository "${repositoryId}" was created. Retry setup with:\n npx prismic@latest instant-start --export ${repositoryId}`, - ); - } throw error; } }); diff --git a/src/lib/prismic/clients/website-generator.ts b/src/lib/prismic/clients/website-generator.ts index 03e5625..127becb 100644 --- a/src/lib/prismic/clients/website-generator.ts +++ b/src/lib/prismic/clients/website-generator.ts @@ -7,13 +7,6 @@ export type WebsiteGeneratorConfig = { host: string; }; -const ProvisionInstantStartResponseSchema = z.object({ - repositoryId: z.string(), - repositoryUrl: z.url(), - redirectDocumentId: z.optional(z.string()), -}); -export type ProvisionInstantStartResponse = z.infer; - const InstantStartExportNotPreparedSchema = z.object({ status: z.literal("not-prepared"), }); @@ -32,17 +25,6 @@ const InstantStartExportStatusSchema = z.union([ ]); export type InstantStartExportStatus = z.infer; -export function provisionInstantStart( - config: WebsiteGeneratorConfig, -): Promise { - const url = new URL("instant-start", getWebsiteGeneratorServiceUrl(config.host)); - return websiteGeneratorRequest(url, config, { - method: "POST", - schema: ProvisionInstantStartResponseSchema, - unknownErrorMessage: "Failed to create an Instant Start repository", - }); -} - export function getInstantStartExportStatus( repositoryId: string, config: WebsiteGeneratorConfig, diff --git a/test/instant-start.test.ts b/test/instant-start.test.ts index 1a7fa6d..16c11d1 100644 --- a/test/instant-start.test.ts +++ b/test/instant-start.test.ts @@ -4,10 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it as unitTest, onTestFinished, vi } from "vitest"; -import { - getOrCreateInstantStartExport, - provisionInstantStart, -} from "../src/lib/prismic/clients/website-generator"; +import { getOrCreateInstantStartExport } from "../src/lib/prismic/clients/website-generator"; import { extractZip } from "../src/lib/zip"; import { it } from "./it"; @@ -18,34 +15,17 @@ it("supports instant-start --help", async ({ expect, prismic }) => { expect(stdout).toContain("--export string"); }); +it("requires --export", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("instant-start"); + expect(exitCode).toBe(1); + expect(stderr).toContain("Missing required option: --export"); +}); + describe.sequential("Instant Start API client", () => { afterEach(() => { vi.unstubAllGlobals(); }); - unitTest("provisions an Instant Start repository", async () => { - const fetchMock = vi.fn(async () => - jsonResponse({ - repositoryId: "my-repo", - repositoryUrl: "https://my-repo.prismic.io", - redirectDocumentId: "document-id", - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const provisioned = await provisionInstantStart({ - token: "test-token", - host: "prismic.io", - }); - - expect(provisioned.repositoryId).toBe("my-repo"); - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = fetchMock.mock.calls[0]; - expect(url.toString()).toBe("https://api.internal.prismic.io/website-generator/instant-start"); - expect(init?.method).toBe("POST"); - expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer test-token"); - }); - unitTest("reuses an export that is already ready", async () => { const readyExport = { status: "ready", From 60352fa88ac36d9ed7820d9c0e2708f74031dd3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:47:10 +0100 Subject: [PATCH 05/20] feat: route instant start through init Co-authored-by: Cursor --- src/commands/index.ts | 5 - src/commands/init-auth.ts | 49 ++++ .../{instant-start.ts => init-instant.ts} | 46 ++-- src/commands/init-project.ts | 196 +++++++++++++++ src/commands/init.ts | 232 +----------------- src/lib/command.ts | 6 + test/init.test.ts | 2 + test/instant-start.test.ts | 74 +++++- 8 files changed, 352 insertions(+), 258 deletions(-) create mode 100644 src/commands/init-auth.ts rename src/commands/{instant-start.ts => init-instant.ts} (73%) create mode 100644 src/commands/init-project.ts diff --git a/src/commands/index.ts b/src/commands/index.ts index 1d6aed9..cccbbdf 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -3,7 +3,6 @@ import docs from "./docs"; import field from "./field"; import gen from "./gen"; import init from "./init"; -import instantStart from "./instant-start"; import locale from "./locale"; import login from "./login"; import logout from "./logout"; @@ -33,10 +32,6 @@ export default createCommandRouter({ handler: init, description: "Initialize a Prismic project", }, - "instant-start": { - handler: instantStart, - description: "Create and set up an Instant Start project", - }, docs: { handler: docs, description: "Browse Prismic documentation", diff --git a/src/commands/init-auth.ts b/src/commands/init-auth.ts new file mode 100644 index 0000000..1a666c7 --- /dev/null +++ b/src/commands/init-auth.ts @@ -0,0 +1,49 @@ +import type { Profile } from "../lib/prismic/clients/user"; + +import { createLoginSession, getCredentials } from "../auth"; +import { env } from "../env"; +import { openBrowser } from "../lib/browser"; +import { CommandError } from "../lib/command"; +import { getProfile } from "../lib/prismic/clients/user"; +import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; + +export async function authenticateInit( + noBrowser: boolean | undefined, +): Promise<{ host: string; token: string | undefined; profile: Profile }> { + const { host, token: initialToken } = await getCredentials(); + let token = initialToken; + let profile: Profile; + + try { + profile = await getProfile({ token, host }); + } catch (error) { + if (!(error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError)) { + throw error; + } + if (env.PRISMIC_TOKEN) { + throw new CommandError( + "PRISMIC_TOKEN is invalid or expired. Unset it to log in with a browser, or replace it with a valid token.", + ); + } + + console.info("Not logged in. Starting login..."); + const { email } = await createLoginSession({ + onReady: (url) => { + if (noBrowser) { + console.info(`Open this URL to log in: ${url}`); + } else { + console.info("Opening browser to complete login..."); + console.info(`If the browser doesn't open, visit: ${url}`); + openBrowser(url); + } + }, + }); + console.info(`Logged in as ${email}`); + + const loggedIn = await getCredentials(); + token = loggedIn.token; + profile = await getProfile({ token, host }); + } + + return { host, token, profile }; +} diff --git a/src/commands/instant-start.ts b/src/commands/init-instant.ts similarity index 73% rename from src/commands/instant-start.ts rename to src/commands/init-instant.ts index 8c8c145..97e08e8 100644 --- a/src/commands/instant-start.ts +++ b/src/commands/init-instant.ts @@ -2,48 +2,49 @@ import { mkdir, rm } from "node:fs/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; -import { getCredentials } from "../auth"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { exists, readURLFile } from "../lib/file"; import { installDependencies } from "../lib/packageJson"; import { setSimulatorUrl } from "../lib/prismic/clients/core"; -import { getProfile } from "../lib/prismic/clients/user"; import { getOrCreateInstantStartExport } from "../lib/prismic/clients/website-generator"; import { extractZip } from "../lib/zip"; +import { authenticateInit } from "./init-auth"; const config = { - name: "prismic instant-start", - description: ` - Download and set up an existing Instant Start repository. - `, + name: "prismic init instant", + description: "Download and set up an existing generated Prismic project.", options: { - export: { + repo: { type: "string", - description: "Existing repository to export", + short: "r", + description: "Repository name", required: true, }, + "no-browser": { + type: "boolean", + description: "Skip opening the browser automatically during login", + }, }, } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { export: repositoryToExport } = values; - const { token, host } = await getCredentials(); - - console.info("Checking Prismic login..."); - const profile = await getProfile({ token, host }); - console.info(`Logged in as ${profile.email}`); + const { repo, "no-browser": noBrowser } = values; + const { token, host } = await authenticateInit(noBrowser); + await setupInstantProject(repo, { token, host }); +}); - const repositoryId = repositoryToExport.toLowerCase(); +async function setupInstantProject( + repository: string, + config: { token: string | undefined; host: string }, +): Promise { + const repositoryId = repository.toLowerCase(); assertRepositoryName(repositoryId); let extractedProject: { destination: string; destinationExisted: boolean } | undefined; try { console.info("Preparing the project export..."); - const readyExport = await getOrCreateInstantStartExport(repositoryId, { - token, - host, - }); + const readyExport = await getOrCreateInstantStartExport(repositoryId, config); console.info("Downloading the project..."); const archive = await readURLFile(new URL(readyExport.downloadUrl)); @@ -58,8 +59,7 @@ export default createCommand(config, async ({ values }) => { console.info("Setting local simulator URL..."); await setSimulatorUrl("http://localhost:3000/slice-simulator", { repo: repositoryId, - token, - host, + ...config, }); console.info(` @@ -71,7 +71,7 @@ Here's what you can do next: cd ${destination} npm run dev -2. Preview your pages live at https://${repositoryId}.${host}/builder +2. Preview your pages live at https://${repositoryId}.${config.host}/builder Start building 🚀 `); @@ -84,7 +84,7 @@ Start building 🚀 } throw error; } -}); +} function assertRepositoryName(repositoryId: string): void { if (!/^[a-z0-9][a-z0-9-]*$/.test(repositoryId)) { diff --git a/src/commands/init-project.ts b/src/commands/init-project.ts new file mode 100644 index 0000000..af428ec --- /dev/null +++ b/src/commands/init-project.ts @@ -0,0 +1,196 @@ +import { getAdapter } from "../adapters"; +import { DEFAULT_PRISMIC_HOST } from "../env"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { diffArrays } from "../lib/diff"; +import { installDependencies, readPackageJson, removeDependencies } from "../lib/packageJson"; +import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; +import { + createConfig, + deleteLegacySliceMachineConfig, + InvalidLegacySliceMachineConfigError, + MissingPrismicConfigError, + readConfig, + readLegacySliceMachineConfig, + UnknownProjectRootError, +} from "../project"; +import { checkIsTypeBuilderEnabled, TypeBuilderRequiredError } from "../project"; +import { authenticateInit } from "./init-auth"; +import { createRepo } from "./repo-create"; + +const config = { + name: "prismic init", + description: ` + Initialize a new Prismic project by creating a repository and + prismic.config.json file. Detects the project framework, installs + dependencies, and pulls models from Prismic. + + Use --repo to connect to an existing repository instead. If a + slicemachine.config.json exists, its repository and settings will be + migrated. + `, + sections: { + SUBCOMMANDS: ` + instant Download and set up a generated project + `, + }, + options: { + repo: { type: "string", short: "r", description: "Repository name" }, + lang: { + type: "string", + short: "l", + description: "Master locale for a new repository (default: en-us)", + }, + "no-browser": { + type: "boolean", + description: "Skip opening the browser automatically during login", + }, + "no-setup": { + type: "boolean", + description: "Skip framework scaffolding (dependencies and framework files)", + }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { repo: explicitRepo, lang, "no-browser": noBrowser, "no-setup": noSetup } = values; + + // Check for existing prismic.config.json + try { + await readConfig(); + throw new CommandError( + "A prismic.config.json file exists. This project is already initialized.", + ); + } catch (error) { + if (error instanceof MissingPrismicConfigError) { + // No config found — proceed with initialization. + } else { + throw error; + } + } + + // Load legacy slicemachine.config.json + let legacySliceMachineConfig; + try { + legacySliceMachineConfig = await readLegacySliceMachineConfig(); + } catch (error) { + if (error instanceof InvalidLegacySliceMachineConfigError) { + console.warn("Could not read slicemachine.config.json, ignoring."); + } + } + + const { host, token, profile } = await authenticateInit(noBrowser); + + let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase(); + if (repo) { + const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); + if (!hasRepoAccess) { + throw new CommandError( + `Repository "${repo}" not found in your account. Check the name or request access to the repository.`, + ); + } + + const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host }); + if (!isTypeBuilderEnabled) { + throw new TypeBuilderRequiredError(repo, host); + } + } + + const adapter = await getAdapter(); + + if (!repo) { + repo = await createRepo({ lang, token, host }); + console.info(`Created repository: ${repo}`); + } + + // Create prismic.config.json + try { + const documentAPIEndpoint = + host !== DEFAULT_PRISMIC_HOST ? `https://${repo}.cdn.${host}/api/v2/` : undefined; + await createConfig({ + repositoryName: repo, + documentAPIEndpoint, + libraries: legacySliceMachineConfig?.libraries, + routes: [], + }); + } catch (error) { + if (error instanceof UnknownProjectRootError) { + throw new CommandError( + "Could not find a package.json file. Run this command from a project directory.", + ); + } + throw new CommandError("Failed to create prismic.config.json."); + } + + if (legacySliceMachineConfig) { + try { + await deleteLegacySliceMachineConfig(); + } catch {} + // Slice Machine is replaced by the Type Builder and CLI, so its packages + // are no longer needed after migrating. + const { dependencies, devDependencies, peerDependencies } = await readPackageJson(); + const sliceMachinePackages = Object.keys({ + ...dependencies, + ...devDependencies, + ...peerDependencies, + }).filter((name) => name === "slice-machine-ui" || name.startsWith("@slicemachine/adapter-")); + if (sliceMachinePackages.length > 0) { + await removeDependencies(sliceMachinePackages); + } + console.info("Migrated slicemachine.config.json to prismic.config.json"); + } + + // Install dependencies and create framework files + await adapter.initProject({ setup: !noSetup }); + + // Run package manager install + if (!noSetup) { + try { + console.info("Installing dependencies..."); + await installDependencies(); + } catch { + console.warn( + "Could not install dependencies automatically. Please install them manually (i.e. `npm install`).", + ); + } + } + + // Sync models from remote and generate types + const [remoteCustomTypes, remoteSlices, localCustomTypes, localSlices] = await Promise.all([ + getCustomTypes({ repo, token, host }), + getSlices({ repo, token, host }), + adapter.getCustomTypes(), + adapter.getSlices(), + ]); + const localCustomTypeModels = localCustomTypes.map((c) => c.model); + const localSliceModels = localSlices.map((s) => s.model); + + const sliceOps = diffArrays(remoteSlices, localSliceModels, { getKey: (m) => m.id }); + for (const slice of sliceOps.update) { + await adapter.updateSlice(slice); + } + for (const slice of sliceOps.delete) { + await adapter.deleteSlice(slice.id); + } + for (const slice of sliceOps.insert) { + await adapter.createSlice(slice); + } + + const customTypeOps = diffArrays(remoteCustomTypes, localCustomTypeModels, { + getKey: (m) => m.id, + }); + for (const customType of customTypeOps.update) { + await adapter.updateCustomType(customType); + } + for (const customType of customTypeOps.delete) { + await adapter.deleteCustomType(customType.id); + } + for (const customType of customTypeOps.insert) { + await adapter.createCustomType(customType); + } + + await adapter.generateTypes(); + + console.info(`\nInitialized Prismic for repository "${repo}".`); + console.info("Run `prismic type create ` to create a content type."); + console.info("Run `prismic pull` to pull models from Prismic."); +}); diff --git a/src/commands/init.ts b/src/commands/init.ts index a1b27c1..4f96f64 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,227 +1,15 @@ -import type { Profile } from "../lib/prismic/clients/user"; +import { createCommandRouter } from "../lib/command"; +import instant from "./init-instant"; +import project from "./init-project"; -import { getAdapter } from "../adapters"; -import { createLoginSession, getCredentials } from "../auth"; -import { DEFAULT_PRISMIC_HOST, env } from "../env"; -import { openBrowser } from "../lib/browser"; -import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { diffArrays } from "../lib/diff"; -import { installDependencies, readPackageJson, removeDependencies } from "../lib/packageJson"; -import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; -import { getProfile } from "../lib/prismic/clients/user"; -import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; -import { - createConfig, - deleteLegacySliceMachineConfig, - InvalidLegacySliceMachineConfigError, - MissingPrismicConfigError, - readConfig, - readLegacySliceMachineConfig, - UnknownProjectRootError, -} from "../project"; -import { checkIsTypeBuilderEnabled, TypeBuilderRequiredError } from "../project"; -import { createRepo } from "./repo-create"; - -const config = { +export default createCommandRouter({ name: "prismic init", - description: ` - Initialize a new Prismic project by creating a repository and - prismic.config.json file. Detects the project framework, installs - dependencies, and pulls models from Prismic. - - Use --repo to connect to an existing repository instead. If a - slicemachine.config.json exists, its repository and settings will be - migrated. - `, - options: { - repo: { type: "string", short: "r", description: "Repository name" }, - lang: { - type: "string", - short: "l", - description: "Master locale for a new repository (default: en-us)", - }, - "no-browser": { - type: "boolean", - description: "Skip opening the browser automatically during login", - }, - "no-setup": { - type: "boolean", - description: "Skip framework scaffolding (dependencies and framework files)", + description: "Initialize a Prismic project.", + defaultHandler: project, + commands: { + instant: { + handler: instant, + description: "Download and set up a generated project", }, }, -} satisfies CommandConfig; - -export default createCommand(config, async ({ values }) => { - const { repo: explicitRepo, lang, "no-browser": noBrowser, "no-setup": noSetup } = values; - - // Check for existing prismic.config.json - try { - await readConfig(); - throw new CommandError( - "A prismic.config.json file exists. This project is already initialized.", - ); - } catch (error) { - if (error instanceof MissingPrismicConfigError) { - // No config found — proceed with initialization. - } else { - throw error; - } - } - - // Load legacy slicemachine.config.json - let legacySliceMachineConfig; - try { - legacySliceMachineConfig = await readLegacySliceMachineConfig(); - } catch (error) { - if (error instanceof InvalidLegacySliceMachineConfigError) { - console.warn("Could not read slicemachine.config.json, ignoring."); - } - } - - const { host, token: initialToken } = await getCredentials(); - let token = initialToken; - let profile: Profile; - try { - profile = await getProfile({ token, host }); - } catch (error) { - if (error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError) { - if (env.PRISMIC_TOKEN) { - throw new CommandError( - "PRISMIC_TOKEN is invalid or expired. Unset it to log in with a browser, or replace it with a valid token.", - ); - } - console.info("Not logged in. Starting login..."); - const { email } = await createLoginSession({ - onReady: (url) => { - if (noBrowser) { - console.info(`Open this URL to log in: ${url}`); - } else { - console.info("Opening browser to complete login..."); - console.info(`If the browser doesn't open, visit: ${url}`); - openBrowser(url); - } - }, - }); - console.info(`Logged in as ${email}`); - const loggedIn = await getCredentials(); - token = loggedIn.token; - profile = await getProfile({ token, host }); - } else { - throw error; - } - } - - let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase(); - if (repo) { - const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); - if (!hasRepoAccess) { - throw new CommandError( - `Repository "${repo}" not found in your account. Check the name or request access to the repository.`, - ); - } - - const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host }); - if (!isTypeBuilderEnabled) { - throw new TypeBuilderRequiredError(repo, host); - } - } - - const adapter = await getAdapter(); - - if (!repo) { - repo = await createRepo({ lang, token, host }); - console.info(`Created repository: ${repo}`); - } - - // Create prismic.config.json - try { - const documentAPIEndpoint = - host !== DEFAULT_PRISMIC_HOST ? `https://${repo}.cdn.${host}/api/v2/` : undefined; - await createConfig({ - repositoryName: repo, - documentAPIEndpoint, - libraries: legacySliceMachineConfig?.libraries, - routes: [], - }); - } catch (error) { - if (error instanceof UnknownProjectRootError) { - throw new CommandError( - "Could not find a package.json file. Run this command from a project directory.", - ); - } - throw new CommandError("Failed to create prismic.config.json."); - } - - if (legacySliceMachineConfig) { - try { - await deleteLegacySliceMachineConfig(); - } catch {} - // Slice Machine is replaced by the Type Builder and CLI, so its packages - // are no longer needed after migrating. - const { dependencies, devDependencies, peerDependencies } = await readPackageJson(); - const sliceMachinePackages = Object.keys({ - ...dependencies, - ...devDependencies, - ...peerDependencies, - }).filter((name) => name === "slice-machine-ui" || name.startsWith("@slicemachine/adapter-")); - if (sliceMachinePackages.length > 0) { - await removeDependencies(sliceMachinePackages); - } - console.info("Migrated slicemachine.config.json to prismic.config.json"); - } - - // Install dependencies and create framework files - await adapter.initProject({ setup: !noSetup }); - - // Run package manager install - if (!noSetup) { - try { - console.info("Installing dependencies..."); - await installDependencies(); - } catch { - console.warn( - "Could not install dependencies automatically. Please install them manually (i.e. `npm install`).", - ); - } - } - - // Sync models from remote and generate types - const [remoteCustomTypes, remoteSlices, localCustomTypes, localSlices] = await Promise.all([ - getCustomTypes({ repo, token, host }), - getSlices({ repo, token, host }), - adapter.getCustomTypes(), - adapter.getSlices(), - ]); - const localCustomTypeModels = localCustomTypes.map((c) => c.model); - const localSliceModels = localSlices.map((s) => s.model); - - const sliceOps = diffArrays(remoteSlices, localSliceModels, { getKey: (m) => m.id }); - for (const slice of sliceOps.update) { - await adapter.updateSlice(slice); - } - for (const slice of sliceOps.delete) { - await adapter.deleteSlice(slice.id); - } - for (const slice of sliceOps.insert) { - await adapter.createSlice(slice); - } - - const customTypeOps = diffArrays(remoteCustomTypes, localCustomTypeModels, { - getKey: (m) => m.id, - }); - for (const customType of customTypeOps.update) { - await adapter.updateCustomType(customType); - } - for (const customType of customTypeOps.delete) { - await adapter.deleteCustomType(customType.id); - } - for (const customType of customTypeOps.insert) { - await adapter.createCustomType(customType); - } - - await adapter.generateTypes(); - - console.info(`\nInitialized Prismic for repository "${repo}".`); - console.info("Run `prismic type create ` to create a content type."); - console.info("Run `prismic pull` to pull models from Prismic."); }); diff --git a/src/lib/command.ts b/src/lib/command.ts index 3cd2d47..77dc733 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -167,6 +167,7 @@ type CreateCommandRouterConfig = { name: string; description: string; sections?: Record; + defaultHandler?: () => Promise; commands: Record; }; type RouterCommand = { handler: () => Promise; description: string }; @@ -177,6 +178,11 @@ export function createCommandRouter(config: CreateCommandRouterConfig): () => Pr return async function () { const args = process.argv.slice(1 + depth); + if (config.defaultHandler && (args.length === 0 || args[0].startsWith("-"))) { + await config.defaultHandler(); + return; + } + const { positionals: [subcommand], } = parseArgs({ diff --git a/test/init.test.ts b/test/init.test.ts index 01acf59..cece165 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -15,6 +15,8 @@ it("supports --help", async ({ expect, prismic }) => { const { stdout, exitCode } = await prismic("init", ["--help"]); expect(exitCode).toBe(0); expect(stdout).toContain("prismic init [options]"); + expect(stdout).toContain("SUBCOMMANDS"); + expect(stdout).toContain("instant"); }); it("fails if prismic.config.json already exists", async ({ expect, prismic }) => { diff --git a/test/instant-start.test.ts b/test/instant-start.test.ts index 16c11d1..2cc6148 100644 --- a/test/instant-start.test.ts +++ b/test/instant-start.test.ts @@ -6,19 +6,77 @@ import { afterEach, describe, expect, it as unitTest, onTestFinished, vi } from import { getOrCreateInstantStartExport } from "../src/lib/prismic/clients/website-generator"; import { extractZip } from "../src/lib/zip"; -import { it } from "./it"; +import { captureOutput, it } from "./it"; -it("supports instant-start --help", async ({ expect, prismic }) => { - const { stdout, exitCode } = await prismic("instant-start", ["--help"]); +it("supports init instant --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("init", ["instant", "--help"]); expect(exitCode).toBe(0); - expect(stdout).toContain("prismic instant-start [options]"); - expect(stdout).toContain("--export string"); + expect(stdout).toContain("prismic init instant [options]"); + expect(stdout).toContain("--repo string"); + expect(stdout).toContain("(required)"); }); -it("requires --export", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("instant-start"); +it("requires --repo in instant mode", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("init", ["instant"]); expect(exitCode).toBe(1); - expect(stderr).toContain("Missing required option: --export"); + expect(stderr).toContain("Missing required option: --repo"); +}); + +it("rejects an unknown init subcommand", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("init", ["unknown"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Unknown command: unknown"); +}); + +it("rejects extra instant arguments", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("init", ["instant", "extra", "--repo", "my-repo"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("extra"); +}); + +it("rejects --lang in instant mode", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("init", [ + "instant", + "--repo", + "my-repo", + "--lang", + "en-us", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("--lang"); +}); + +it("rejects --no-setup in instant mode", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("init", [ + "instant", + "--repo", + "my-repo", + "--no-setup", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("--no-setup"); +}); + +it("dispatches instant mode before checking the current project", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("init", ["instant", "--repo", "invalid_repository"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Invalid repository name"); + expect(stderr).not.toContain("already initialized"); +}); + +it("uses the init login flow", async ({ expect, logout, prismic, repo }) => { + await logout(); + const proc = prismic("init", ["instant", "--repo", repo, "--no-browser"]); + const output = captureOutput(proc); + + await expect.poll(output, { timeout: 15_000 }).toMatch(/port=(\d+)/); + proc.kill(); +}); + +it("does not list instant-start as a top-level command", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("", ["--help"]); + expect(exitCode).toBe(0); + expect(stdout).not.toContain("instant-start"); }); describe.sequential("Instant Start API client", () => { From fac31ec156ec3fbefecb34025295502860ab6f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:49:42 +0100 Subject: [PATCH 06/20] chore: refine instant init description Co-authored-by: Cursor --- src/commands/init-instant.ts | 2 +- src/commands/init-project.ts | 2 +- src/commands/init.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/init-instant.ts b/src/commands/init-instant.ts index 97e08e8..44200b7 100644 --- a/src/commands/init-instant.ts +++ b/src/commands/init-instant.ts @@ -12,7 +12,7 @@ import { authenticateInit } from "./init-auth"; const config = { name: "prismic init instant", - description: "Download and set up an existing generated Prismic project.", + description: "Instantly start a ready-to-run Prismic project.", options: { repo: { type: "string", diff --git a/src/commands/init-project.ts b/src/commands/init-project.ts index af428ec..f0335e4 100644 --- a/src/commands/init-project.ts +++ b/src/commands/init-project.ts @@ -30,7 +30,7 @@ const config = { `, sections: { SUBCOMMANDS: ` - instant Download and set up a generated project + instant Instantly start a ready-to-run Prismic project `, }, options: { diff --git a/src/commands/init.ts b/src/commands/init.ts index 4f96f64..00783e2 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -9,7 +9,7 @@ export default createCommandRouter({ commands: { instant: { handler: instant, - description: "Download and set up a generated project", + description: "Instantly start a ready-to-run Prismic project", }, }, }); From 9eb8dbf6c11cd3e15993cbd2e9561c1d06fb414f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:24:54 +0100 Subject: [PATCH 07/20] feat: clean up instant start previews Co-authored-by: Cursor --- src/commands/init-instant.ts | 5 +-- src/lib/prismic/clients/core.ts | 13 +++++++ src/lib/prismic/clients/website-generator.ts | 1 + test/instant-start.test.ts | 37 ++++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/commands/init-instant.ts b/src/commands/init-instant.ts index 44200b7..c13b60f 100644 --- a/src/commands/init-instant.ts +++ b/src/commands/init-instant.ts @@ -5,7 +5,7 @@ import { pathToFileURL } from "node:url"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { exists, readURLFile } from "../lib/file"; import { installDependencies } from "../lib/packageJson"; -import { setSimulatorUrl } from "../lib/prismic/clients/core"; +import { removePreviewsByURL, setSimulatorUrl } from "../lib/prismic/clients/core"; import { getOrCreateInstantStartExport } from "../lib/prismic/clients/website-generator"; import { extractZip } from "../lib/zip"; import { authenticateInit } from "./init-auth"; @@ -56,7 +56,8 @@ async function setupInstantProject( console.info("Installing dependencies..."); await installDependencies({ start: pathToFileURL(destination) }); - console.info("Setting local simulator URL..."); + console.info("Configuring local previews..."); + await removePreviewsByURL(readyExport.previewUrls, { repo: repositoryId, ...config }); await setSimulatorUrl("http://localhost:3000/slice-simulator", { repo: repositoryId, ...config, diff --git a/src/lib/prismic/clients/core.ts b/src/lib/prismic/clients/core.ts index f9a73d4..bbc4659 100644 --- a/src/lib/prismic/clients/core.ts +++ b/src/lib/prismic/clients/core.ts @@ -62,6 +62,19 @@ export async function removePreview(id: string, config: CoreConfig): Promise, + config: CoreConfig, +): Promise { + const previews = await getPreviews(config); + const urlSet = new Set(urls); + await Promise.all( + previews + .filter((preview) => urlSet.has(preview.url)) + .map((preview) => removePreview(preview.id, config)), + ); +} + const EnvironmentSchema = z.object({ kind: z.enum(["prod", "stage", "dev"]), name: z.string(), diff --git a/src/lib/prismic/clients/website-generator.ts b/src/lib/prismic/clients/website-generator.ts index 127becb..e9b8a2b 100644 --- a/src/lib/prismic/clients/website-generator.ts +++ b/src/lib/prismic/clients/website-generator.ts @@ -16,6 +16,7 @@ const InstantStartExportReadySchema = z.object({ framework: z.literal("next"), preparedAt: z.string(), downloadUrl: z.url(), + previewUrls: z.array(z.url()), }); export type InstantStartExportReady = z.infer; diff --git a/test/instant-start.test.ts b/test/instant-start.test.ts index 2cc6148..a0609bb 100644 --- a/test/instant-start.test.ts +++ b/test/instant-start.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it as unitTest, onTestFinished, vi } from "vitest"; +import { removePreviewsByURL } from "../src/lib/prismic/clients/core"; import { getOrCreateInstantStartExport } from "../src/lib/prismic/clients/website-generator"; import { extractZip } from "../src/lib/zip"; import { captureOutput, it } from "./it"; @@ -90,6 +91,7 @@ describe.sequential("Instant Start API client", () => { framework: "next", preparedAt: "2026-07-17T10:00:00.000Z", downloadUrl: "https://cdn.example.com/my-repo/.exports/instant-start.zip", + previewUrls: ["https://starter.example.com/api/preview"], }; const fetchMock = vi.fn(async () => jsonResponse(readyExport)); vi.stubGlobal("fetch", fetchMock); @@ -109,6 +111,7 @@ describe.sequential("Instant Start API client", () => { framework: "next", preparedAt: "2026-07-17T10:00:00.000Z", downloadUrl: "https://cdn.example.com/my-repo/.exports/instant-start.zip", + previewUrls: ["https://starter.example.com/api/preview"], }; const fetchMock = vi .fn() @@ -128,6 +131,40 @@ describe.sequential("Instant Start API client", () => { expect(init?.method).toBe("POST"); expect(init?.body).toBe(JSON.stringify({ framework: "next", replace: false })); }); + + unitTest("removes only previews declared by the export", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + results: [ + { + id: "starter-preview", + label: "Production", + url: "https://starter.example.com/api/preview", + }, + { + id: "custom-preview", + label: "Custom", + url: "https://custom.example.com/api/preview", + }, + ], + }), + ) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await removePreviewsByURL(["https://starter.example.com/api/preview"], { + repo: "my-repo", + token: "test-token", + host: "prismic.io", + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [url, init] = fetchMock.mock.calls[1]; + expect(url.toString()).toContain("/previews/delete/starter-preview"); + expect(init?.method).toBe("POST"); + }); }); describe("ZIP extraction", () => { From 43b72a6c2d9b542e38044be5dccc99dd4b293790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:19:05 +0100 Subject: [PATCH 08/20] refactor(instant-start): download public starter Co-authored-by: Cursor --- src/commands/init-instant.ts | 35 +++-- src/lib/instant-start.ts | 51 +++++++ src/lib/prismic/clients/website-generator.ts | 83 ----------- src/lib/zip.ts | 44 +++++- test/instant-start.test.ts | 149 ++++++++++++++----- 5 files changed, 224 insertions(+), 138 deletions(-) create mode 100644 src/lib/instant-start.ts delete mode 100644 src/lib/prismic/clients/website-generator.ts diff --git a/src/commands/init-instant.ts b/src/commands/init-instant.ts index c13b60f..454314e 100644 --- a/src/commands/init-instant.ts +++ b/src/commands/init-instant.ts @@ -2,11 +2,18 @@ import { mkdir, rm } from "node:fs/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import type { Profile } from "../lib/prismic/clients/user"; + import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { exists, readURLFile } from "../lib/file"; +import { + assertInstantStartRepositoryAccess, + instantStartArchiveURL, + instantStartHostedPreviewURL, + patchInstantStartConfig, +} from "../lib/instant-start"; import { installDependencies } from "../lib/packageJson"; import { removePreviewsByURL, setSimulatorUrl } from "../lib/prismic/clients/core"; -import { getOrCreateInstantStartExport } from "../lib/prismic/clients/website-generator"; import { extractZip } from "../lib/zip"; import { authenticateInit } from "./init-auth"; @@ -29,38 +36,44 @@ const config = { export default createCommand(config, async ({ values }) => { const { repo, "no-browser": noBrowser } = values; - const { token, host } = await authenticateInit(noBrowser); - await setupInstantProject(repo, { token, host }); + const { token, host, profile } = await authenticateInit(noBrowser); + await setupInstantProject(repo, { token, host, profile }); }); async function setupInstantProject( repository: string, - config: { token: string | undefined; host: string }, + config: { token: string | undefined; host: string; profile: Profile }, ): Promise { const repositoryId = repository.toLowerCase(); assertRepositoryName(repositoryId); + assertInstantStartRepositoryAccess(repositoryId, config.profile); let extractedProject: { destination: string; destinationExisted: boolean } | undefined; try { - console.info("Preparing the project export..."); - const readyExport = await getOrCreateInstantStartExport(repositoryId, config); - console.info("Downloading the project..."); - const archive = await readURLFile(new URL(readyExport.downloadUrl)); + const archive = await readURLFile(instantStartArchiveURL); const destination = resolve(process.cwd(), repositoryId); const destinationExisted = await exists(pathToFileURL(destination)); - await extractZip(new Uint8Array(await archive.arrayBuffer()), destination); + await extractZip(new Uint8Array(await archive.arrayBuffer()), destination, { + stripSingleRootDirectory: true, + }); extractedProject = { destination, destinationExisted }; + await patchInstantStartConfig(destination, repositoryId, config.host); console.info("Installing dependencies..."); await installDependencies({ start: pathToFileURL(destination) }); console.info("Configuring local previews..."); - await removePreviewsByURL(readyExport.previewUrls, { repo: repositoryId, ...config }); + await removePreviewsByURL([instantStartHostedPreviewURL], { + repo: repositoryId, + token: config.token, + host: config.host, + }); await setSimulatorUrl("http://localhost:3000/slice-simulator", { repo: repositoryId, - ...config, + token: config.token, + host: config.host, }); console.info(` diff --git a/src/lib/instant-start.ts b/src/lib/instant-start.ts new file mode 100644 index 0000000..7ada811 --- /dev/null +++ b/src/lib/instant-start.ts @@ -0,0 +1,51 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { Profile } from "./prismic/clients/user"; + +import { CommandError } from "./command"; + +const instantStartCommit = "1eb2488e86a17eb096fe494aae34041f1b840317"; + +export const instantStartArchiveURL = new URL( + `https://github.com/prismicio/instant-start-next-landing-page/archive/${instantStartCommit}.zip`, +); + +export const instantStartHostedPreviewURL = + "https://nextjs-starter-prismic-landing-page.vercel.app/api/preview"; + +export function assertInstantStartRepositoryAccess(repositoryId: string, profile: Profile): void { + const hasRepositoryAccess = profile.repositories.some( + (repository) => repository.domain === repositoryId, + ); + if (!hasRepositoryAccess) { + throw new CommandError( + `Repository "${repositoryId}" not found in your account. Check the name or request access to the repository.`, + ); + } +} + +export async function patchInstantStartConfig( + destination: string, + repositoryId: string, + host: string, +): Promise { + const configPath = join(destination, "prismic.config.json"); + const config: unknown = JSON.parse(await readFile(configPath, "utf8")); + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error("Invalid prismic.config.json."); + } + + await writeFile( + configPath, + `${JSON.stringify( + { + ...config, + repositoryName: repositoryId, + documentAPIEndpoint: `https://${repositoryId}.${host}/api/v2`, + }, + null, + 2, + )}\n`, + ); +} diff --git a/src/lib/prismic/clients/website-generator.ts b/src/lib/prismic/clients/website-generator.ts deleted file mode 100644 index e9b8a2b..0000000 --- a/src/lib/prismic/clients/website-generator.ts +++ /dev/null @@ -1,83 +0,0 @@ -import * as z from "zod/mini"; - -import { request, type RequestOptions } from "../../request"; - -export type WebsiteGeneratorConfig = { - token: string | undefined; - host: string; -}; - -const InstantStartExportNotPreparedSchema = z.object({ - status: z.literal("not-prepared"), -}); - -const InstantStartExportReadySchema = z.object({ - status: z.literal("ready"), - framework: z.literal("next"), - preparedAt: z.string(), - downloadUrl: z.url(), - previewUrls: z.array(z.url()), -}); -export type InstantStartExportReady = z.infer; - -const InstantStartExportStatusSchema = z.union([ - InstantStartExportNotPreparedSchema, - InstantStartExportReadySchema, -]); -export type InstantStartExportStatus = z.infer; - -export function getInstantStartExportStatus( - repositoryId: string, - config: WebsiteGeneratorConfig, -): Promise { - const url = getInstantStartExportUrl(repositoryId, config.host); - return websiteGeneratorRequest(url, config, { - schema: InstantStartExportStatusSchema, - notFoundMessage: `Repository not found: ${repositoryId}`, - unknownErrorMessage: "Failed to check the Instant Start export", - }); -} - -export function createInstantStartExport( - repositoryId: string, - config: WebsiteGeneratorConfig, -): Promise { - const url = getInstantStartExportUrl(repositoryId, config.host); - return websiteGeneratorRequest(url, config, { - method: "POST", - json: { framework: "next", replace: false }, - schema: InstantStartExportReadySchema, - notFoundMessage: `Repository not found: ${repositoryId}`, - unknownErrorMessage: "Failed to create the Instant Start export", - }); -} - -export async function getOrCreateInstantStartExport( - repositoryId: string, - config: WebsiteGeneratorConfig, -): Promise { - const status = await getInstantStartExportStatus(repositoryId, config); - return status.status === "ready" ? status : createInstantStartExport(repositoryId, config); -} - -function websiteGeneratorRequest( - url: URL, - config: WebsiteGeneratorConfig, - options: RequestOptions, -): Promise { - return request(url, { - headers: { Authorization: `Bearer ${config.token ?? ""}` }, - ...options, - }); -} - -function getInstantStartExportUrl(repositoryId: string, host: string): URL { - return new URL( - `instant-start/${encodeURIComponent(repositoryId)}/export`, - getWebsiteGeneratorServiceUrl(host), - ); -} - -function getWebsiteGeneratorServiceUrl(host: string): URL { - return new URL(`https://api.internal.${host}/website-generator/`); -} diff --git a/src/lib/zip.ts b/src/lib/zip.ts index 0fbee08..b256860 100644 --- a/src/lib/zip.ts +++ b/src/lib/zip.ts @@ -2,7 +2,11 @@ import { unzipSync } from "fflate"; import { mkdir, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join, win32 } from "node:path"; -export async function extractZip(data: Uint8Array, destination: string): Promise { +export async function extractZip( + data: Uint8Array, + destination: string, + options: { stripSingleRootDirectory?: boolean } = {}, +): Promise { await assertEmptyOrMissingDirectory(destination); const parent = dirname(destination); @@ -11,8 +15,19 @@ export async function extractZip(data: Uint8Array, destination: string): Promise try { const files = unzipSync(data); - for (const [entryName, contents] of Object.entries(files)) { - const relativePath = normalizeEntryPath(entryName); + const entries = Object.entries(files).map(([entryName, contents]) => ({ + entryName, + contents, + relativePath: normalizeEntryPath(entryName), + })); + const rootDirectory = options.stripSingleRootDirectory + ? getSingleRootDirectory(entries) + : undefined; + + for (const { entryName, contents, relativePath: entryPath } of entries) { + const relativePath = rootDirectory + ? entryPath.slice(rootDirectory.length).replace(/^\/+/, "") + : entryPath; if (!relativePath) continue; const path = join(temporaryDirectory, relativePath); @@ -32,6 +47,29 @@ export async function extractZip(data: Uint8Array, destination: string): Promise } } +function getSingleRootDirectory(entries: { entryName: string; relativePath: string }[]): string { + const rootDirectories = new Set( + entries + .map(({ relativePath }) => relativePath.split("/")[0]) + .filter((rootDirectory) => rootDirectory), + ); + if (rootDirectories.size !== 1) { + throw new Error("ZIP archive does not contain a single root directory."); + } + + const [rootDirectory] = rootDirectories; + const hasInvalidRootEntry = entries.some( + ({ entryName, relativePath }) => + (relativePath === rootDirectory && !entryName.endsWith("/") && !entryName.endsWith("\\")) || + (relativePath !== rootDirectory && !relativePath.startsWith(`${rootDirectory}/`)), + ); + if (!rootDirectory || hasInvalidRootEntry) { + throw new Error("ZIP archive does not contain a single root directory."); + } + + return rootDirectory; +} + async function assertEmptyOrMissingDirectory(destination: string): Promise { try { const destinationStat = await stat(destination); diff --git a/test/instant-start.test.ts b/test/instant-start.test.ts index a0609bb..3d952f2 100644 --- a/test/instant-start.test.ts +++ b/test/instant-start.test.ts @@ -4,8 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it as unitTest, onTestFinished, vi } from "vitest"; +import { readURLFile } from "../src/lib/file"; +import { + assertInstantStartRepositoryAccess, + instantStartArchiveURL, + patchInstantStartConfig, +} from "../src/lib/instant-start"; import { removePreviewsByURL } from "../src/lib/prismic/clients/core"; -import { getOrCreateInstantStartExport } from "../src/lib/prismic/clients/website-generator"; import { extractZip } from "../src/lib/zip"; import { captureOutput, it } from "./it"; @@ -80,59 +85,67 @@ it("does not list instant-start as a top-level command", async ({ expect, prismi expect(stdout).not.toContain("instant-start"); }); -describe.sequential("Instant Start API client", () => { +describe.sequential("Instant Start project", () => { afterEach(() => { vi.unstubAllGlobals(); }); - unitTest("reuses an export that is already ready", async () => { - const readyExport = { - status: "ready", - framework: "next", - preparedAt: "2026-07-17T10:00:00.000Z", - downloadUrl: "https://cdn.example.com/my-repo/.exports/instant-start.zip", - previewUrls: ["https://starter.example.com/api/preview"], + unitTest("uses the pinned public starter archive", () => { + expect(instantStartArchiveURL.toString()).toBe( + "https://github.com/prismicio/instant-start-next-landing-page/archive/1eb2488e86a17eb096fe494aae34041f1b840317.zip", + ); + }); + + unitTest("validates repository access from the authenticated profile", () => { + const profile = { + email: "user@example.com", + shortId: "user", + intercomHash: "hash", + repositories: [{ domain: "my-repo" }], }; - const fetchMock = vi.fn(async () => jsonResponse(readyExport)); - vi.stubGlobal("fetch", fetchMock); - await expect( - getOrCreateInstantStartExport("my-repo", { - token: "test-token", - host: "prismic.io", - }), - ).resolves.toEqual(readyExport); - expect(fetchMock).toHaveBeenCalledOnce(); + expect(() => assertInstantStartRepositoryAccess("my-repo", profile)).not.toThrow(); + expect(() => assertInstantStartRepositoryAccess("other-repo", profile)).toThrow( + 'Repository "other-repo" not found in your account.', + ); }); - unitTest("creates an export when none is prepared", async () => { - const readyExport = { - status: "ready", - framework: "next", - preparedAt: "2026-07-17T10:00:00.000Z", - downloadUrl: "https://cdn.example.com/my-repo/.exports/instant-start.zip", - previewUrls: ["https://starter.example.com/api/preview"], - }; - const fetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse({ status: "not-prepared" })) - .mockResolvedValueOnce(jsonResponse(readyExport)); - vi.stubGlobal("fetch", fetchMock); + unitTest("patches repository settings while preserving starter config", async () => { + const destination = await makeTemporaryDirectory(); + await writeFile( + join(destination, "prismic.config.json"), + JSON.stringify({ + repositoryName: "starter", + documentAPIEndpoint: "https://starter.cdn.prismic.io/api/v2", + libraries: ["./src/slices"], + routes: [{ type: "page", path: "/:uid" }], + }), + ); + + await patchInstantStartConfig(destination, "my-repo", "prismic.io"); await expect( - getOrCreateInstantStartExport("my-repo", { - token: "test-token", - host: "prismic.io", - }), - ).resolves.toEqual(readyExport); + readFile(join(destination, "prismic.config.json"), "utf8").then(JSON.parse), + ).resolves.toEqual({ + repositoryName: "my-repo", + documentAPIEndpoint: "https://my-repo.prismic.io/api/v2", + libraries: ["./src/slices"], + routes: [{ type: "page", path: "/:uid" }], + }); + }); - expect(fetchMock).toHaveBeenCalledTimes(2); - const [, init] = fetchMock.mock.calls[1]; - expect(init?.method).toBe("POST"); - expect(init?.body).toBe(JSON.stringify({ framework: "next", replace: false })); + unitTest("reports archive download failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("Not found", { status: 404 })), + ); + + await expect(readURLFile(instantStartArchiveURL)).rejects.toThrow( + `Failed to download file from "${instantStartArchiveURL.toString()}" (HTTP 404).`, + ); }); - unitTest("removes only previews declared by the export", async () => { + unitTest("removes only the hosted starter preview", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( @@ -189,6 +202,60 @@ describe("ZIP extraction", () => { ); }); + unitTest("strips a single GitHub archive root when requested", async () => { + const root = await makeTemporaryDirectory(); + const destination = join(root, "my-repo"); + + await extractZip( + zipSync({ + "starter-commit/package.json": new TextEncoder().encode('{"name":"starter"}'), + "starter-commit/src/app/page.tsx": new TextEncoder().encode("export default Page"), + }), + destination, + { stripSingleRootDirectory: true }, + ); + + await expect(readFile(join(destination, "package.json"), "utf8")).resolves.toBe( + '{"name":"starter"}', + ); + await expect(access(join(destination, "starter-commit"))).rejects.toThrow(); + }); + + unitTest("accepts an explicit GitHub archive root directory", async () => { + const root = await makeTemporaryDirectory(); + const destination = join(root, "my-repo"); + + await extractZip( + zipSync({ + "starter-commit/": new Uint8Array(), + "starter-commit/package.json": new TextEncoder().encode('{"name":"starter"}'), + }), + destination, + { stripSingleRootDirectory: true }, + ); + + await expect(readFile(join(destination, "package.json"), "utf8")).resolves.toBe( + '{"name":"starter"}', + ); + }); + + unitTest("rejects a top-level file masquerading as the archive root", async () => { + const root = await makeTemporaryDirectory(); + const destination = join(root, "my-repo"); + + await expect( + extractZip( + zipSync({ + "starter-commit": new TextEncoder().encode("not a directory"), + "starter-commit/package.json": new TextEncoder().encode("{}"), + }), + destination, + { stripSingleRootDirectory: true }, + ), + ).rejects.toThrow("ZIP archive does not contain a single root directory"); + await expect(access(destination)).rejects.toThrow(); + }); + unitTest("does not overwrite a non-empty destination", async () => { const root = await makeTemporaryDirectory(); const destination = join(root, "my-repo"); From a50e1dfcc30de866f539f3d3dde92766f94b83b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:03:04 +0100 Subject: [PATCH 09/20] refactor(cli): replace init instant with starter download Restore the standalone init command and move the paired starter handoff into a dedicated starter download namespace with repository validation, pinned GitHub download, local preview setup, and post-extract documents cleanup. Co-authored-by: Cursor --- src/commands/index.ts | 5 + src/commands/init-auth.ts | 49 ---- src/commands/init-instant.ts | 107 -------- src/commands/init-project.ts | 196 --------------- src/commands/init.ts | 232 +++++++++++++++++- src/commands/starter-download.ts | 181 ++++++++++++++ src/commands/starter.ts | 13 + src/lib/command.ts | 6 - src/lib/{instant-start.ts => starter.ts} | 24 +- test/init.test.ts | 2 - ...start.test.ts => starter-download.test.ts} | 82 ++++--- 11 files changed, 481 insertions(+), 416 deletions(-) delete mode 100644 src/commands/init-auth.ts delete mode 100644 src/commands/init-instant.ts delete mode 100644 src/commands/init-project.ts create mode 100644 src/commands/starter-download.ts create mode 100644 src/commands/starter.ts rename src/lib/{instant-start.ts => starter.ts} (64%) rename test/{instant-start.test.ts => starter-download.test.ts} (76%) diff --git a/src/commands/index.ts b/src/commands/index.ts index cccbbdf..216b8fe 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -11,6 +11,7 @@ import pull from "./pull"; import push from "./push"; import repo from "./repo"; import slice from "./slice"; +import starter from "./starter"; import status from "./status"; import sync from "./sync"; import token from "./token"; @@ -64,6 +65,10 @@ export default createCommandRouter({ handler: repo, description: "Manage repositories", }, + starter: { + handler: starter, + description: "Download official starters", + }, type: { handler: type_, description: "Manage content types", diff --git a/src/commands/init-auth.ts b/src/commands/init-auth.ts deleted file mode 100644 index 1a666c7..0000000 --- a/src/commands/init-auth.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Profile } from "../lib/prismic/clients/user"; - -import { createLoginSession, getCredentials } from "../auth"; -import { env } from "../env"; -import { openBrowser } from "../lib/browser"; -import { CommandError } from "../lib/command"; -import { getProfile } from "../lib/prismic/clients/user"; -import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; - -export async function authenticateInit( - noBrowser: boolean | undefined, -): Promise<{ host: string; token: string | undefined; profile: Profile }> { - const { host, token: initialToken } = await getCredentials(); - let token = initialToken; - let profile: Profile; - - try { - profile = await getProfile({ token, host }); - } catch (error) { - if (!(error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError)) { - throw error; - } - if (env.PRISMIC_TOKEN) { - throw new CommandError( - "PRISMIC_TOKEN is invalid or expired. Unset it to log in with a browser, or replace it with a valid token.", - ); - } - - console.info("Not logged in. Starting login..."); - const { email } = await createLoginSession({ - onReady: (url) => { - if (noBrowser) { - console.info(`Open this URL to log in: ${url}`); - } else { - console.info("Opening browser to complete login..."); - console.info(`If the browser doesn't open, visit: ${url}`); - openBrowser(url); - } - }, - }); - console.info(`Logged in as ${email}`); - - const loggedIn = await getCredentials(); - token = loggedIn.token; - profile = await getProfile({ token, host }); - } - - return { host, token, profile }; -} diff --git a/src/commands/init-instant.ts b/src/commands/init-instant.ts deleted file mode 100644 index 454314e..0000000 --- a/src/commands/init-instant.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { mkdir, rm } from "node:fs/promises"; -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -import type { Profile } from "../lib/prismic/clients/user"; - -import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { exists, readURLFile } from "../lib/file"; -import { - assertInstantStartRepositoryAccess, - instantStartArchiveURL, - instantStartHostedPreviewURL, - patchInstantStartConfig, -} from "../lib/instant-start"; -import { installDependencies } from "../lib/packageJson"; -import { removePreviewsByURL, setSimulatorUrl } from "../lib/prismic/clients/core"; -import { extractZip } from "../lib/zip"; -import { authenticateInit } from "./init-auth"; - -const config = { - name: "prismic init instant", - description: "Instantly start a ready-to-run Prismic project.", - options: { - repo: { - type: "string", - short: "r", - description: "Repository name", - required: true, - }, - "no-browser": { - type: "boolean", - description: "Skip opening the browser automatically during login", - }, - }, -} satisfies CommandConfig; - -export default createCommand(config, async ({ values }) => { - const { repo, "no-browser": noBrowser } = values; - const { token, host, profile } = await authenticateInit(noBrowser); - await setupInstantProject(repo, { token, host, profile }); -}); - -async function setupInstantProject( - repository: string, - config: { token: string | undefined; host: string; profile: Profile }, -): Promise { - const repositoryId = repository.toLowerCase(); - assertRepositoryName(repositoryId); - assertInstantStartRepositoryAccess(repositoryId, config.profile); - - let extractedProject: { destination: string; destinationExisted: boolean } | undefined; - - try { - console.info("Downloading the project..."); - const archive = await readURLFile(instantStartArchiveURL); - const destination = resolve(process.cwd(), repositoryId); - const destinationExisted = await exists(pathToFileURL(destination)); - await extractZip(new Uint8Array(await archive.arrayBuffer()), destination, { - stripSingleRootDirectory: true, - }); - extractedProject = { destination, destinationExisted }; - await patchInstantStartConfig(destination, repositoryId, config.host); - - console.info("Installing dependencies..."); - await installDependencies({ start: pathToFileURL(destination) }); - - console.info("Configuring local previews..."); - await removePreviewsByURL([instantStartHostedPreviewURL], { - repo: repositoryId, - token: config.token, - host: config.host, - }); - await setSimulatorUrl("http://localhost:3000/slice-simulator", { - repo: repositoryId, - token: config.token, - host: config.host, - }); - - console.info(` -Your project is ready 🎉 - -Here's what you can do next: - -1. Start the development server: - cd ${destination} - npm run dev - -2. Preview your pages live at https://${repositoryId}.${config.host}/builder - -Start building 🚀 -`); - } catch (error) { - if (extractedProject) { - await rm(extractedProject.destination, { recursive: true, force: true }); - if (extractedProject.destinationExisted) { - await mkdir(extractedProject.destination); - } - } - throw error; - } -} - -function assertRepositoryName(repositoryId: string): void { - if (!/^[a-z0-9][a-z0-9-]*$/.test(repositoryId)) { - throw new CommandError(`Invalid repository name: ${repositoryId}`); - } -} diff --git a/src/commands/init-project.ts b/src/commands/init-project.ts deleted file mode 100644 index f0335e4..0000000 --- a/src/commands/init-project.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { getAdapter } from "../adapters"; -import { DEFAULT_PRISMIC_HOST } from "../env"; -import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { diffArrays } from "../lib/diff"; -import { installDependencies, readPackageJson, removeDependencies } from "../lib/packageJson"; -import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; -import { - createConfig, - deleteLegacySliceMachineConfig, - InvalidLegacySliceMachineConfigError, - MissingPrismicConfigError, - readConfig, - readLegacySliceMachineConfig, - UnknownProjectRootError, -} from "../project"; -import { checkIsTypeBuilderEnabled, TypeBuilderRequiredError } from "../project"; -import { authenticateInit } from "./init-auth"; -import { createRepo } from "./repo-create"; - -const config = { - name: "prismic init", - description: ` - Initialize a new Prismic project by creating a repository and - prismic.config.json file. Detects the project framework, installs - dependencies, and pulls models from Prismic. - - Use --repo to connect to an existing repository instead. If a - slicemachine.config.json exists, its repository and settings will be - migrated. - `, - sections: { - SUBCOMMANDS: ` - instant Instantly start a ready-to-run Prismic project - `, - }, - options: { - repo: { type: "string", short: "r", description: "Repository name" }, - lang: { - type: "string", - short: "l", - description: "Master locale for a new repository (default: en-us)", - }, - "no-browser": { - type: "boolean", - description: "Skip opening the browser automatically during login", - }, - "no-setup": { - type: "boolean", - description: "Skip framework scaffolding (dependencies and framework files)", - }, - }, -} satisfies CommandConfig; - -export default createCommand(config, async ({ values }) => { - const { repo: explicitRepo, lang, "no-browser": noBrowser, "no-setup": noSetup } = values; - - // Check for existing prismic.config.json - try { - await readConfig(); - throw new CommandError( - "A prismic.config.json file exists. This project is already initialized.", - ); - } catch (error) { - if (error instanceof MissingPrismicConfigError) { - // No config found — proceed with initialization. - } else { - throw error; - } - } - - // Load legacy slicemachine.config.json - let legacySliceMachineConfig; - try { - legacySliceMachineConfig = await readLegacySliceMachineConfig(); - } catch (error) { - if (error instanceof InvalidLegacySliceMachineConfigError) { - console.warn("Could not read slicemachine.config.json, ignoring."); - } - } - - const { host, token, profile } = await authenticateInit(noBrowser); - - let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase(); - if (repo) { - const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); - if (!hasRepoAccess) { - throw new CommandError( - `Repository "${repo}" not found in your account. Check the name or request access to the repository.`, - ); - } - - const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host }); - if (!isTypeBuilderEnabled) { - throw new TypeBuilderRequiredError(repo, host); - } - } - - const adapter = await getAdapter(); - - if (!repo) { - repo = await createRepo({ lang, token, host }); - console.info(`Created repository: ${repo}`); - } - - // Create prismic.config.json - try { - const documentAPIEndpoint = - host !== DEFAULT_PRISMIC_HOST ? `https://${repo}.cdn.${host}/api/v2/` : undefined; - await createConfig({ - repositoryName: repo, - documentAPIEndpoint, - libraries: legacySliceMachineConfig?.libraries, - routes: [], - }); - } catch (error) { - if (error instanceof UnknownProjectRootError) { - throw new CommandError( - "Could not find a package.json file. Run this command from a project directory.", - ); - } - throw new CommandError("Failed to create prismic.config.json."); - } - - if (legacySliceMachineConfig) { - try { - await deleteLegacySliceMachineConfig(); - } catch {} - // Slice Machine is replaced by the Type Builder and CLI, so its packages - // are no longer needed after migrating. - const { dependencies, devDependencies, peerDependencies } = await readPackageJson(); - const sliceMachinePackages = Object.keys({ - ...dependencies, - ...devDependencies, - ...peerDependencies, - }).filter((name) => name === "slice-machine-ui" || name.startsWith("@slicemachine/adapter-")); - if (sliceMachinePackages.length > 0) { - await removeDependencies(sliceMachinePackages); - } - console.info("Migrated slicemachine.config.json to prismic.config.json"); - } - - // Install dependencies and create framework files - await adapter.initProject({ setup: !noSetup }); - - // Run package manager install - if (!noSetup) { - try { - console.info("Installing dependencies..."); - await installDependencies(); - } catch { - console.warn( - "Could not install dependencies automatically. Please install them manually (i.e. `npm install`).", - ); - } - } - - // Sync models from remote and generate types - const [remoteCustomTypes, remoteSlices, localCustomTypes, localSlices] = await Promise.all([ - getCustomTypes({ repo, token, host }), - getSlices({ repo, token, host }), - adapter.getCustomTypes(), - adapter.getSlices(), - ]); - const localCustomTypeModels = localCustomTypes.map((c) => c.model); - const localSliceModels = localSlices.map((s) => s.model); - - const sliceOps = diffArrays(remoteSlices, localSliceModels, { getKey: (m) => m.id }); - for (const slice of sliceOps.update) { - await adapter.updateSlice(slice); - } - for (const slice of sliceOps.delete) { - await adapter.deleteSlice(slice.id); - } - for (const slice of sliceOps.insert) { - await adapter.createSlice(slice); - } - - const customTypeOps = diffArrays(remoteCustomTypes, localCustomTypeModels, { - getKey: (m) => m.id, - }); - for (const customType of customTypeOps.update) { - await adapter.updateCustomType(customType); - } - for (const customType of customTypeOps.delete) { - await adapter.deleteCustomType(customType.id); - } - for (const customType of customTypeOps.insert) { - await adapter.createCustomType(customType); - } - - await adapter.generateTypes(); - - console.info(`\nInitialized Prismic for repository "${repo}".`); - console.info("Run `prismic type create ` to create a content type."); - console.info("Run `prismic pull` to pull models from Prismic."); -}); diff --git a/src/commands/init.ts b/src/commands/init.ts index 00783e2..a1b27c1 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,15 +1,227 @@ -import { createCommandRouter } from "../lib/command"; -import instant from "./init-instant"; -import project from "./init-project"; +import type { Profile } from "../lib/prismic/clients/user"; -export default createCommandRouter({ +import { getAdapter } from "../adapters"; +import { createLoginSession, getCredentials } from "../auth"; +import { DEFAULT_PRISMIC_HOST, env } from "../env"; +import { openBrowser } from "../lib/browser"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { diffArrays } from "../lib/diff"; +import { installDependencies, readPackageJson, removeDependencies } from "../lib/packageJson"; +import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; +import { getProfile } from "../lib/prismic/clients/user"; +import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; +import { + createConfig, + deleteLegacySliceMachineConfig, + InvalidLegacySliceMachineConfigError, + MissingPrismicConfigError, + readConfig, + readLegacySliceMachineConfig, + UnknownProjectRootError, +} from "../project"; +import { checkIsTypeBuilderEnabled, TypeBuilderRequiredError } from "../project"; +import { createRepo } from "./repo-create"; + +const config = { name: "prismic init", - description: "Initialize a Prismic project.", - defaultHandler: project, - commands: { - instant: { - handler: instant, - description: "Instantly start a ready-to-run Prismic project", + description: ` + Initialize a new Prismic project by creating a repository and + prismic.config.json file. Detects the project framework, installs + dependencies, and pulls models from Prismic. + + Use --repo to connect to an existing repository instead. If a + slicemachine.config.json exists, its repository and settings will be + migrated. + `, + options: { + repo: { type: "string", short: "r", description: "Repository name" }, + lang: { + type: "string", + short: "l", + description: "Master locale for a new repository (default: en-us)", + }, + "no-browser": { + type: "boolean", + description: "Skip opening the browser automatically during login", + }, + "no-setup": { + type: "boolean", + description: "Skip framework scaffolding (dependencies and framework files)", }, }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { repo: explicitRepo, lang, "no-browser": noBrowser, "no-setup": noSetup } = values; + + // Check for existing prismic.config.json + try { + await readConfig(); + throw new CommandError( + "A prismic.config.json file exists. This project is already initialized.", + ); + } catch (error) { + if (error instanceof MissingPrismicConfigError) { + // No config found — proceed with initialization. + } else { + throw error; + } + } + + // Load legacy slicemachine.config.json + let legacySliceMachineConfig; + try { + legacySliceMachineConfig = await readLegacySliceMachineConfig(); + } catch (error) { + if (error instanceof InvalidLegacySliceMachineConfigError) { + console.warn("Could not read slicemachine.config.json, ignoring."); + } + } + + const { host, token: initialToken } = await getCredentials(); + let token = initialToken; + let profile: Profile; + try { + profile = await getProfile({ token, host }); + } catch (error) { + if (error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError) { + if (env.PRISMIC_TOKEN) { + throw new CommandError( + "PRISMIC_TOKEN is invalid or expired. Unset it to log in with a browser, or replace it with a valid token.", + ); + } + console.info("Not logged in. Starting login..."); + const { email } = await createLoginSession({ + onReady: (url) => { + if (noBrowser) { + console.info(`Open this URL to log in: ${url}`); + } else { + console.info("Opening browser to complete login..."); + console.info(`If the browser doesn't open, visit: ${url}`); + openBrowser(url); + } + }, + }); + console.info(`Logged in as ${email}`); + const loggedIn = await getCredentials(); + token = loggedIn.token; + profile = await getProfile({ token, host }); + } else { + throw error; + } + } + + let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase(); + if (repo) { + const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); + if (!hasRepoAccess) { + throw new CommandError( + `Repository "${repo}" not found in your account. Check the name or request access to the repository.`, + ); + } + + const isTypeBuilderEnabled = await checkIsTypeBuilderEnabled(repo, { token, host }); + if (!isTypeBuilderEnabled) { + throw new TypeBuilderRequiredError(repo, host); + } + } + + const adapter = await getAdapter(); + + if (!repo) { + repo = await createRepo({ lang, token, host }); + console.info(`Created repository: ${repo}`); + } + + // Create prismic.config.json + try { + const documentAPIEndpoint = + host !== DEFAULT_PRISMIC_HOST ? `https://${repo}.cdn.${host}/api/v2/` : undefined; + await createConfig({ + repositoryName: repo, + documentAPIEndpoint, + libraries: legacySliceMachineConfig?.libraries, + routes: [], + }); + } catch (error) { + if (error instanceof UnknownProjectRootError) { + throw new CommandError( + "Could not find a package.json file. Run this command from a project directory.", + ); + } + throw new CommandError("Failed to create prismic.config.json."); + } + + if (legacySliceMachineConfig) { + try { + await deleteLegacySliceMachineConfig(); + } catch {} + // Slice Machine is replaced by the Type Builder and CLI, so its packages + // are no longer needed after migrating. + const { dependencies, devDependencies, peerDependencies } = await readPackageJson(); + const sliceMachinePackages = Object.keys({ + ...dependencies, + ...devDependencies, + ...peerDependencies, + }).filter((name) => name === "slice-machine-ui" || name.startsWith("@slicemachine/adapter-")); + if (sliceMachinePackages.length > 0) { + await removeDependencies(sliceMachinePackages); + } + console.info("Migrated slicemachine.config.json to prismic.config.json"); + } + + // Install dependencies and create framework files + await adapter.initProject({ setup: !noSetup }); + + // Run package manager install + if (!noSetup) { + try { + console.info("Installing dependencies..."); + await installDependencies(); + } catch { + console.warn( + "Could not install dependencies automatically. Please install them manually (i.e. `npm install`).", + ); + } + } + + // Sync models from remote and generate types + const [remoteCustomTypes, remoteSlices, localCustomTypes, localSlices] = await Promise.all([ + getCustomTypes({ repo, token, host }), + getSlices({ repo, token, host }), + adapter.getCustomTypes(), + adapter.getSlices(), + ]); + const localCustomTypeModels = localCustomTypes.map((c) => c.model); + const localSliceModels = localSlices.map((s) => s.model); + + const sliceOps = diffArrays(remoteSlices, localSliceModels, { getKey: (m) => m.id }); + for (const slice of sliceOps.update) { + await adapter.updateSlice(slice); + } + for (const slice of sliceOps.delete) { + await adapter.deleteSlice(slice.id); + } + for (const slice of sliceOps.insert) { + await adapter.createSlice(slice); + } + + const customTypeOps = diffArrays(remoteCustomTypes, localCustomTypeModels, { + getKey: (m) => m.id, + }); + for (const customType of customTypeOps.update) { + await adapter.updateCustomType(customType); + } + for (const customType of customTypeOps.delete) { + await adapter.deleteCustomType(customType.id); + } + for (const customType of customTypeOps.insert) { + await adapter.createCustomType(customType); + } + + await adapter.generateTypes(); + + console.info(`\nInitialized Prismic for repository "${repo}".`); + console.info("Run `prismic type create ` to create a content type."); + console.info("Run `prismic pull` to pull models from Prismic."); }); diff --git a/src/commands/starter-download.ts b/src/commands/starter-download.ts new file mode 100644 index 0000000..30d1508 --- /dev/null +++ b/src/commands/starter-download.ts @@ -0,0 +1,181 @@ +import { mkdir, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import type { Profile } from "../lib/prismic/clients/user"; + +import { createLoginSession, getCredentials } from "../auth"; +import { env } from "../env"; +import { openBrowser } from "../lib/browser"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { exists, readURLFile } from "../lib/file"; +import { installDependencies } from "../lib/packageJson"; +import { addPreview, removePreviewsByURL, setSimulatorUrl } from "../lib/prismic/clients/core"; +import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; +import { getProfile } from "../lib/prismic/clients/user"; +import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; +import { + assertStarterRepositoryAccess, + assertStarterRepositoryHasModels, + patchStarterConfig, + starterArchiveURL, + starterHostedPreviewURL, +} from "../lib/starter"; +import { extractZip } from "../lib/zip"; + +const config = { + name: "prismic starter download", + description: "Download and configure the official starter for a Prismic repository.", + options: { + repo: { + type: "string", + short: "r", + description: "Repository name", + required: true, + }, + "no-browser": { + type: "boolean", + description: "Skip opening the browser automatically during login", + }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { repo, "no-browser": noBrowser } = values; + const { token, host, profile } = await authenticateStarterDownload(noBrowser); + await downloadStarter(repo, { token, host, profile }); +}); + +async function downloadStarter( + repository: string, + config: { token: string | undefined; host: string; profile: Profile }, +): Promise { + const repositoryId = repository.toLowerCase(); + assertRepositoryName(repositoryId); + assertStarterRepositoryAccess(repositoryId, config.profile); + + const [customTypes, slices] = await Promise.all([ + getCustomTypes({ + repo: repositoryId, + token: config.token, + host: config.host, + }), + getSlices({ + repo: repositoryId, + token: config.token, + host: config.host, + }), + ]); + assertStarterRepositoryHasModels(repositoryId, customTypes, slices); + + let extractedProject: { destination: string; destinationExisted: boolean } | undefined; + + try { + console.info("Downloading the project..."); + const archive = await readURLFile(starterArchiveURL); + const destination = resolve(process.cwd(), repositoryId); + const destinationExisted = await exists(pathToFileURL(destination)); + await extractZip(new Uint8Array(await archive.arrayBuffer()), destination, { + stripSingleRootDirectory: true, + }); + extractedProject = { destination, destinationExisted }; + await rm(join(destination, "documents"), { recursive: true, force: true }); + await patchStarterConfig(destination, repositoryId, config.host); + + console.info("Installing dependencies..."); + await installDependencies({ start: pathToFileURL(destination) }); + + console.info("Configuring local previews..."); + await removePreviewsByURL([starterHostedPreviewURL], { + repo: repositoryId, + token: config.token, + host: config.host, + }); + await addPreview( + { + name: "Development", + websiteURL: "http://localhost:3000", + resolverPath: "/api/preview", + }, + { + repo: repositoryId, + token: config.token, + host: config.host, + }, + ); + await setSimulatorUrl("http://localhost:3000/slice-simulator", { + repo: repositoryId, + token: config.token, + host: config.host, + }); + + console.info(` +Your project is ready 🎉 + +Here's what you can do next: + +1. Start the development server: + cd ${destination} + npm run dev + +2. Preview your pages live at https://${repositoryId}.${config.host}/builder + +Start building 🚀 +`); + } catch (error) { + if (extractedProject) { + await rm(extractedProject.destination, { recursive: true, force: true }); + if (extractedProject.destinationExisted) { + await mkdir(extractedProject.destination); + } + } + throw error; + } +} + +function assertRepositoryName(repositoryId: string): void { + if (!/^[a-z0-9][a-z0-9-]*$/.test(repositoryId)) { + throw new CommandError(`Invalid repository name: ${repositoryId}`); + } +} + +async function authenticateStarterDownload( + noBrowser: boolean | undefined, +): Promise<{ host: string; token: string | undefined; profile: Profile }> { + const { host, token: initialToken } = await getCredentials(); + let token = initialToken; + let profile: Profile; + + try { + profile = await getProfile({ token, host }); + } catch (error) { + if (!(error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError)) { + throw error; + } + if (env.PRISMIC_TOKEN) { + throw new CommandError( + "PRISMIC_TOKEN is invalid or expired. Unset it to log in with a browser, or replace it with a valid token.", + ); + } + + console.info("Not logged in. Starting login..."); + const { email } = await createLoginSession({ + onReady: (url) => { + if (noBrowser) { + console.info(`Open this URL to log in: ${url}`); + } else { + console.info("Opening browser to complete login..."); + console.info(`If the browser doesn't open, visit: ${url}`); + openBrowser(url); + } + }, + }); + console.info(`Logged in as ${email}`); + + const loggedIn = await getCredentials(); + token = loggedIn.token; + profile = await getProfile({ token, host }); + } + + return { host, token, profile }; +} diff --git a/src/commands/starter.ts b/src/commands/starter.ts new file mode 100644 index 0000000..a1c4813 --- /dev/null +++ b/src/commands/starter.ts @@ -0,0 +1,13 @@ +import { createCommandRouter } from "../lib/command"; +import download from "./starter-download"; + +export default createCommandRouter({ + name: "prismic starter", + description: "Download official Prismic starters.", + commands: { + download: { + handler: download, + description: "Download and configure a starter", + }, + }, +}); diff --git a/src/lib/command.ts b/src/lib/command.ts index 77dc733..3cd2d47 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -167,7 +167,6 @@ type CreateCommandRouterConfig = { name: string; description: string; sections?: Record; - defaultHandler?: () => Promise; commands: Record; }; type RouterCommand = { handler: () => Promise; description: string }; @@ -178,11 +177,6 @@ export function createCommandRouter(config: CreateCommandRouterConfig): () => Pr return async function () { const args = process.argv.slice(1 + depth); - if (config.defaultHandler && (args.length === 0 || args[0].startsWith("-"))) { - await config.defaultHandler(); - return; - } - const { positionals: [subcommand], } = parseArgs({ diff --git a/src/lib/instant-start.ts b/src/lib/starter.ts similarity index 64% rename from src/lib/instant-start.ts rename to src/lib/starter.ts index 7ada811..8f2a0fd 100644 --- a/src/lib/instant-start.ts +++ b/src/lib/starter.ts @@ -5,16 +5,16 @@ import type { Profile } from "./prismic/clients/user"; import { CommandError } from "./command"; -const instantStartCommit = "1eb2488e86a17eb096fe494aae34041f1b840317"; +const starterCommit = "1eb2488e86a17eb096fe494aae34041f1b840317"; -export const instantStartArchiveURL = new URL( - `https://github.com/prismicio/instant-start-next-landing-page/archive/${instantStartCommit}.zip`, +export const starterArchiveURL = new URL( + `https://github.com/prismicio/instant-start-next-landing-page/archive/${starterCommit}.zip`, ); -export const instantStartHostedPreviewURL = +export const starterHostedPreviewURL = "https://nextjs-starter-prismic-landing-page.vercel.app/api/preview"; -export function assertInstantStartRepositoryAccess(repositoryId: string, profile: Profile): void { +export function assertStarterRepositoryAccess(repositoryId: string, profile: Profile): void { const hasRepositoryAccess = profile.repositories.some( (repository) => repository.domain === repositoryId, ); @@ -25,7 +25,19 @@ export function assertInstantStartRepositoryAccess(repositoryId: string, profile } } -export async function patchInstantStartConfig( +export function assertStarterRepositoryHasModels( + repositoryId: string, + customTypes: unknown[], + slices: unknown[], +): void { + if (customTypes.length > 0 || slices.length > 0) return; + + throw new CommandError( + `Repository "${repositoryId}" has no starter models. Use a repository created from the starter in the Prismic dashboard.`, + ); +} + +export async function patchStarterConfig( destination: string, repositoryId: string, host: string, diff --git a/test/init.test.ts b/test/init.test.ts index 6d32f6a..78da6ac 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -14,8 +14,6 @@ it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("init", ["--help"]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("prismic init [options]"); - expect(stdout).toContain("SUBCOMMANDS"); - expect(stdout).toContain("instant"); }); it("fails if prismic.config.json already exists", async ({ expect, prismic }) => { diff --git a/test/instant-start.test.ts b/test/starter-download.test.ts similarity index 76% rename from test/instant-start.test.ts rename to test/starter-download.test.ts index 3d952f2..4b5f2fe 100644 --- a/test/instant-start.test.ts +++ b/test/starter-download.test.ts @@ -5,44 +5,45 @@ import { join } from "node:path"; import { afterEach, describe, expect, it as unitTest, onTestFinished, vi } from "vitest"; import { readURLFile } from "../src/lib/file"; -import { - assertInstantStartRepositoryAccess, - instantStartArchiveURL, - patchInstantStartConfig, -} from "../src/lib/instant-start"; import { removePreviewsByURL } from "../src/lib/prismic/clients/core"; +import { + assertStarterRepositoryAccess, + assertStarterRepositoryHasModels, + patchStarterConfig, + starterArchiveURL, +} from "../src/lib/starter"; import { extractZip } from "../src/lib/zip"; import { captureOutput, it } from "./it"; -it("supports init instant --help", async ({ expect, prismic }) => { - const { stdout, exitCode } = await prismic("init", ["instant", "--help"]); +it("supports starter download --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("starter", ["download", "--help"]); expect(exitCode).toBe(0); - expect(stdout).toContain("prismic init instant [options]"); + expect(stdout).toContain("prismic starter download [options]"); expect(stdout).toContain("--repo string"); expect(stdout).toContain("(required)"); }); -it("requires --repo in instant mode", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("init", ["instant"]); +it("requires --repo", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("starter", ["download"]); expect(exitCode).toBe(1); expect(stderr).toContain("Missing required option: --repo"); }); -it("rejects an unknown init subcommand", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("init", ["unknown"]); +it("rejects an unknown starter subcommand", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("starter", ["unknown"]); expect(exitCode).toBe(1); expect(stderr).toContain("Unknown command: unknown"); }); -it("rejects extra instant arguments", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("init", ["instant", "extra", "--repo", "my-repo"]); +it("rejects extra download arguments", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("starter", ["download", "extra", "--repo", "my-repo"]); expect(exitCode).toBe(1); expect(stderr).toContain("extra"); }); -it("rejects --lang in instant mode", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("init", [ - "instant", +it("rejects init-only options", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("starter", [ + "download", "--repo", "my-repo", "--lang", @@ -52,46 +53,39 @@ it("rejects --lang in instant mode", async ({ expect, prismic }) => { expect(stderr).toContain("--lang"); }); -it("rejects --no-setup in instant mode", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("init", [ - "instant", +it("validates the repository name", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("starter", [ + "download", "--repo", - "my-repo", - "--no-setup", + "invalid_repository", ]); expect(exitCode).toBe(1); - expect(stderr).toContain("--no-setup"); -}); - -it("dispatches instant mode before checking the current project", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("init", ["instant", "--repo", "invalid_repository"]); - expect(exitCode).toBe(1); expect(stderr).toContain("Invalid repository name"); - expect(stderr).not.toContain("already initialized"); }); -it("uses the init login flow", async ({ expect, logout, prismic, repo }) => { +it("uses the browser login flow", async ({ expect, logout, prismic, repo }) => { await logout(); - const proc = prismic("init", ["instant", "--repo", repo, "--no-browser"]); + const proc = prismic("starter", ["download", "--repo", repo, "--no-browser"]); const output = captureOutput(proc); await expect.poll(output, { timeout: 15_000 }).toMatch(/port=(\d+)/); proc.kill(); }); -it("does not list instant-start as a top-level command", async ({ expect, prismic }) => { +it("lists starter as a top-level command", async ({ expect, prismic }) => { const { stdout, exitCode } = await prismic("", ["--help"]); expect(exitCode).toBe(0); + expect(stdout).toContain("starter"); expect(stdout).not.toContain("instant-start"); }); -describe.sequential("Instant Start project", () => { +describe.sequential("Starter download", () => { afterEach(() => { vi.unstubAllGlobals(); }); unitTest("uses the pinned public starter archive", () => { - expect(instantStartArchiveURL.toString()).toBe( + expect(starterArchiveURL.toString()).toBe( "https://github.com/prismicio/instant-start-next-landing-page/archive/1eb2488e86a17eb096fe494aae34041f1b840317.zip", ); }); @@ -104,12 +98,20 @@ describe.sequential("Instant Start project", () => { repositories: [{ domain: "my-repo" }], }; - expect(() => assertInstantStartRepositoryAccess("my-repo", profile)).not.toThrow(); - expect(() => assertInstantStartRepositoryAccess("other-repo", profile)).toThrow( + expect(() => assertStarterRepositoryAccess("my-repo", profile)).not.toThrow(); + expect(() => assertStarterRepositoryAccess("other-repo", profile)).toThrow( 'Repository "other-repo" not found in your account.', ); }); + unitTest("rejects repositories without starter models", () => { + expect(() => assertStarterRepositoryHasModels("my-repo", [], [])).toThrow( + 'Repository "my-repo" has no starter models.', + ); + expect(() => assertStarterRepositoryHasModels("my-repo", [{}], [])).not.toThrow(); + expect(() => assertStarterRepositoryHasModels("my-repo", [], [{}])).not.toThrow(); + }); + unitTest("patches repository settings while preserving starter config", async () => { const destination = await makeTemporaryDirectory(); await writeFile( @@ -122,7 +124,7 @@ describe.sequential("Instant Start project", () => { }), ); - await patchInstantStartConfig(destination, "my-repo", "prismic.io"); + await patchStarterConfig(destination, "my-repo", "prismic.io"); await expect( readFile(join(destination, "prismic.config.json"), "utf8").then(JSON.parse), @@ -140,8 +142,8 @@ describe.sequential("Instant Start project", () => { vi.fn(async () => new Response("Not found", { status: 404 })), ); - await expect(readURLFile(instantStartArchiveURL)).rejects.toThrow( - `Failed to download file from "${instantStartArchiveURL.toString()}" (HTTP 404).`, + await expect(readURLFile(starterArchiveURL)).rejects.toThrow( + `Failed to download file from "${starterArchiveURL.toString()}" (HTTP 404).`, ); }); @@ -294,7 +296,7 @@ function jsonResponse(value: unknown): Response { } async function makeTemporaryDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "prismic-instant-start-")); + const directory = await mkdtemp(join(tmpdir(), "prismic-starter-download-")); onTestFinished(() => rm(directory, { recursive: true, force: true })); return directory; } From 3dd0d260684f1d567ce54128da8cb052c6091be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:49:49 +0100 Subject: [PATCH 10/20] fix(starter-download): skip duplicate Development preview Check for an existing localhost preview before adding one so rerunning the command does not create duplicate preview configs. Co-authored-by: Cursor --- src/commands/starter-download.ts | 39 +++++++++++++++++++++----------- src/lib/starter.ts | 6 +++++ test/starter-download.test.ts | 20 ++++++++++++++++ 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/commands/starter-download.ts b/src/commands/starter-download.ts index 30d1508..72bb600 100644 --- a/src/commands/starter-download.ts +++ b/src/commands/starter-download.ts @@ -10,13 +10,19 @@ import { openBrowser } from "../lib/browser"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { exists, readURLFile } from "../lib/file"; import { installDependencies } from "../lib/packageJson"; -import { addPreview, removePreviewsByURL, setSimulatorUrl } from "../lib/prismic/clients/core"; +import { + addPreview, + getPreviews, + removePreviewsByURL, + setSimulatorUrl, +} from "../lib/prismic/clients/core"; import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; import { getProfile } from "../lib/prismic/clients/user"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; import { assertStarterRepositoryAccess, assertStarterRepositoryHasModels, + hasStarterLocalPreview, patchStarterConfig, starterArchiveURL, starterHostedPreviewURL, @@ -91,18 +97,25 @@ async function downloadStarter( token: config.token, host: config.host, }); - await addPreview( - { - name: "Development", - websiteURL: "http://localhost:3000", - resolverPath: "/api/preview", - }, - { - repo: repositoryId, - token: config.token, - host: config.host, - }, - ); + const previews = await getPreviews({ + repo: repositoryId, + token: config.token, + host: config.host, + }); + if (!hasStarterLocalPreview(previews)) { + await addPreview( + { + name: "Development", + websiteURL: "http://localhost:3000", + resolverPath: "/api/preview", + }, + { + repo: repositoryId, + token: config.token, + host: config.host, + }, + ); + } await setSimulatorUrl("http://localhost:3000/slice-simulator", { repo: repositoryId, token: config.token, diff --git a/src/lib/starter.ts b/src/lib/starter.ts index 8f2a0fd..2691a10 100644 --- a/src/lib/starter.ts +++ b/src/lib/starter.ts @@ -14,6 +14,12 @@ export const starterArchiveURL = new URL( export const starterHostedPreviewURL = "https://nextjs-starter-prismic-landing-page.vercel.app/api/preview"; +export const starterLocalPreviewURL = "http://localhost:3000/api/preview"; + +export function hasStarterLocalPreview(previews: readonly { url: string }[]): boolean { + return previews.some((preview) => preview.url === starterLocalPreviewURL); +} + export function assertStarterRepositoryAccess(repositoryId: string, profile: Profile): void { const hasRepositoryAccess = profile.repositories.some( (repository) => repository.domain === repositoryId, diff --git a/test/starter-download.test.ts b/test/starter-download.test.ts index 4b5f2fe..141f705 100644 --- a/test/starter-download.test.ts +++ b/test/starter-download.test.ts @@ -9,8 +9,10 @@ import { removePreviewsByURL } from "../src/lib/prismic/clients/core"; import { assertStarterRepositoryAccess, assertStarterRepositoryHasModels, + hasStarterLocalPreview, patchStarterConfig, starterArchiveURL, + starterLocalPreviewURL, } from "../src/lib/starter"; import { extractZip } from "../src/lib/zip"; import { captureOutput, it } from "./it"; @@ -147,6 +149,24 @@ describe.sequential("Starter download", () => { ); }); + unitTest("detects an existing local development preview", () => { + expect(starterLocalPreviewURL).toBe("http://localhost:3000/api/preview"); + expect( + hasStarterLocalPreview([ + { + url: starterLocalPreviewURL, + }, + ]), + ).toBe(true); + expect( + hasStarterLocalPreview([ + { + url: "https://custom.example.com/api/preview", + }, + ]), + ).toBe(false); + }); + unitTest("removes only the hosted starter preview", async () => { const fetchMock = vi .fn() From ae38848ec68fd384a43dda4b29d2f5cb46958515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:33:08 +0100 Subject: [PATCH 11/20] feat(starter): download archive from repository starter metadata Resolve the GitHub archive URL from persisted starter provenance instead of a hardcoded commit, and reject repositories without recorded revision metadata. Co-authored-by: Cursor --- src/commands/starter-download.ts | 10 ++++- src/lib/prismic/clients/repository.ts | 25 ++++++++--- src/lib/starter.ts | 42 +++++++++++++++---- test/repository-client.test.ts | 49 ++++++++++++++++++++++ test/starter-download.test.ts | 60 +++++++++++++++++++++++++-- 5 files changed, 167 insertions(+), 19 deletions(-) create mode 100644 test/repository-client.test.ts diff --git a/src/commands/starter-download.ts b/src/commands/starter-download.ts index 47d2367..1a32a7e 100644 --- a/src/commands/starter-download.ts +++ b/src/commands/starter-download.ts @@ -15,6 +15,7 @@ import { setSimulatorUrl, } from "../lib/prismic/clients/core"; import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; +import { getRepository } from "../lib/prismic/clients/repository"; import { getProfile, type Profile } from "../lib/prismic/clients/user"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; import { sentryCaptureError } from "../lib/sentry"; @@ -22,7 +23,7 @@ import { assertStarterRepositoryAccess, assertStarterRepositoryHasModels, patchStarterConfig, - starterArchiveURL, + resolveStarterArchive, starterHostedPreviewURL, starterLocalPreviewURL, } from "../lib/starter"; @@ -66,6 +67,13 @@ async function downloadStarter( repositoryId: string, config: { token: string | undefined; host: string }, ): Promise { + const repository = await getRepository({ + repo: repositoryId, + token: config.token, + host: config.host, + }); + const starterArchiveURL = resolveStarterArchive(repository.starter); + const [customTypes, slices] = await Promise.all([ getCustomTypes({ repo: repositoryId, diff --git a/src/lib/prismic/clients/repository.ts b/src/lib/prismic/clients/repository.ts index fdfe1bf..8334454 100644 --- a/src/lib/prismic/clients/repository.ts +++ b/src/lib/prismic/clients/repository.ts @@ -8,14 +8,27 @@ type RepositoryConfig = { host: string; }; -const RepositorySchema = z.object({ - quotas: z.optional( - z.object({ - sliceMachineEnabled: z.boolean(), - }), - ), +const RepositoryStarterSchema = z.object({ + id: z.string(), + revision: z.string(), + framework: z.string(), }); +const RepositorySchema = z.pipe( + z.object({ + starter: z.optional(z.nullable(RepositoryStarterSchema)), + quotas: z.optional( + z.object({ + sliceMachineEnabled: z.boolean(), + }), + ), + }), + z.transform((repository) => ({ + ...repository, + starter: repository.starter ?? null, + })), +); + export type Repository = z.infer; export function getRepository(config: RepositoryConfig): Promise { diff --git a/src/lib/starter.ts b/src/lib/starter.ts index 497643d..b7e5c76 100644 --- a/src/lib/starter.ts +++ b/src/lib/starter.ts @@ -1,21 +1,19 @@ import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import type { Repository } from "./prismic/clients/repository"; import type { Profile } from "./prismic/clients/user"; import { CommandError } from "./command"; -const starterCommit = "1eb2488e86a17eb096fe494aae34041f1b840317"; - -export const starterArchiveURL = new URL( - `https://github.com/prismicio/instant-start-next-landing-page/archive/${starterCommit}.zip`, -); - -export const starterHostedPreviewURL = - "https://nextjs-starter-prismic-landing-page.vercel.app/api/preview"; +export const starterHostedPreviewURL = "https://next-instant-start.vercel.app/api/preview"; export const starterLocalPreviewURL = "http://localhost:3000/api/preview"; +const supportedStarterId = "prismicio/next-instant-start"; +const supportedStarterFramework = "next"; +const starterRevisionPattern = /^[0-9a-f]{40}$/; + export function assertStarterRepositoryAccess(repositoryId: string, profile: Profile): void { const hasRepositoryAccess = profile.repositories.some( (repository) => repository.domain === repositoryId, @@ -39,6 +37,34 @@ export function assertStarterRepositoryHasModels( ); } +export function resolveStarterArchive(starter: Repository["starter"] | undefined): URL { + if (starter == null) { + throw new CommandError( + `Repository does not support starter download. Use a repository created with Instant Start.`, + ); + } + + if (starter.id !== supportedStarterId) { + throw new CommandError( + `Repository starter "${starter.id}" is not supported by starter download.`, + ); + } + + if (starter.framework !== supportedStarterFramework) { + throw new CommandError( + `Repository starter framework "${starter.framework}" is not supported by starter download.`, + ); + } + + if (!starterRevisionPattern.test(starter.revision)) { + throw new CommandError( + `Repository starter revision "${starter.revision}" is not a valid Git commit SHA.`, + ); + } + + return new URL(`https://github.com/prismicio/next-instant-start/archive/${starter.revision}.zip`); +} + export async function patchStarterConfig( destination: string, repositoryId: string, diff --git a/test/repository-client.test.ts b/test/repository-client.test.ts new file mode 100644 index 0000000..872af05 --- /dev/null +++ b/test/repository-client.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getRepository } from "../src/lib/prismic/clients/repository"; + +describe("getRepository starter parsing", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("defaults missing starter metadata to null", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ quotas: { sliceMachineEnabled: true } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + getRepository({ + repo: "my-repo", + token: "test-token", + host: "prismic.io", + }), + ).resolves.toEqual({ + starter: null, + quotas: { sliceMachineEnabled: true }, + }); + }); + + it("preserves explicit null starter metadata", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ starter: null }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + getRepository({ + repo: "my-repo", + token: "test-token", + host: "prismic.io", + }), + ).resolves.toEqual({ + starter: null, + }); + }); +}); diff --git a/test/starter-download.test.ts b/test/starter-download.test.ts index 133103c..e58622b 100644 --- a/test/starter-download.test.ts +++ b/test/starter-download.test.ts @@ -12,7 +12,8 @@ import { assertStarterRepositoryAccess, assertStarterRepositoryHasModels, patchStarterConfig, - starterArchiveURL, + resolveStarterArchive, + starterHostedPreviewURL, starterLocalPreviewURL, } from "../src/lib/starter"; import { extractZip } from "../src/lib/zip"; @@ -105,12 +106,57 @@ describe.sequential("Starter download", () => { vi.unstubAllGlobals(); }); - unitTest("uses the pinned public starter archive", () => { - expect(starterArchiveURL.toString()).toBe( - "https://github.com/prismicio/instant-start-next-landing-page/archive/1eb2488e86a17eb096fe494aae34041f1b840317.zip", + unitTest("builds the archive URL from repository starter metadata", () => { + expect( + resolveStarterArchive({ + id: "prismicio/next-instant-start", + revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", + framework: "next", + }).toString(), + ).toBe( + "https://github.com/prismicio/next-instant-start/archive/d2d78ca51884d0d665ec58879d370d033eefaf04.zip", ); }); + unitTest("rejects repositories without starter provenance", () => { + expect(() => resolveStarterArchive(null)).toThrow( + "Repository does not support starter download.", + ); + expect(() => resolveStarterArchive(undefined)).toThrow( + "Repository does not support starter download.", + ); + }); + + unitTest("rejects unsupported starter metadata", () => { + expect(() => + resolveStarterArchive({ + id: "prismicio/other-starter", + revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", + framework: "next", + }), + ).toThrow('Repository starter "prismicio/other-starter" is not supported'); + + expect(() => + resolveStarterArchive({ + id: "prismicio/next-instant-start", + revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", + framework: "nuxt", + }), + ).toThrow('Repository starter framework "nuxt" is not supported'); + + expect(() => + resolveStarterArchive({ + id: "prismicio/next-instant-start", + revision: "not-a-sha", + framework: "next", + }), + ).toThrow('Repository starter revision "not-a-sha" is not a valid Git commit SHA.'); + }); + + unitTest("uses the hosted preview URL for cleanup", () => { + expect(starterHostedPreviewURL).toBe("https://next-instant-start.vercel.app/api/preview"); + }); + unitTest("validates repository access from the authenticated profile", () => { const profile = { email: "user@example.com", @@ -158,6 +204,12 @@ describe.sequential("Starter download", () => { }); unitTest("reports archive download failures", async () => { + const starterArchiveURL = resolveStarterArchive({ + id: "prismicio/next-instant-start", + revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", + framework: "next", + }); + vi.stubGlobal( "fetch", vi.fn(async () => new Response("Not found", { status: 404 })), From 31e452efd8cccde44858d4d46b946144c15898a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:55:25 +0100 Subject: [PATCH 12/20] refactor(starter): trust repository starter provenance for archive URL Build the GitHub archive URL directly from repository metadata and only reject repositories without starter provenance. Co-authored-by: Cursor --- src/lib/starter.ts | 24 +---------------------- test/starter-download.test.ts | 36 ++++------------------------------- 2 files changed, 5 insertions(+), 55 deletions(-) diff --git a/src/lib/starter.ts b/src/lib/starter.ts index b7e5c76..51ffcb9 100644 --- a/src/lib/starter.ts +++ b/src/lib/starter.ts @@ -10,10 +10,6 @@ export const starterHostedPreviewURL = "https://next-instant-start.vercel.app/ap export const starterLocalPreviewURL = "http://localhost:3000/api/preview"; -const supportedStarterId = "prismicio/next-instant-start"; -const supportedStarterFramework = "next"; -const starterRevisionPattern = /^[0-9a-f]{40}$/; - export function assertStarterRepositoryAccess(repositoryId: string, profile: Profile): void { const hasRepositoryAccess = profile.repositories.some( (repository) => repository.domain === repositoryId, @@ -44,25 +40,7 @@ export function resolveStarterArchive(starter: Repository["starter"] | undefined ); } - if (starter.id !== supportedStarterId) { - throw new CommandError( - `Repository starter "${starter.id}" is not supported by starter download.`, - ); - } - - if (starter.framework !== supportedStarterFramework) { - throw new CommandError( - `Repository starter framework "${starter.framework}" is not supported by starter download.`, - ); - } - - if (!starterRevisionPattern.test(starter.revision)) { - throw new CommandError( - `Repository starter revision "${starter.revision}" is not a valid Git commit SHA.`, - ); - } - - return new URL(`https://github.com/prismicio/next-instant-start/archive/${starter.revision}.zip`); + return new URL(`https://github.com/${starter.id}/archive/${starter.revision}.zip`); } export async function patchStarterConfig( diff --git a/test/starter-download.test.ts b/test/starter-download.test.ts index e58622b..74dfdb5 100644 --- a/test/starter-download.test.ts +++ b/test/starter-download.test.ts @@ -109,13 +109,11 @@ describe.sequential("Starter download", () => { unitTest("builds the archive URL from repository starter metadata", () => { expect( resolveStarterArchive({ - id: "prismicio/next-instant-start", - revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", - framework: "next", + id: "prismicio/custom-starter", + revision: "starter-release", + framework: "custom", }).toString(), - ).toBe( - "https://github.com/prismicio/next-instant-start/archive/d2d78ca51884d0d665ec58879d370d033eefaf04.zip", - ); + ).toBe("https://github.com/prismicio/custom-starter/archive/starter-release.zip"); }); unitTest("rejects repositories without starter provenance", () => { @@ -127,32 +125,6 @@ describe.sequential("Starter download", () => { ); }); - unitTest("rejects unsupported starter metadata", () => { - expect(() => - resolveStarterArchive({ - id: "prismicio/other-starter", - revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", - framework: "next", - }), - ).toThrow('Repository starter "prismicio/other-starter" is not supported'); - - expect(() => - resolveStarterArchive({ - id: "prismicio/next-instant-start", - revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", - framework: "nuxt", - }), - ).toThrow('Repository starter framework "nuxt" is not supported'); - - expect(() => - resolveStarterArchive({ - id: "prismicio/next-instant-start", - revision: "not-a-sha", - framework: "next", - }), - ).toThrow('Repository starter revision "not-a-sha" is not a valid Git commit SHA.'); - }); - unitTest("uses the hosted preview URL for cleanup", () => { expect(starterHostedPreviewURL).toBe("https://next-instant-start.vercel.app/api/preview"); }); From 88032e16e2b2574abcf9fff832020d6859526423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:38:04 +0100 Subject: [PATCH 13/20] feat(starter): complete local handoff after download Mark the Instant Start onboarding step complete only after the starter has been downloaded and configured locally, without failing setup if tracking fails. Co-authored-by: Cursor --- src/commands/starter-download.ts | 8 +++++++- src/lib/prismic/clients/repository.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/commands/starter-download.ts b/src/commands/starter-download.ts index 1a32a7e..16b2702 100644 --- a/src/commands/starter-download.ts +++ b/src/commands/starter-download.ts @@ -15,7 +15,7 @@ import { setSimulatorUrl, } from "../lib/prismic/clients/core"; import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; -import { getRepository } from "../lib/prismic/clients/repository"; +import { completeOnboardingStepsSilently, getRepository } from "../lib/prismic/clients/repository"; import { getProfile, type Profile } from "../lib/prismic/clients/user"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; import { sentryCaptureError } from "../lib/sentry"; @@ -148,6 +148,12 @@ async function downloadStarter( ); } + await completeOnboardingStepsSilently({ + repo: repositoryId, + ...config, + stepIds: ["instantStart_continueBuildingLocally"], + }); + let previewInstruction = ""; if (localPreviewConfigured) { previewInstruction = `• Preview your pages live at https://${repositoryId}.${config.host}/builder\n`; diff --git a/src/lib/prismic/clients/repository.ts b/src/lib/prismic/clients/repository.ts index 8334454..7db28b7 100644 --- a/src/lib/prismic/clients/repository.ts +++ b/src/lib/prismic/clients/repository.ts @@ -40,7 +40,8 @@ export type OnboardingStep = | "createPrismicProject" | "createPageType" | "createSlice" - | "connectPrismic"; + | "connectPrismic" + | "instantStart_continueBuildingLocally"; const OnboardingStateSchema = z.object({ completedSteps: z.array(z.string()), From cf16d38774403de0413f20b8a2576c5719be6cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:20:29 +0100 Subject: [PATCH 14/20] fix(starter): derive hosted preview from provenance Read the deployment URL from repository starter metadata so download removes the exact preview configured during provisioning. Co-authored-by: Cursor --- src/commands/starter-download.ts | 3 ++- src/lib/prismic/clients/repository.ts | 1 + src/lib/starter.ts | 12 ++++++++++-- test/repository-client.test.ts | 24 ++++++++++++++++++++++++ test/starter-download.test.ts | 15 ++++++++++++--- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/commands/starter-download.ts b/src/commands/starter-download.ts index 16b2702..800df10 100644 --- a/src/commands/starter-download.ts +++ b/src/commands/starter-download.ts @@ -24,7 +24,7 @@ import { assertStarterRepositoryHasModels, patchStarterConfig, resolveStarterArchive, - starterHostedPreviewURL, + resolveStarterHostedPreviewURL, starterLocalPreviewURL, } from "../lib/starter"; import { extractZip } from "../lib/zip"; @@ -73,6 +73,7 @@ async function downloadStarter( host: config.host, }); const starterArchiveURL = resolveStarterArchive(repository.starter); + const starterHostedPreviewURL = resolveStarterHostedPreviewURL(repository.starter); const [customTypes, slices] = await Promise.all([ getCustomTypes({ diff --git a/src/lib/prismic/clients/repository.ts b/src/lib/prismic/clients/repository.ts index 7db28b7..0003a90 100644 --- a/src/lib/prismic/clients/repository.ts +++ b/src/lib/prismic/clients/repository.ts @@ -12,6 +12,7 @@ const RepositoryStarterSchema = z.object({ id: z.string(), revision: z.string(), framework: z.string(), + deploymentUrl: z.url(), }); const RepositorySchema = z.pipe( diff --git a/src/lib/starter.ts b/src/lib/starter.ts index 51ffcb9..aaae68e 100644 --- a/src/lib/starter.ts +++ b/src/lib/starter.ts @@ -6,8 +6,6 @@ import type { Profile } from "./prismic/clients/user"; import { CommandError } from "./command"; -export const starterHostedPreviewURL = "https://next-instant-start.vercel.app/api/preview"; - export const starterLocalPreviewURL = "http://localhost:3000/api/preview"; export function assertStarterRepositoryAccess(repositoryId: string, profile: Profile): void { @@ -43,6 +41,16 @@ export function resolveStarterArchive(starter: Repository["starter"] | undefined return new URL(`https://github.com/${starter.id}/archive/${starter.revision}.zip`); } +export function resolveStarterHostedPreviewURL(starter: Repository["starter"] | undefined): string { + if (starter == null) { + throw new CommandError( + `Repository does not support starter download. Use a repository created with Instant Start.`, + ); + } + + return new URL("/api/preview", starter.deploymentUrl).href; +} + export async function patchStarterConfig( destination: string, repositoryId: string, diff --git a/test/repository-client.test.ts b/test/repository-client.test.ts index 872af05..8c4cf4e 100644 --- a/test/repository-client.test.ts +++ b/test/repository-client.test.ts @@ -46,4 +46,28 @@ describe("getRepository starter parsing", () => { starter: null, }); }); + + it("preserves complete starter provenance", async () => { + const starter = { + id: "prismicio/next-instant-start", + revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", + framework: "next", + deploymentUrl: "https://next-instant-start-2a3svap7o-prismic.vercel.app/", + }; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ starter }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + getRepository({ + repo: "my-repo", + token: "test-token", + host: "prismic.io", + }), + ).resolves.toEqual({ starter }); + }); }); diff --git a/test/starter-download.test.ts b/test/starter-download.test.ts index 74dfdb5..f7d9df1 100644 --- a/test/starter-download.test.ts +++ b/test/starter-download.test.ts @@ -13,7 +13,7 @@ import { assertStarterRepositoryHasModels, patchStarterConfig, resolveStarterArchive, - starterHostedPreviewURL, + resolveStarterHostedPreviewURL, starterLocalPreviewURL, } from "../src/lib/starter"; import { extractZip } from "../src/lib/zip"; @@ -112,6 +112,7 @@ describe.sequential("Starter download", () => { id: "prismicio/custom-starter", revision: "starter-release", framework: "custom", + deploymentUrl: "https://starter.example.com/", }).toString(), ).toBe("https://github.com/prismicio/custom-starter/archive/starter-release.zip"); }); @@ -125,8 +126,15 @@ describe.sequential("Starter download", () => { ); }); - unitTest("uses the hosted preview URL for cleanup", () => { - expect(starterHostedPreviewURL).toBe("https://next-instant-start.vercel.app/api/preview"); + unitTest("builds the hosted preview URL from starter metadata", () => { + expect( + resolveStarterHostedPreviewURL({ + id: "prismicio/custom-starter", + revision: "starter-release", + framework: "custom", + deploymentUrl: "https://starter.example.com/", + }), + ).toBe("https://starter.example.com/api/preview"); }); unitTest("validates repository access from the authenticated profile", () => { @@ -180,6 +188,7 @@ describe.sequential("Starter download", () => { id: "prismicio/next-instant-start", revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", framework: "next", + deploymentUrl: "https://next-instant-start-2a3svap7o-prismic.vercel.app/", }); vi.stubGlobal( From 2423c085eda70b46dffe359616a53abe93befb38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:00:52 +0100 Subject: [PATCH 15/20] feat(init): replace starter download with degit handoff Move Instant Start local setup to `prismic init --repo` for existing projects, removing the dedicated starter download command and zip flow. Co-authored-by: Cursor --- package-lock.json | 9 - package.json | 3 - src/commands/index.ts | 5 - src/commands/init.ts | 145 ++++++++--- src/commands/starter-download.ts | 230 ----------------- src/commands/starter.ts | 13 - src/lib/packageJson.ts | 13 +- src/lib/starter.ts | 110 ++++---- src/lib/zip.ts | 108 -------- test/index.test.ts | 1 + test/init.test.ts | 27 +- test/starter-download.test.ts | 423 ------------------------------- test/starter-handoff.test.ts | 115 +++++++++ 13 files changed, 314 insertions(+), 888 deletions(-) delete mode 100644 src/commands/starter-download.ts delete mode 100644 src/commands/starter.ts delete mode 100644 src/lib/zip.ts delete mode 100644 test/starter-download.test.ts create mode 100644 test/starter-handoff.test.ts diff --git a/package-lock.json b/package-lock.json index 1651d50..2789b1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "prismic", "version": "1.13.0", "license": "Apache-2.0", - "dependencies": { - "fflate": "^0.8.3" - }, "bin": { "prismic": "dist/index.mjs" }, @@ -2164,12 +2161,6 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/package.json b/package.json index 1d08625..a2b3c53 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,6 @@ "unit:watch": "vitest watch", "test": "npm run lint && npm run types && npm run unit" }, - "dependencies": { - "fflate": "^0.8.3" - }, "devDependencies": { "@prismicio/types-internal": "3.16.1", "@types/node": "25.0.9", diff --git a/src/commands/index.ts b/src/commands/index.ts index 19479ba..e715d22 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -12,7 +12,6 @@ import pull from "./pull"; import push from "./push"; import repo from "./repo"; import slice from "./slice"; -import starter from "./starter"; import status from "./status"; import sync from "./sync"; import token from "./token"; @@ -71,10 +70,6 @@ export default createCommandRouter({ handler: repo, description: "Manage repositories", }, - starter: { - handler: starter, - description: "Download official starters", - }, type: { handler: type_, description: "Manage content types", diff --git a/src/commands/init.ts b/src/commands/init.ts index a1b27c1..51e2441 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,3 +1,5 @@ +import { rm } from "node:fs/promises"; + import type { Profile } from "../lib/prismic/clients/user"; import { getAdapter } from "../adapters"; @@ -8,16 +10,26 @@ import { CommandError, createCommand, type CommandConfig } from "../lib/command" import { diffArrays } from "../lib/diff"; import { installDependencies, readPackageJson, removeDependencies } from "../lib/packageJson"; import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; +import { getRepository, type Repository } from "../lib/prismic/clients/repository"; import { getProfile } from "../lib/prismic/clients/user"; +import { canonicalizeCustomType, canonicalizeSlice } from "../lib/prismic/models"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; import { + assertStarterRepositoryHasModels, + completeStarterHandoff, + hasUnsafeStarterModelChanges, +} from "../lib/starter"; +import { + type Config, createConfig, deleteLegacySliceMachineConfig, + findProjectRoot, InvalidLegacySliceMachineConfigError, MissingPrismicConfigError, readConfig, readLegacySliceMachineConfig, UnknownProjectRootError, + updateConfig, } from "../project"; import { checkIsTypeBuilderEnabled, TypeBuilderRequiredError } from "../project"; import { createRepo } from "./repo-create"; @@ -54,27 +66,28 @@ const config = { export default createCommand(config, async ({ values }) => { const { repo: explicitRepo, lang, "no-browser": noBrowser, "no-setup": noSetup } = values; - // Check for existing prismic.config.json + let existingConfig: Config | undefined; try { - await readConfig(); + existingConfig = await readConfig(); + } catch (error) { + if (!(error instanceof MissingPrismicConfigError)) throw error; + } + if (existingConfig && !explicitRepo) { throw new CommandError( - "A prismic.config.json file exists. This project is already initialized.", + "A prismic.config.json file exists. Use `prismic init --repo ` to connect it to an existing repository.", ); - } catch (error) { - if (error instanceof MissingPrismicConfigError) { - // No config found — proceed with initialization. - } else { - throw error; - } } + const isExistingProjectHandoff = existingConfig !== undefined && explicitRepo !== undefined; // Load legacy slicemachine.config.json let legacySliceMachineConfig; - try { - legacySliceMachineConfig = await readLegacySliceMachineConfig(); - } catch (error) { - if (error instanceof InvalidLegacySliceMachineConfigError) { - console.warn("Could not read slicemachine.config.json, ignoring."); + if (!existingConfig) { + try { + legacySliceMachineConfig = await readLegacySliceMachineConfig(); + } catch (error) { + if (error instanceof InvalidLegacySliceMachineConfigError) { + console.warn("Could not read slicemachine.config.json, ignoring."); + } } } @@ -112,6 +125,7 @@ export default createCommand(config, async ({ values }) => { } let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase(); + let connectedRepository: Repository | undefined; if (repo) { const hasRepoAccess = profile.repositories.some((repository) => repository.domain === repo); if (!hasRepoAccess) { @@ -124,6 +138,8 @@ export default createCommand(config, async ({ values }) => { if (!isTypeBuilderEnabled) { throw new TypeBuilderRequiredError(repo, host); } + + connectedRepository = await getRepository({ repo, token, host }); } const adapter = await getAdapter(); @@ -133,16 +149,20 @@ export default createCommand(config, async ({ values }) => { console.info(`Created repository: ${repo}`); } - // Create prismic.config.json + // Create or reconnect prismic.config.json try { const documentAPIEndpoint = host !== DEFAULT_PRISMIC_HOST ? `https://${repo}.cdn.${host}/api/v2/` : undefined; - await createConfig({ - repositoryName: repo, - documentAPIEndpoint, - libraries: legacySliceMachineConfig?.libraries, - routes: [], - }); + if (existingConfig) { + await updateConfig({ repositoryName: repo, documentAPIEndpoint }); + } else { + await createConfig({ + repositoryName: repo, + documentAPIEndpoint, + libraries: legacySliceMachineConfig?.libraries, + routes: [], + }); + } } catch (error) { if (error instanceof UnknownProjectRootError) { throw new CommandError( @@ -171,7 +191,7 @@ export default createCommand(config, async ({ values }) => { } // Install dependencies and create framework files - await adapter.initProject({ setup: !noSetup }); + await adapter.initProject({ setup: !noSetup && !existingConfig }); // Run package manager install if (!noSetup) { @@ -195,33 +215,78 @@ export default createCommand(config, async ({ values }) => { const localCustomTypeModels = localCustomTypes.map((c) => c.model); const localSliceModels = localSlices.map((s) => s.model); - const sliceOps = diffArrays(remoteSlices, localSliceModels, { getKey: (m) => m.id }); - for (const slice of sliceOps.update) { - await adapter.updateSlice(slice); - } - for (const slice of sliceOps.delete) { - await adapter.deleteSlice(slice.id); - } - for (const slice of sliceOps.insert) { - await adapter.createSlice(slice); - } + const sliceOps = diffArrays(remoteSlices, localSliceModels, { + getKey: (model) => model.id, + equals: (a, b) => JSON.stringify(canonicalizeSlice(a)) === JSON.stringify(canonicalizeSlice(b)), + }); const customTypeOps = diffArrays(remoteCustomTypes, localCustomTypeModels, { - getKey: (m) => m.id, + getKey: (model) => model.id, + equals: (a, b) => + JSON.stringify(canonicalizeCustomType(a)) === JSON.stringify(canonicalizeCustomType(b)), }); - for (const customType of customTypeOps.update) { - await adapter.updateCustomType(customType); - } - for (const customType of customTypeOps.delete) { - await adapter.deleteCustomType(customType.id); + + if (isExistingProjectHandoff && connectedRepository?.starter) { + assertStarterRepositoryHasModels(repo, remoteCustomTypes, remoteSlices); + await removeStarterDocuments(connectedRepository.starter); } - for (const customType of customTypeOps.insert) { - await adapter.createCustomType(customType); + + const hasUnsafeModelChanges = + isExistingProjectHandoff && hasUnsafeStarterModelChanges(customTypeOps, sliceOps); + + if (!hasUnsafeModelChanges) { + for (const slice of sliceOps.update) { + await adapter.updateSlice(slice); + } + for (const slice of sliceOps.delete) { + await adapter.deleteSlice(slice.id); + } + for (const slice of sliceOps.insert) { + await adapter.createSlice(slice); + } + + for (const customType of customTypeOps.update) { + await adapter.updateCustomType(customType); + } + for (const customType of customTypeOps.delete) { + await adapter.deleteCustomType(customType.id); + } + for (const customType of customTypeOps.insert) { + await adapter.createCustomType(customType); + } } await adapter.generateTypes(); + if (hasUnsafeModelChanges) { + console.warn(` +Local and remote models differ, so no model files were changed. The project is connected. + +Choose the source of truth: + prismic pull --force Adopt remote models + prismic push --force Keep local models + `); + } + + if (isExistingProjectHandoff && connectedRepository?.starter) { + await completeStarterHandoff(connectedRepository.starter, { repo, token, host }); + } + console.info(`\nInitialized Prismic for repository "${repo}".`); console.info("Run `prismic type create ` to create a content type."); console.info("Run `prismic pull` to pull models from Prismic."); }); + +async function removeStarterDocuments(starter: NonNullable): Promise { + const packageJson = await readPackageJson(); + const starterPackageName = starter.id.split("/").at(-1); + if (!starterPackageName || packageJson.name !== starterPackageName) { + console.warn( + "Starter seed documents were not removed because the local package does not match the repository starter.", + ); + return; + } + + const projectRoot = await findProjectRoot(); + await rm(new URL("documents/", projectRoot), { recursive: true, force: true }); +} diff --git a/src/commands/starter-download.ts b/src/commands/starter-download.ts deleted file mode 100644 index 800df10..0000000 --- a/src/commands/starter-download.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { mkdir, rm } from "node:fs/promises"; -import { join, resolve, sep } from "node:path"; -import { pathToFileURL } from "node:url"; - -import { createLoginSession, getCredentials } from "../auth"; -import { env } from "../env"; -import { openBrowser } from "../lib/browser"; -import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { exists, readURLFile } from "../lib/file"; -import { installDependencies } from "../lib/packageJson"; -import { - addPreview, - hasPreviewByURL, - removePreviewsByURL, - setSimulatorUrl, -} from "../lib/prismic/clients/core"; -import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; -import { completeOnboardingStepsSilently, getRepository } from "../lib/prismic/clients/repository"; -import { getProfile, type Profile } from "../lib/prismic/clients/user"; -import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; -import { sentryCaptureError } from "../lib/sentry"; -import { - assertStarterRepositoryAccess, - assertStarterRepositoryHasModels, - patchStarterConfig, - resolveStarterArchive, - resolveStarterHostedPreviewURL, - starterLocalPreviewURL, -} from "../lib/starter"; -import { extractZip } from "../lib/zip"; - -const config = { - name: "prismic starter download", - description: "Download and configure the official starter for a Prismic repository.", - options: { - repo: { - type: "string", - short: "r", - description: "Repository name", - required: true, - }, - "no-browser": { - type: "boolean", - description: "Skip opening the browser automatically during login", - }, - }, -} satisfies CommandConfig; - -export default createCommand(config, async ({ values }) => { - const { repo, "no-browser": noBrowser } = values; - const repositoryId = repo.toLowerCase(); - assertRepositoryName(repositoryId); - - const { token, host, profile } = await authenticateStarterDownload(noBrowser); - assertStarterRepositoryAccess(repositoryId, profile); - await downloadStarter(repositoryId, { token, host }); -}); - -const localPreviewConfig = { - name: "Development", - websiteURL: "http://localhost:3000", - resolverPath: "/api/preview", -}; -const simulatorUrl = `${localPreviewConfig.websiteURL}/slice-simulator`; - -async function downloadStarter( - repositoryId: string, - config: { token: string | undefined; host: string }, -): Promise { - const repository = await getRepository({ - repo: repositoryId, - token: config.token, - host: config.host, - }); - const starterArchiveURL = resolveStarterArchive(repository.starter); - const starterHostedPreviewURL = resolveStarterHostedPreviewURL(repository.starter); - - const [customTypes, slices] = await Promise.all([ - getCustomTypes({ - repo: repositoryId, - token: config.token, - host: config.host, - }), - getSlices({ - repo: repositoryId, - token: config.token, - host: config.host, - }), - ]); - assertStarterRepositoryHasModels(repositoryId, customTypes, slices); - - let extractedProject: { destination: string; destinationExisted: boolean } | undefined; - - try { - console.info("Downloading the project..."); - const archive = await readURLFile(starterArchiveURL); - const destination = resolve(process.cwd(), repositoryId); - const destinationExisted = await exists(pathToFileURL(destination)); - await extractZip(new Uint8Array(await archive.arrayBuffer()), destination, { - stripSingleRootDirectory: true, - }); - extractedProject = { destination, destinationExisted }; - await rm(join(destination, "documents"), { recursive: true, force: true }); - await patchStarterConfig(destination, repositoryId, config.host); - - console.info("Installing dependencies..."); - try { - const destinationUrl = pathToFileURL(`${destination}${sep}`); - await installDependencies({ start: destinationUrl, stop: destinationUrl }); - } catch { - console.warn( - "Could not install dependencies automatically. Please install them manually (i.e. `npm install`).", - ); - } - - console.info("Configuring local previews and simulator..."); - const coreConfig = { repo: repositoryId, ...config }; - - let localPreviewConfigured = false; - try { - await removePreviewsByURL([starterHostedPreviewURL], coreConfig); - const hasDevelopmentPreview = await hasPreviewByURL(starterLocalPreviewURL, coreConfig); - if (!hasDevelopmentPreview) { - await addPreview(localPreviewConfig, coreConfig); - } - localPreviewConfigured = true; - } catch (error) { - await sentryCaptureError(error); - const commandArgs = [ - "prismic", - "preview", - "add", - starterLocalPreviewURL, - "--name", - localPreviewConfig.name, - ]; - console.error( - `Could not configure local preview. Please configure it manually (i.e. \`${commandArgs.join(" ")}\`). Continuing.`, - ); - } - - try { - await setSimulatorUrl(simulatorUrl, coreConfig); - } catch (error) { - await sentryCaptureError(error); - const commandArgs = ["prismic", "preview", "set-simulator", simulatorUrl]; - console.error( - `Could not configure local slice simulator. Please configure it manually (i.e. \`${commandArgs.join(" ")}\`). Continuing.`, - ); - } - - await completeOnboardingStepsSilently({ - repo: repositoryId, - ...config, - stepIds: ["instantStart_continueBuildingLocally"], - }); - - let previewInstruction = ""; - if (localPreviewConfigured) { - previewInstruction = `• Preview your pages live at https://${repositoryId}.${config.host}/builder\n`; - } - console.info(` -Your project is ready 🎉 - -Here's what you can do next: - -• Start the development server: - cd ${destination} - npm run dev - -${previewInstruction} -Start building 🚀 -`); - } catch (error) { - if (extractedProject) { - await rm(extractedProject.destination, { recursive: true, force: true }); - if (extractedProject.destinationExisted) { - await mkdir(extractedProject.destination); - } - } - throw error; - } -} - -function assertRepositoryName(repositoryId: string): void { - if (!/^[a-z0-9][a-z0-9-]*$/.test(repositoryId)) { - throw new CommandError(`Invalid repository name: ${repositoryId}`); - } -} - -async function authenticateStarterDownload( - noBrowser: boolean | undefined, -): Promise<{ host: string; token: string | undefined; profile: Profile }> { - const { host, token: initialToken } = await getCredentials(); - let token = initialToken; - let profile: Profile; - - try { - profile = await getProfile({ token, host }); - } catch (error) { - if (!(error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError)) { - throw error; - } - if (env.PRISMIC_TOKEN) { - throw new CommandError( - "PRISMIC_TOKEN is invalid or expired. Unset it to log in with a browser, or replace it with a valid token.", - ); - } - - console.info("Not logged in. Starting login..."); - const { email } = await createLoginSession({ - onReady: (url) => { - if (noBrowser) { - console.info(`Open this URL to log in: ${url}`); - } else { - console.info("Opening browser to complete login..."); - console.info(`If the browser doesn't open, visit: ${url}`); - openBrowser(url); - } - }, - }); - console.info(`Logged in as ${email}`); - - const loggedIn = await getCredentials(); - token = loggedIn.token; - profile = await getProfile({ token, host }); - } - - return { host, token, profile }; -} diff --git a/src/commands/starter.ts b/src/commands/starter.ts deleted file mode 100644 index a1c4813..0000000 --- a/src/commands/starter.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createCommandRouter } from "../lib/command"; -import download from "./starter-download"; - -export default createCommandRouter({ - name: "prismic starter", - description: "Download official Prismic starters.", - commands: { - download: { - handler: download, - description: "Download and configure a starter", - }, - }, -}); diff --git a/src/lib/packageJson.ts b/src/lib/packageJson.ts index d58be0c..1ed9370 100644 --- a/src/lib/packageJson.ts +++ b/src/lib/packageJson.ts @@ -8,6 +8,7 @@ import { exists, findUpward, readJsonFile } from "./file"; import { request } from "./request"; const PackageJsonSchema = z.object({ + name: z.optional(z.string()), dependencies: z.optional(z.record(z.string(), z.string())), devDependencies: z.optional(z.record(z.string(), z.string())), peerDependencies: z.optional(z.record(z.string(), z.string())), @@ -21,10 +22,8 @@ export async function readPackageJson(): Promise { return packageJson; } -export async function findPackageJson( - config: { start?: URL; stop?: URL | string } = {}, -): Promise { - const packageJsonPath = await findUpward("package.json", config); +export async function findPackageJson(): Promise { + const packageJsonPath = await findUpward("package.json"); if (!packageJsonPath) throw new MissingPackageJson(); return packageJsonPath; } @@ -77,10 +76,8 @@ const INSTALL_COMMANDS = { bun: ["bun", "install"], }; -export async function installDependencies( - config: { start?: URL; stop?: URL | string } = {}, -): Promise { - const packageJsonPath = await findPackageJson(config); +export async function installDependencies(): Promise { + const packageJsonPath = await findPackageJson(); const cwd = new URL(".", packageJsonPath); const packageManager = await detectPackageManager(packageJsonPath); const [command, ...args] = INSTALL_COMMANDS[packageManager]; diff --git a/src/lib/starter.ts b/src/lib/starter.ts index aaae68e..0455aa8 100644 --- a/src/lib/starter.ts +++ b/src/lib/starter.ts @@ -1,23 +1,23 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - +import type { ArrayDiff } from "./diff"; import type { Repository } from "./prismic/clients/repository"; -import type { Profile } from "./prismic/clients/user"; import { CommandError } from "./command"; +import { + addPreview, + hasPreviewByURL, + removePreviewsByURL, + setSimulatorUrl, +} from "./prismic/clients/core"; +import { completeOnboardingStepsSilently } from "./prismic/clients/repository"; +import { sentryCaptureError } from "./sentry"; export const starterLocalPreviewURL = "http://localhost:3000/api/preview"; - -export function assertStarterRepositoryAccess(repositoryId: string, profile: Profile): void { - const hasRepositoryAccess = profile.repositories.some( - (repository) => repository.domain === repositoryId, - ); - if (!hasRepositoryAccess) { - throw new CommandError( - `Repository "${repositoryId}" not found in your account. Check the name or request access to the repository.`, - ); - } -} +const starterLocalPreviewConfig = { + name: "Development", + websiteURL: "http://localhost:3000", + resolverPath: "/api/preview", +}; +const starterLocalSimulatorURL = "http://localhost:3000/slice-simulator"; export function assertStarterRepositoryHasModels( repositoryId: string, @@ -31,47 +31,65 @@ export function assertStarterRepositoryHasModels( ); } -export function resolveStarterArchive(starter: Repository["starter"] | undefined): URL { +export function hasUnsafeStarterModelChanges(...diffs: Array>): boolean { + return diffs.some((diff) => diff.update.length > 0 || diff.delete.length > 0); +} + +export function resolveStarterHostedPreviewURL(starter: Repository["starter"] | undefined): string { + assertStarterProvenance(starter); + + return new URL("/api/preview", starter.deploymentUrl).href; +} + +function assertStarterProvenance( + starter: Repository["starter"] | undefined, +): asserts starter is NonNullable { if (starter == null) { throw new CommandError( - `Repository does not support starter download. Use a repository created with Instant Start.`, + "Repository does not have starter provenance. Use a repository created with Instant Start.", ); } - - return new URL(`https://github.com/${starter.id}/archive/${starter.revision}.zip`); } -export function resolveStarterHostedPreviewURL(starter: Repository["starter"] | undefined): string { - if (starter == null) { - throw new CommandError( - `Repository does not support starter download. Use a repository created with Instant Start.`, +export async function completeStarterHandoff( + starter: NonNullable, + config: { repo: string; token: string | undefined; host: string }, +): Promise<{ localPreviewConfigured: boolean }> { + const localPreviewConfigured = await configureStarterPreviews(starter, config); + + try { + await setSimulatorUrl(starterLocalSimulatorURL, config); + } catch (error) { + await sentryCaptureError(error); + console.error( + `Could not configure the local slice simulator. Run \`prismic preview set-simulator ${starterLocalSimulatorURL}\` manually. Continuing.`, ); } - return new URL("/api/preview", starter.deploymentUrl).href; + await completeOnboardingStepsSilently({ + ...config, + stepIds: ["instantStart_continueBuildingLocally"], + }); + + return { localPreviewConfigured }; } -export async function patchStarterConfig( - destination: string, - repositoryId: string, - host: string, -): Promise { - const configPath = join(destination, "prismic.config.json"); - const config: unknown = JSON.parse(await readFile(configPath, "utf8")); - if (!config || typeof config !== "object" || Array.isArray(config)) { - throw new Error("Invalid prismic.config.json."); +async function configureStarterPreviews( + starter: NonNullable, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + try { + await removePreviewsByURL([resolveStarterHostedPreviewURL(starter)], config); + const hasDevelopmentPreview = await hasPreviewByURL(starterLocalPreviewURL, config); + if (!hasDevelopmentPreview) { + await addPreview(starterLocalPreviewConfig, config); + } + return true; + } catch (error) { + await sentryCaptureError(error); + console.error( + `Could not configure the local preview. Run \`prismic preview add ${starterLocalPreviewURL} --name ${starterLocalPreviewConfig.name}\` manually. Continuing.`, + ); + return false; } - - await writeFile( - configPath, - `${JSON.stringify( - { - ...config, - repositoryName: repositoryId, - documentAPIEndpoint: `https://${repositoryId}.${host}/api/v2`, - }, - null, - 2, - )}\n`, - ); } diff --git a/src/lib/zip.ts b/src/lib/zip.ts deleted file mode 100644 index 443565e..0000000 --- a/src/lib/zip.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { unzipSync } from "fflate"; -import { mkdir, mkdtemp, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join, win32 } from "node:path"; - -export async function extractZip( - data: Uint8Array, - destination: string, - options: { stripSingleRootDirectory?: boolean } = {}, -): Promise { - const destinationExisted = await assertEmptyOrMissingDirectory(destination); - - const parent = dirname(destination); - await mkdir(parent, { recursive: true }); - const temporaryDirectory = await mkdtemp(join(parent, `.${basename(destination)}-`)); - - try { - const files = unzipSync(data); - const entries = Object.entries(files).map(([entryName, contents]) => ({ - entryName, - contents, - relativePath: normalizeEntryPath(entryName), - })); - const rootDirectory = options.stripSingleRootDirectory - ? getSingleRootDirectory(entries) - : undefined; - - for (const { entryName, contents, relativePath: entryPath } of entries) { - const relativePath = rootDirectory - ? entryPath.slice(rootDirectory.length).replace(/^\/+/, "") - : entryPath; - if (!relativePath) continue; - - const path = join(temporaryDirectory, relativePath); - if (entryName.endsWith("/") || entryName.endsWith("\\")) { - await mkdir(path, { recursive: true }); - } else { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, contents); - } - } - - await rm(destination, { recursive: true, force: true }); - await rename(temporaryDirectory, destination); - } catch (error) { - await rm(temporaryDirectory, { recursive: true, force: true }); - if (destinationExisted) { - await mkdir(destination, { recursive: true }); - } - throw error; - } -} - -function getSingleRootDirectory(entries: { entryName: string; relativePath: string }[]): string { - const rootDirectories = new Set( - entries - .map(({ relativePath }) => relativePath.split("/")[0]) - .filter((rootDirectory) => rootDirectory), - ); - if (rootDirectories.size !== 1) { - throw new Error("ZIP archive does not contain a single root directory."); - } - - const [rootDirectory] = rootDirectories; - const hasInvalidRootEntry = entries.some( - ({ entryName, relativePath }) => - (relativePath === rootDirectory && !entryName.endsWith("/") && !entryName.endsWith("\\")) || - (relativePath !== rootDirectory && !relativePath.startsWith(`${rootDirectory}/`)), - ); - if (!rootDirectory || hasInvalidRootEntry) { - throw new Error("ZIP archive does not contain a single root directory."); - } - - return rootDirectory; -} - -async function assertEmptyOrMissingDirectory(destination: string): Promise { - try { - const destinationStat = await stat(destination); - if (!destinationStat.isDirectory()) { - throw new Error(`Destination already exists and is not a directory: ${destination}`); - } - if ((await readdir(destination)).length > 0) { - throw new Error(`Destination directory is not empty: ${destination}`); - } - return true; - } catch (error) { - if (isMissingFileError(error)) return false; - throw error; - } -} - -function normalizeEntryPath(entryName: string): string { - const normalized = entryName.replaceAll("\\", "/"); - if (isAbsolute(normalized) || win32.isAbsolute(normalized)) { - throw new Error(`ZIP entry has an absolute path: ${entryName}`); - } - - const segments = normalized.split("/").filter((segment) => segment && segment !== "."); - if (segments.includes("..")) { - throw new Error(`ZIP entry escapes the destination: ${entryName}`); - } - - return segments.join("/"); -} - -function isMissingFileError(error: unknown): boolean { - return error instanceof Error && "code" in error && error.code === "ENOENT"; -} diff --git a/test/index.test.ts b/test/index.test.ts index 125532e..193cd4a 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -7,6 +7,7 @@ it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("", ["--help"]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("prismic [options]"); + expect(stdout).not.toContain("starter"); }); it("prints help text by default", async ({ expect, prismic }) => { diff --git a/test/init.test.ts b/test/init.test.ts index 78da6ac..8ebb7bf 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -16,10 +16,10 @@ it("supports --help", async ({ expect, prismic }) => { expect(stdout).toContain("prismic init [options]"); }); -it("fails if prismic.config.json already exists", async ({ expect, prismic }) => { - const { exitCode, stderr } = await prismic("init", ["--repo", "test"]); +it("fails if prismic.config.json already exists without --repo", async ({ expect, prismic }) => { + const { exitCode, stderr } = await prismic("init"); expect(exitCode).toBe(1); - expect(stderr).toContain("already initialized"); + expect(stderr).toContain("init --repo"); }); it("creates a repo if --repo is not provided and no legacy config exists", async ({ @@ -104,6 +104,27 @@ it("initializes a project with --repo when logged in", async ({ expect(config.repositoryName).toBe(repo); }, 60_000); +it("reconnects an existing project with --repo", async ({ expect, project, prismic, repo }) => { + await writeFile( + new URL("prismic.config.json", project), + JSON.stringify({ + repositoryName: "starter-placeholder", + libraries: ["./src/slices"], + routes: [{ type: "page", path: "/:uid" }], + }), + ); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode, stderr).toBe(0); + + const config = JSON.parse(await readFile(new URL("prismic.config.json", project), "utf-8")); + expect(config).toMatchObject({ + repositoryName: repo, + libraries: ["./src/slices"], + routes: [{ type: "page", path: "/:uid" }], + }); +}, 60_000); + it("skips framework scaffolding with --no-setup", async ({ expect, project, prismic, repo }) => { await rm(new URL("prismic.config.json", project)); diff --git a/test/starter-download.test.ts b/test/starter-download.test.ts deleted file mode 100644 index f7d9df1..0000000 --- a/test/starter-download.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { zipSync } from "fflate"; -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; -import { afterEach, describe, expect, it as unitTest, onTestFinished, vi } from "vitest"; - -import { readURLFile } from "../src/lib/file"; -import { findPackageJson } from "../src/lib/packageJson"; -import { addPreview, hasPreviewByURL, removePreviewsByURL } from "../src/lib/prismic/clients/core"; -import { - assertStarterRepositoryAccess, - assertStarterRepositoryHasModels, - patchStarterConfig, - resolveStarterArchive, - resolveStarterHostedPreviewURL, - starterLocalPreviewURL, -} from "../src/lib/starter"; -import { extractZip } from "../src/lib/zip"; -import { captureOutput, it } from "./it"; - -it("supports starter --help", async ({ expect, prismic }) => { - const { stdout, exitCode } = await prismic("starter", ["--help"]); - expect(exitCode).toBe(0); - expect(stdout).toContain("prismic starter "); - expect(stdout).toContain("download"); -}); - -it("supports starter download --help", async ({ expect, prismic }) => { - const { stdout, exitCode } = await prismic("starter", ["download", "--help"]); - expect(exitCode).toBe(0); - expect(stdout).toContain("prismic starter download [options]"); - expect(stdout).toContain("--repo string"); - expect(stdout).toContain("(required)"); -}); - -it("requires --repo", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("starter", ["download"]); - expect(exitCode).toBe(1); - expect(stderr).toContain("Missing required option: --repo"); -}); - -it("rejects an unknown starter subcommand", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("starter", ["unknown"]); - expect(exitCode).toBe(1); - expect(stderr).toContain("Unknown command: unknown"); -}); - -it("rejects extra download arguments", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("starter", ["download", "extra", "--repo", "my-repo"]); - expect(exitCode).toBe(1); - expect(stderr).toContain("extra"); -}); - -it("rejects init-only options", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("starter", [ - "download", - "--repo", - "my-repo", - "--lang", - "en-us", - ]); - expect(exitCode).toBe(1); - expect(stderr).toContain("--lang"); -}); - -it("validates the repository name", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("starter", [ - "download", - "--repo", - "invalid_repository", - ]); - expect(exitCode).toBe(1); - expect(stderr).toContain("Invalid repository name"); -}); - -it("validates repository access", async ({ expect, login, prismic }) => { - await login(); - const { stderr, exitCode } = await prismic("starter", [ - "download", - "--repo", - "missing-repository", - ]); - expect(exitCode).toBe(1); - expect(stderr).toContain('Repository "missing-repository" not found in your account'); -}); - -it("uses the browser login flow", async ({ expect, logout, prismic, repo }) => { - await logout(); - const proc = prismic("starter", ["download", "--repo", repo, "--no-browser"]); - const output = captureOutput(proc); - - await expect.poll(output, { timeout: 15_000 }).toMatch(/port=(\d+)/); - proc.kill(); -}); - -it("lists starter as a top-level command", async ({ expect, prismic }) => { - const { stdout, exitCode } = await prismic("", ["--help"]); - expect(exitCode).toBe(0); - expect(stdout).toContain("starter"); - expect(stdout).not.toContain("instant-start"); -}); - -describe.sequential("Starter download", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - unitTest("builds the archive URL from repository starter metadata", () => { - expect( - resolveStarterArchive({ - id: "prismicio/custom-starter", - revision: "starter-release", - framework: "custom", - deploymentUrl: "https://starter.example.com/", - }).toString(), - ).toBe("https://github.com/prismicio/custom-starter/archive/starter-release.zip"); - }); - - unitTest("rejects repositories without starter provenance", () => { - expect(() => resolveStarterArchive(null)).toThrow( - "Repository does not support starter download.", - ); - expect(() => resolveStarterArchive(undefined)).toThrow( - "Repository does not support starter download.", - ); - }); - - unitTest("builds the hosted preview URL from starter metadata", () => { - expect( - resolveStarterHostedPreviewURL({ - id: "prismicio/custom-starter", - revision: "starter-release", - framework: "custom", - deploymentUrl: "https://starter.example.com/", - }), - ).toBe("https://starter.example.com/api/preview"); - }); - - unitTest("validates repository access from the authenticated profile", () => { - const profile = { - email: "user@example.com", - shortId: "user", - intercomHash: "hash", - repositories: [{ domain: "my-repo" }], - }; - - expect(() => assertStarterRepositoryAccess("my-repo", profile)).not.toThrow(); - expect(() => assertStarterRepositoryAccess("other-repo", profile)).toThrow( - 'Repository "other-repo" not found in your account.', - ); - }); - - unitTest("rejects repositories without starter models", () => { - expect(() => assertStarterRepositoryHasModels("my-repo", [], [])).toThrow( - 'Repository "my-repo" has no starter models.', - ); - expect(() => assertStarterRepositoryHasModels("my-repo", [{}], [])).not.toThrow(); - expect(() => assertStarterRepositoryHasModels("my-repo", [], [{}])).not.toThrow(); - }); - - unitTest("patches repository settings while preserving starter config", async () => { - const destination = await makeTemporaryDirectory(); - await writeFile( - join(destination, "prismic.config.json"), - JSON.stringify({ - repositoryName: "starter", - documentAPIEndpoint: "https://starter.cdn.prismic.io/api/v2", - libraries: ["./src/slices"], - routes: [{ type: "page", path: "/:uid" }], - }), - ); - - await patchStarterConfig(destination, "my-repo", "prismic.io"); - - await expect( - readFile(join(destination, "prismic.config.json"), "utf8").then(JSON.parse), - ).resolves.toEqual({ - repositoryName: "my-repo", - documentAPIEndpoint: "https://my-repo.prismic.io/api/v2", - libraries: ["./src/slices"], - routes: [{ type: "page", path: "/:uid" }], - }); - }); - - unitTest("reports archive download failures", async () => { - const starterArchiveURL = resolveStarterArchive({ - id: "prismicio/next-instant-start", - revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", - framework: "next", - deploymentUrl: "https://next-instant-start-2a3svap7o-prismic.vercel.app/", - }); - - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("Not found", { status: 404 })), - ); - - await expect(readURLFile(starterArchiveURL)).rejects.toThrow( - `Failed to download file from "${starterArchiveURL.toString()}" (HTTP 404).`, - ); - }); - - unitTest("finds an existing local development preview", async () => { - const fetchMock = vi.fn().mockResolvedValue( - jsonResponse({ - results: [ - { - id: "development-preview", - label: "Development", - url: starterLocalPreviewURL, - }, - ], - }), - ); - vi.stubGlobal("fetch", fetchMock); - - await expect( - hasPreviewByURL(starterLocalPreviewURL, { - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }), - ).resolves.toBe(true); - }); - - unitTest("removes only the hosted starter preview", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - jsonResponse({ - results: [ - { - id: "starter-preview", - label: "Production", - url: "https://starter.example.com/api/preview", - }, - { - id: "custom-preview", - label: "Custom", - url: "https://custom.example.com/api/preview", - }, - ], - }), - ) - .mockResolvedValueOnce(new Response(null, { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - - await removePreviewsByURL(["https://starter.example.com/api/preview"], { - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }); - - expect(fetchMock).toHaveBeenCalledTimes(2); - const [url, init] = fetchMock.mock.calls[1]; - expect(url.toString()).toContain("/previews/delete/starter-preview"); - expect(init?.method).toBe("POST"); - }); - - unitTest("adds the local development preview", async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - - await addPreview( - { - name: "Development", - websiteURL: "http://localhost:3000", - resolverPath: "/api/preview", - }, - { - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }, - ); - - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = fetchMock.mock.calls[0]; - expect(url.toString()).toContain("/previews/new"); - expect(init?.method).toBe("POST"); - expect(init?.body).toBe( - JSON.stringify({ - name: "Development", - websiteURL: "http://localhost:3000", - resolverPath: "/api/preview", - }), - ); - }); -}); - -describe("Package installation", () => { - unitTest("does not find package.json above the extracted project", async () => { - const root = await makeTemporaryDirectory(); - const destination = join(root, "my-repo"); - await mkdir(destination); - await writeFile(join(root, "package.json"), "{}"); - const destinationUrl = pathToFileURL(`${destination}/`); - - await expect(findPackageJson({ start: destinationUrl, stop: destinationUrl })).rejects.toThrow( - "Could not find a package.json file", - ); - }); -}); - -describe("ZIP extraction", () => { - unitTest("extracts nested files into an empty destination", async () => { - const root = await makeTemporaryDirectory(); - const destination = join(root, "my-repo"); - await mkdir(destination); - - await extractZip( - zipSync({ - "package.json": new TextEncoder().encode('{"name":"starter"}'), - "src/app/page.tsx": new TextEncoder().encode("export default Page"), - }), - destination, - ); - - await expect(readFile(join(destination, "package.json"), "utf8")).resolves.toBe( - '{"name":"starter"}', - ); - await expect(readFile(join(destination, "src/app/page.tsx"), "utf8")).resolves.toBe( - "export default Page", - ); - }); - - unitTest("strips a single GitHub archive root when requested", async () => { - const root = await makeTemporaryDirectory(); - const destination = join(root, "my-repo"); - - await extractZip( - zipSync({ - "starter-commit/package.json": new TextEncoder().encode('{"name":"starter"}'), - "starter-commit/src/app/page.tsx": new TextEncoder().encode("export default Page"), - }), - destination, - { stripSingleRootDirectory: true }, - ); - - await expect(readFile(join(destination, "package.json"), "utf8")).resolves.toBe( - '{"name":"starter"}', - ); - await expect(access(join(destination, "starter-commit"))).rejects.toThrow(); - }); - - unitTest("accepts an explicit GitHub archive root directory", async () => { - const root = await makeTemporaryDirectory(); - const destination = join(root, "my-repo"); - - await extractZip( - zipSync({ - "starter-commit/": new Uint8Array(), - "starter-commit/package.json": new TextEncoder().encode('{"name":"starter"}'), - }), - destination, - { stripSingleRootDirectory: true }, - ); - - await expect(readFile(join(destination, "package.json"), "utf8")).resolves.toBe( - '{"name":"starter"}', - ); - }); - - unitTest("rejects a top-level file masquerading as the archive root", async () => { - const root = await makeTemporaryDirectory(); - const destination = join(root, "my-repo"); - - await expect( - extractZip( - zipSync({ - "starter-commit": new TextEncoder().encode("not a directory"), - "starter-commit/package.json": new TextEncoder().encode("{}"), - }), - destination, - { stripSingleRootDirectory: true }, - ), - ).rejects.toThrow("ZIP archive does not contain a single root directory"); - await expect(access(destination)).rejects.toThrow(); - }); - - unitTest("does not overwrite a non-empty destination", async () => { - const root = await makeTemporaryDirectory(); - const destination = join(root, "my-repo"); - await mkdir(destination); - await writeFile(join(destination, "keep.txt"), "keep"); - - await expect( - extractZip(zipSync({ "package.json": new TextEncoder().encode("{}") }), destination), - ).rejects.toThrow("Destination directory is not empty"); - await expect(readFile(join(destination, "keep.txt"), "utf8")).resolves.toBe("keep"); - }); - - unitTest("rejects path traversal without leaving partial output", async () => { - const root = await makeTemporaryDirectory(); - const destination = join(root, "my-repo"); - - await expect( - extractZip( - zipSync({ - "package.json": new TextEncoder().encode("{}"), - "../outside.txt": new TextEncoder().encode("outside"), - }), - destination, - ), - ).rejects.toThrow("ZIP entry escapes the destination"); - await expect(access(destination)).rejects.toThrow(); - await expect(access(join(root, "outside.txt"))).rejects.toThrow(); - }); -}); - -function jsonResponse(value: unknown): Response { - return new Response(JSON.stringify(value), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); -} - -async function makeTemporaryDirectory(): Promise { - const directory = await mkdtemp(join(tmpdir(), "prismic-starter-download-")); - onTestFinished(() => rm(directory, { recursive: true, force: true })); - return directory; -} diff --git a/test/starter-handoff.test.ts b/test/starter-handoff.test.ts new file mode 100644 index 0000000..9838db8 --- /dev/null +++ b/test/starter-handoff.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + assertStarterRepositoryHasModels, + completeStarterHandoff, + hasUnsafeStarterModelChanges, + resolveStarterHostedPreviewURL, +} from "../src/lib/starter"; + +describe("starter handoff", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("builds the hosted preview URL from starter metadata", () => { + expect( + resolveStarterHostedPreviewURL({ + id: "prismicio/custom-starter", + revision: "starter-release", + framework: "custom", + deploymentUrl: "https://starter.example.com/", + }), + ).toBe("https://starter.example.com/api/preview"); + }); + + it("rejects repositories without starter models", () => { + expect(() => assertStarterRepositoryHasModels("my-repo", [], [])).toThrow( + 'Repository "my-repo" has no starter models.', + ); + expect(() => assertStarterRepositoryHasModels("my-repo", [{}], [])).not.toThrow(); + expect(() => assertStarterRepositoryHasModels("my-repo", [], [{}])).not.toThrow(); + }); + + it("only treats updates and deletions as unsafe automatic syncs", () => { + expect( + hasUnsafeStarterModelChanges( + { insert: [], update: [], delete: [] }, + { insert: [{}], update: [], delete: [] }, + ), + ).toBe(false); + expect( + hasUnsafeStarterModelChanges({ + insert: [], + update: [{}], + delete: [], + }), + ).toBe(true); + expect( + hasUnsafeStarterModelChanges({ + insert: [], + update: [], + delete: [{}], + }), + ).toBe(true); + }); + + it("completes previews, simulator, and onboarding", async () => { + let previewReadCount = 0; + const fetchMock = vi.fn(async (input, init) => { + const url = input.toString(); + if (url.includes("/core/repository/preview_configs")) { + previewReadCount += 1; + return jsonResponse({ + results: + previewReadCount === 1 + ? [ + { + id: "hosted-preview", + label: "Production", + url: "https://starter.example.com/api/preview", + }, + ] + : [], + }); + } + if (url.includes("/onboarding")) { + return jsonResponse({ + completedSteps: init?.method === "PATCH" ? ["instantStart_continueBuildingLocally"] : [], + }); + } + return new Response(null, { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect( + completeStarterHandoff( + { + id: "prismicio/custom-starter", + revision: "starter-release", + framework: "custom", + deploymentUrl: "https://starter.example.com/", + }, + { + repo: "my-repo", + token: "test-token", + host: "prismic.io", + }, + ), + ).resolves.toEqual({ localPreviewConfigured: true }); + + expect(fetchMock).toHaveBeenCalledTimes(7); + expect(fetchMock.mock.calls[3][0].toString()).toContain("/previews/new"); + expect(fetchMock.mock.calls[4][0].toString()).toContain("/core/repository"); + expect(fetchMock.mock.calls[6][0].toString()).toContain( + "/onboarding/instantStart_continueBuildingLocally/toggle", + ); + }); +}); + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} From 6fd2b04cd97029113c345f0b2647798aeb83dce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:20:27 +0100 Subject: [PATCH 16/20] fix(core): restore preview URL helpers for starter handoff These exports were removed during the starter download cleanup but are still required by completeStarterHandoff, which broke TypeScript and runtime preview setup. Co-authored-by: Cursor --- src/lib/prismic/clients/core.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/lib/prismic/clients/core.ts b/src/lib/prismic/clients/core.ts index f9a73d4..25e2ca9 100644 --- a/src/lib/prismic/clients/core.ts +++ b/src/lib/prismic/clients/core.ts @@ -62,6 +62,24 @@ export async function removePreview(id: string, config: CoreConfig): Promise { + const previews = await getPreviews(config); + return previews.some((preview) => preview.url === url); +} + +export async function removePreviewsByURL( + urls: Iterable, + config: CoreConfig, +): Promise { + const previews = await getPreviews(config); + const urlSet = new Set(urls); + await Promise.all( + previews + .filter((preview) => urlSet.has(preview.url)) + .map((preview) => removePreview(preview.id, config)), + ); +} + const EnvironmentSchema = z.object({ kind: z.enum(["prod", "stage", "dev"]), name: z.string(), From 1db41c11f5d985c0e13ea043df759d6585f0800c Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Sun, 2 Aug 2026 22:29:46 -1000 Subject: [PATCH 17/20] refactor(init): reorganize starter handoff per file organization conventions (#250) Co-Authored-By: Claude Fable 5 --- src/commands/init.ts | 75 ++++++++++++++-- src/lib/prismic/clients/core.ts | 18 ---- src/lib/starter.ts | 95 -------------------- test/init.test.ts | 148 +++++++++++++++++++++++++++++++- test/prismic.ts | 28 ++++++ test/repository-client.test.ts | 73 ---------------- test/starter-handoff.test.ts | 115 ------------------------- 7 files changed, 241 insertions(+), 311 deletions(-) delete mode 100644 src/lib/starter.ts delete mode 100644 test/repository-client.test.ts delete mode 100644 test/starter-handoff.test.ts diff --git a/src/commands/init.ts b/src/commands/init.ts index 51e2441..5525032 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -9,16 +9,22 @@ import { openBrowser } from "../lib/browser"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { diffArrays } from "../lib/diff"; import { installDependencies, readPackageJson, removeDependencies } from "../lib/packageJson"; +import { + addPreview, + getPreviews, + removePreview, + setSimulatorUrl, +} from "../lib/prismic/clients/core"; import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; -import { getRepository, type Repository } from "../lib/prismic/clients/repository"; +import { + completeOnboardingStepsSilently, + getRepository, + type Repository, +} from "../lib/prismic/clients/repository"; import { getProfile } from "../lib/prismic/clients/user"; import { canonicalizeCustomType, canonicalizeSlice } from "../lib/prismic/models"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; -import { - assertStarterRepositoryHasModels, - completeStarterHandoff, - hasUnsafeStarterModelChanges, -} from "../lib/starter"; +import { sentryCaptureError } from "../lib/sentry"; import { type Config, createConfig, @@ -227,12 +233,17 @@ export default createCommand(config, async ({ values }) => { }); if (isExistingProjectHandoff && connectedRepository?.starter) { - assertStarterRepositoryHasModels(repo, remoteCustomTypes, remoteSlices); + if (remoteCustomTypes.length === 0 && remoteSlices.length === 0) { + throw new CommandError( + `Repository "${repo}" has no starter models. Use a repository created from the starter in the Prismic dashboard.`, + ); + } await removeStarterDocuments(connectedRepository.starter); } const hasUnsafeModelChanges = - isExistingProjectHandoff && hasUnsafeStarterModelChanges(customTypeOps, sliceOps); + isExistingProjectHandoff && + [customTypeOps, sliceOps].some((ops) => ops.update.length > 0 || ops.delete.length > 0); if (!hasUnsafeModelChanges) { for (const slice of sliceOps.update) { @@ -290,3 +301,51 @@ async function removeStarterDocuments(starter: NonNullable, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + try { + const hostedPreviewURL = new URL("/api/preview", starter.deploymentUrl).href; + const previews = await getPreviews(config); + await Promise.all( + previews + .filter((preview) => preview.url === hostedPreviewURL) + .map((preview) => removePreview(preview.id, config)), + ); + const hasDevelopmentPreview = previews.some( + (preview) => preview.url === starterLocalPreviewURL, + ); + if (!hasDevelopmentPreview) { + await addPreview(starterLocalPreviewConfig, config); + } + } catch (error) { + await sentryCaptureError(error); + console.error( + `Could not configure the local preview. Run \`prismic preview add ${starterLocalPreviewURL} --name ${starterLocalPreviewConfig.name}\` manually. Continuing.`, + ); + } + + try { + await setSimulatorUrl(starterLocalSimulatorURL, config); + } catch (error) { + await sentryCaptureError(error); + console.error( + `Could not configure the local slice simulator. Run \`prismic preview set-simulator ${starterLocalSimulatorURL}\` manually. Continuing.`, + ); + } + + await completeOnboardingStepsSilently({ + ...config, + stepIds: ["instantStart_continueBuildingLocally"], + }); +} diff --git a/src/lib/prismic/clients/core.ts b/src/lib/prismic/clients/core.ts index 25e2ca9..f9a73d4 100644 --- a/src/lib/prismic/clients/core.ts +++ b/src/lib/prismic/clients/core.ts @@ -62,24 +62,6 @@ export async function removePreview(id: string, config: CoreConfig): Promise { - const previews = await getPreviews(config); - return previews.some((preview) => preview.url === url); -} - -export async function removePreviewsByURL( - urls: Iterable, - config: CoreConfig, -): Promise { - const previews = await getPreviews(config); - const urlSet = new Set(urls); - await Promise.all( - previews - .filter((preview) => urlSet.has(preview.url)) - .map((preview) => removePreview(preview.id, config)), - ); -} - const EnvironmentSchema = z.object({ kind: z.enum(["prod", "stage", "dev"]), name: z.string(), diff --git a/src/lib/starter.ts b/src/lib/starter.ts deleted file mode 100644 index 0455aa8..0000000 --- a/src/lib/starter.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { ArrayDiff } from "./diff"; -import type { Repository } from "./prismic/clients/repository"; - -import { CommandError } from "./command"; -import { - addPreview, - hasPreviewByURL, - removePreviewsByURL, - setSimulatorUrl, -} from "./prismic/clients/core"; -import { completeOnboardingStepsSilently } from "./prismic/clients/repository"; -import { sentryCaptureError } from "./sentry"; - -export const starterLocalPreviewURL = "http://localhost:3000/api/preview"; -const starterLocalPreviewConfig = { - name: "Development", - websiteURL: "http://localhost:3000", - resolverPath: "/api/preview", -}; -const starterLocalSimulatorURL = "http://localhost:3000/slice-simulator"; - -export function assertStarterRepositoryHasModels( - repositoryId: string, - customTypes: unknown[], - slices: unknown[], -): void { - if (customTypes.length > 0 || slices.length > 0) return; - - throw new CommandError( - `Repository "${repositoryId}" has no starter models. Use a repository created from the starter in the Prismic dashboard.`, - ); -} - -export function hasUnsafeStarterModelChanges(...diffs: Array>): boolean { - return diffs.some((diff) => diff.update.length > 0 || diff.delete.length > 0); -} - -export function resolveStarterHostedPreviewURL(starter: Repository["starter"] | undefined): string { - assertStarterProvenance(starter); - - return new URL("/api/preview", starter.deploymentUrl).href; -} - -function assertStarterProvenance( - starter: Repository["starter"] | undefined, -): asserts starter is NonNullable { - if (starter == null) { - throw new CommandError( - "Repository does not have starter provenance. Use a repository created with Instant Start.", - ); - } -} - -export async function completeStarterHandoff( - starter: NonNullable, - config: { repo: string; token: string | undefined; host: string }, -): Promise<{ localPreviewConfigured: boolean }> { - const localPreviewConfigured = await configureStarterPreviews(starter, config); - - try { - await setSimulatorUrl(starterLocalSimulatorURL, config); - } catch (error) { - await sentryCaptureError(error); - console.error( - `Could not configure the local slice simulator. Run \`prismic preview set-simulator ${starterLocalSimulatorURL}\` manually. Continuing.`, - ); - } - - await completeOnboardingStepsSilently({ - ...config, - stepIds: ["instantStart_continueBuildingLocally"], - }); - - return { localPreviewConfigured }; -} - -async function configureStarterPreviews( - starter: NonNullable, - config: { repo: string; token: string | undefined; host: string }, -): Promise { - try { - await removePreviewsByURL([resolveStarterHostedPreviewURL(starter)], config); - const hasDevelopmentPreview = await hasPreviewByURL(starterLocalPreviewURL, config); - if (!hasDevelopmentPreview) { - await addPreview(starterLocalPreviewConfig, config); - } - return true; - } catch (error) { - await sentryCaptureError(error); - console.error( - `Could not configure the local preview. Run \`prismic preview add ${starterLocalPreviewURL} --name ${starterLocalPreviewConfig.name}\` manually. Continuing.`, - ); - return false; - } -} diff --git a/test/init.test.ts b/test/init.test.ts index 8ebb7bf..e56bccd 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -1,12 +1,20 @@ -import { access, readFile, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { describe } from "vitest"; -import { captureOutput, it } from "./it"; +import { buildCustomType, captureOutput, it, readLocalCustomType, writeLocalCustomType } from "./it"; import { addPreview, + createInstantStartRepository, createRepository, + deleteCustomType, deleteRepository, + deleteSlice, + getCustomTypes, + getOnboardingCompletedSteps, getPreviews, getRepository, + getSlices, + insertCustomType, setSimulatorUrl, } from "./prismic"; @@ -239,3 +247,139 @@ it("installs dependencies", { timeout: 30_000 }, async ({ expect, project, prism // Verify the stubbed npm was invoked (it creates package-lock.json) await expect(access(new URL("package-lock.json", project))).resolves.toBeUndefined(); }); + +it("warns and keeps local models when reconnecting with model differences", async ({ + expect, + project, + prismic, + repo, +}) => { + // A local-only model makes the local/remote diff contain a deletion, which + // blocks the automatic sync during an existing-project reconnect. + const localOnly = buildCustomType(); + await writeLocalCustomType(project, localOnly); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Choose the source of truth"); + + const localModel = await readLocalCustomType(project, localOnly.id); + expect(localModel).toEqual(localOnly); +}, 60_000); + +describe("with an isolated repository", () => { + it.scoped({ isolateRepo: true }); + + it("warns and keeps local models when reconnecting with a modified model", async ({ + expect, + project, + prismic, + repo, + token, + host, + }) => { + const model = buildCustomType(); + await insertCustomType(model, { repo, token, host }); + const modified = { ...model, label: `${model.label}Modified` }; + await writeLocalCustomType(project, modified); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Choose the source of truth"); + + const localModel = await readLocalCustomType(project, model.id); + expect(localModel).toEqual(modified); + }, 60_000); +}); + +it.skip("completes the handoff for a starter project", async ({ + expect, + project, + prismic, + token, + host, + password, +}) => { + const repo = await createInstantStartRepository({ token, host }); + try { + await writeFile( + new URL("package.json", project), + JSON.stringify({ name: "next-instant-start", dependencies: { next: "latest" } }), + ); + await mkdir(new URL("documents/", project), { recursive: true }); + await writeFile(new URL("documents/homepage.json", project), "{}"); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode, stderr).toBe(0); + + const config = JSON.parse(await readFile(new URL("prismic.config.json", project), "utf-8")); + expect(config.repositoryName).toBe(repo); + + // Seed documents are removed. + await expect(access(new URL("documents/", project))).rejects.toThrow(); + + // The hosted preview is replaced by the local Development preview. + const previews = await getPreviews({ repo, token, host }); + const previewLabels = previews.map((preview) => preview.label); + expect(previewLabels).toContain("Development"); + expect(previewLabels).not.toContain("Production"); + + const repository = await getRepository({ repo, token, host }); + expect(repository.simulatorUrl).toBe("http://localhost:3000/slice-simulator"); + + const completedSteps = await getOnboardingCompletedSteps({ repo, token, host }); + expect(completedSteps).toContain("instantStart_continueBuildingLocally"); + } finally { + await deleteRepository(repo, { token, password, host }); + } +}, 120_000); + +it.skip("keeps seed documents when the local package does not match the starter", async ({ + expect, + project, + prismic, + token, + host, + password, +}) => { + const repo = await createInstantStartRepository({ token, host }); + try { + // The fixture package.json has no name, so it cannot match the starter. + await mkdir(new URL("documents/", project), { recursive: true }); + await writeFile(new URL("documents/homepage.json", project), "{}"); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Starter seed documents were not removed"); + + await expect(access(new URL("documents/", project))).resolves.toBeUndefined(); + } finally { + await deleteRepository(repo, { token, password, host }); + } +}, 120_000); + +it.skip("fails when the starter repository has no models", async ({ + expect, + prismic, + token, + host, + password, +}) => { + const repo = await createInstantStartRepository({ token, host }); + try { + const [customTypes, slices] = await Promise.all([ + getCustomTypes({ repo, token, host }), + getSlices({ repo, token, host }), + ]); + await Promise.all([ + ...customTypes.map((customType) => deleteCustomType(customType.id, { repo, token, host })), + ...slices.map((slice) => deleteSlice(slice.id, { repo, token, host })), + ]); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("has no starter models"); + } finally { + await deleteRepository(repo, { token, password, host }); + } +}, 120_000); diff --git a/test/prismic.ts b/test/prismic.ts index e0dea64..ed421ef 100644 --- a/test/prismic.ts +++ b/test/prismic.ts @@ -33,6 +33,34 @@ export async function createRepository(domain: string, config: AuthConfig): Prom throw new Error(`Failed to create repository ${domain}: ${res.status} ${await res.text()}`); } +export async function createInstantStartRepository(config: AuthConfig): Promise { + const host = config.host ?? DEFAULT_HOST; + const url = new URL("website-generator/instant-start", `https://api.internal.${host}/`); + const res = await fetch(url, { + method: "POST", + headers: { Authorization: `Bearer ${config.token}` }, + }); + if (!res.ok) + throw new Error( + `Failed to create Instant Start repository: ${res.status} ${await res.text()}`, + ); + const data = await res.json(); + return data.repositoryId; +} + +export async function getOnboardingCompletedSteps(config: RepoConfig): Promise { + const host = config.host ?? DEFAULT_HOST; + const url = new URL("repository/onboarding", `https://api.internal.${host}/`); + url.searchParams.set("repository", config.repo); + const res = await fetch(url, { + headers: { Authorization: `Bearer ${config.token}`, repository: config.repo }, + }); + if (!res.ok) + throw new Error(`Failed to get onboarding state: ${res.status} ${await res.text()}`); + const data = await res.json(); + return data.completedSteps; +} + export async function deleteRepository( domain: string, config: AuthConfig & { password: string }, diff --git a/test/repository-client.test.ts b/test/repository-client.test.ts deleted file mode 100644 index 8c4cf4e..0000000 --- a/test/repository-client.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { getRepository } from "../src/lib/prismic/clients/repository"; - -describe("getRepository starter parsing", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - it("defaults missing starter metadata to null", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ quotas: { sliceMachineEnabled: true } }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - await expect( - getRepository({ - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }), - ).resolves.toEqual({ - starter: null, - quotas: { sliceMachineEnabled: true }, - }); - }); - - it("preserves explicit null starter metadata", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ starter: null }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - await expect( - getRepository({ - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }), - ).resolves.toEqual({ - starter: null, - }); - }); - - it("preserves complete starter provenance", async () => { - const starter = { - id: "prismicio/next-instant-start", - revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", - framework: "next", - deploymentUrl: "https://next-instant-start-2a3svap7o-prismic.vercel.app/", - }; - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ starter }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - await expect( - getRepository({ - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }), - ).resolves.toEqual({ starter }); - }); -}); diff --git a/test/starter-handoff.test.ts b/test/starter-handoff.test.ts deleted file mode 100644 index 9838db8..0000000 --- a/test/starter-handoff.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { - assertStarterRepositoryHasModels, - completeStarterHandoff, - hasUnsafeStarterModelChanges, - resolveStarterHostedPreviewURL, -} from "../src/lib/starter"; - -describe("starter handoff", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("builds the hosted preview URL from starter metadata", () => { - expect( - resolveStarterHostedPreviewURL({ - id: "prismicio/custom-starter", - revision: "starter-release", - framework: "custom", - deploymentUrl: "https://starter.example.com/", - }), - ).toBe("https://starter.example.com/api/preview"); - }); - - it("rejects repositories without starter models", () => { - expect(() => assertStarterRepositoryHasModels("my-repo", [], [])).toThrow( - 'Repository "my-repo" has no starter models.', - ); - expect(() => assertStarterRepositoryHasModels("my-repo", [{}], [])).not.toThrow(); - expect(() => assertStarterRepositoryHasModels("my-repo", [], [{}])).not.toThrow(); - }); - - it("only treats updates and deletions as unsafe automatic syncs", () => { - expect( - hasUnsafeStarterModelChanges( - { insert: [], update: [], delete: [] }, - { insert: [{}], update: [], delete: [] }, - ), - ).toBe(false); - expect( - hasUnsafeStarterModelChanges({ - insert: [], - update: [{}], - delete: [], - }), - ).toBe(true); - expect( - hasUnsafeStarterModelChanges({ - insert: [], - update: [], - delete: [{}], - }), - ).toBe(true); - }); - - it("completes previews, simulator, and onboarding", async () => { - let previewReadCount = 0; - const fetchMock = vi.fn(async (input, init) => { - const url = input.toString(); - if (url.includes("/core/repository/preview_configs")) { - previewReadCount += 1; - return jsonResponse({ - results: - previewReadCount === 1 - ? [ - { - id: "hosted-preview", - label: "Production", - url: "https://starter.example.com/api/preview", - }, - ] - : [], - }); - } - if (url.includes("/onboarding")) { - return jsonResponse({ - completedSteps: init?.method === "PATCH" ? ["instantStart_continueBuildingLocally"] : [], - }); - } - return new Response(null, { status: 200 }); - }); - vi.stubGlobal("fetch", fetchMock); - - await expect( - completeStarterHandoff( - { - id: "prismicio/custom-starter", - revision: "starter-release", - framework: "custom", - deploymentUrl: "https://starter.example.com/", - }, - { - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }, - ), - ).resolves.toEqual({ localPreviewConfigured: true }); - - expect(fetchMock).toHaveBeenCalledTimes(7); - expect(fetchMock.mock.calls[3][0].toString()).toContain("/previews/new"); - expect(fetchMock.mock.calls[4][0].toString()).toContain("/core/repository"); - expect(fetchMock.mock.calls[6][0].toString()).toContain( - "/onboarding/instantStart_continueBuildingLocally/toggle", - ); - }); -}); - -function jsonResponse(value: unknown): Response { - return new Response(JSON.stringify(value), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); -} From 8c7a99df35e54aaf3f46ceec91d5b49cf96e69f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:48:13 +0100 Subject: [PATCH 18/20] refactor(init): use adapter URLs for starter handoff and rename package Derive local preview and simulator URLs from adapter config instead of hardcoding Next.js values. Rename package.json to the repo when the local project matches the starter template after handoff completes. Co-authored-by: Cursor --- src/adapters/index.ts | 21 ++++++++++++++++++- src/adapters/nextjs.ts | 13 +++++++----- src/adapters/nuxt.ts | 13 +++++++----- src/adapters/sveltekit.ts | 18 ++++++++++------ src/commands/init.ts | 44 ++++++++++++++++++++------------------- src/lib/packageJson.ts | 10 +++++++++ test/init.test.ts | 3 +++ 7 files changed, 84 insertions(+), 38 deletions(-) diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 43912ba..0ac2132 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -26,6 +26,12 @@ const TYPES_FILENAME = "prismicio-types.d.ts"; type CustomTypeMeta = { model: CustomType; modelPath: URL; directory: URL; library: URL }; type SharedSliceMeta = { model: SharedSlice; modelPath: URL; directory: URL; library: URL }; +export type LocalDevelopmentPreview = { + name: string; + websiteURL: string; + resolverPath: string; +}; + export async function getAdapter(): Promise { const { dependencies, devDependencies, peerDependencies } = await readPackageJson(); const allDependencies = { ...dependencies, ...devDependencies, ...peerDependencies }; @@ -60,6 +66,19 @@ export abstract class Adapter { abstract readonly environmentEnvVarName: string; + abstract readonly localPreviewConfig: LocalDevelopmentPreview; + + get localPreviewUrl(): string { + return new URL( + this.localPreviewConfig.resolverPath, + this.localPreviewConfig.websiteURL, + ).href; + } + + get localSimulatorUrl(): string { + return new URL("slice-simulator", this.localPreviewConfig.websiteURL).href; + } + abstract onProjectInitialized(): Promise | void; abstract onSliceCreated(model: SharedSlice, library: URL): Promise | void; abstract onSliceUpdated(model: SharedSlice): Promise | void; @@ -83,7 +102,7 @@ export abstract class Adapter { } async getSliceLibraries(): Promise { - let libraries = await getLibraries(); + const libraries = await getLibraries(); if (libraries) return libraries; const defaultSliceLibrary = await this.getDefaultSliceLibrary(); return [defaultSliceLibrary]; diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index b4fab6d..c456fbc 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -34,6 +34,12 @@ export class NextJsAdapter extends Adapter { readonly environmentEnvVarName = "NEXT_PUBLIC_PRISMIC_ENVIRONMENT"; + readonly localPreviewConfig = { + name: "Development", + websiteURL: "http://localhost:3000", + resolverPath: "/api/preview", + }; + async setupProject(): Promise { await addDependencies({ "@prismicio/client": `^${await getNpmPackageVersion("@prismicio/client")}`, @@ -53,15 +59,12 @@ export class NextJsAdapter extends Adapter { const simulatorUrl = await getSimulatorUrl({ repo, token, host }); if (!simulatorUrl) { - await setSimulatorUrl("http://localhost:3000/slice-simulator", { repo, token, host }); + await setSimulatorUrl(this.localSimulatorUrl, { repo, token, host }); } const previews = await getPreviews({ repo, token, host }); if (previews.length === 0) { - await addPreview( - { name: "Development", websiteURL: "http://localhost:3000", resolverPath: "/api/preview" }, - { repo, token, host }, - ); + await addPreview(this.localPreviewConfig, { repo, token, host }); } } diff --git a/src/adapters/nuxt.ts b/src/adapters/nuxt.ts index abe5463..84fa4f5 100644 --- a/src/adapters/nuxt.ts +++ b/src/adapters/nuxt.ts @@ -29,6 +29,12 @@ export class NuxtAdapter extends Adapter { readonly environmentEnvVarName = "NUXT_PUBLIC_PRISMIC_ENVIRONMENT"; + readonly localPreviewConfig = { + name: "Development", + websiteURL: "http://localhost:3000", + resolverPath: "/preview", + }; + async setupProject(): Promise { await addDependencies({ "@prismicio/client": `^${await getNpmPackageVersion("@prismicio/client")}`, @@ -46,15 +52,12 @@ export class NuxtAdapter extends Adapter { const simulatorUrl = await getSimulatorUrl({ repo, token, host }); if (!simulatorUrl) { - await setSimulatorUrl("http://localhost:3000/slice-simulator", { repo, token, host }); + await setSimulatorUrl(this.localSimulatorUrl, { repo, token, host }); } const previews = await getPreviews({ repo, token, host }); if (previews.length === 0) { - await addPreview( - { name: "Development", websiteURL: "http://localhost:3000", resolverPath: "/preview" }, - { repo, token, host }, - ); + await addPreview(this.localPreviewConfig, { repo, token, host }); } } diff --git a/src/adapters/sveltekit.ts b/src/adapters/sveltekit.ts index ebc58a4..dc42bf3 100644 --- a/src/adapters/sveltekit.ts +++ b/src/adapters/sveltekit.ts @@ -1,4 +1,7 @@ -import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes"; +import type { + CustomType, + SharedSlice, +} from "@prismicio/types-internal/lib/customtypes"; import { pascalCase } from "change-case"; import { loadFile } from "magicast"; @@ -36,6 +39,12 @@ export class SvelteKitAdapter extends Adapter { readonly environmentEnvVarName = "PUBLIC_PRISMIC_ENVIRONMENT"; + readonly localPreviewConfig = { + name: "Development", + websiteURL: "http://localhost:5173", + resolverPath: "/api/preview", + }; + async setupProject(): Promise { await addDependencies({ "@prismicio/client": `^${await getNpmPackageVersion("@prismicio/client")}`, @@ -57,15 +66,12 @@ export class SvelteKitAdapter extends Adapter { const simulatorUrl = await getSimulatorUrl({ repo, token, host }); if (!simulatorUrl) { - await setSimulatorUrl("http://localhost:5173/slice-simulator", { repo, token, host }); + await setSimulatorUrl(this.localSimulatorUrl, { repo, token, host }); } const previews = await getPreviews({ repo, token, host }); if (previews.length === 0) { - await addPreview( - { name: "Development", websiteURL: "http://localhost:5173", resolverPath: "/api/preview" }, - { repo, token, host }, - ); + await addPreview(this.localPreviewConfig, { repo, token, host }); } } diff --git a/src/commands/init.ts b/src/commands/init.ts index 5525032..c39a9c6 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -2,13 +2,13 @@ import { rm } from "node:fs/promises"; import type { Profile } from "../lib/prismic/clients/user"; -import { getAdapter } from "../adapters"; +import { getAdapter, type Adapter } from "../adapters"; import { createLoginSession, getCredentials } from "../auth"; import { DEFAULT_PRISMIC_HOST, env } from "../env"; import { openBrowser } from "../lib/browser"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { diffArrays } from "../lib/diff"; -import { installDependencies, readPackageJson, removeDependencies } from "../lib/packageJson"; +import { installDependencies, readPackageJson, removeDependencies, updatePackageJsonName } from "../lib/packageJson"; import { addPreview, getPreviews, @@ -280,7 +280,7 @@ Choose the source of truth: } if (isExistingProjectHandoff && connectedRepository?.starter) { - await completeStarterHandoff(connectedRepository.starter, { repo, token, host }); + await completeStarterHandoff(adapter, connectedRepository.starter, { repo, token, host }); } console.info(`\nInitialized Prismic for repository "${repo}".`); @@ -288,10 +288,14 @@ Choose the source of truth: console.info("Run `prismic pull` to pull models from Prismic."); }); -async function removeStarterDocuments(starter: NonNullable): Promise { +async function isStarterPackage(starter: NonNullable): Promise { const packageJson = await readPackageJson(); const starterPackageName = starter.id.split("/").at(-1); - if (!starterPackageName || packageJson.name !== starterPackageName) { + return Boolean(starterPackageName && packageJson.name === starterPackageName); +} + +async function removeStarterDocuments(starter: NonNullable): Promise { + if (!(await isStarterPackage(starter))) { console.warn( "Starter seed documents were not removed because the local package does not match the repository starter.", ); @@ -302,45 +306,39 @@ async function removeStarterDocuments(starter: NonNullable, config: { repo: string; token: string | undefined; host: string }, ): Promise { try { - const hostedPreviewURL = new URL("/api/preview", starter.deploymentUrl).href; + const hostedPreviewURL = new URL( + adapter.localPreviewConfig.resolverPath, + starter.deploymentUrl, + ).href; const previews = await getPreviews(config); await Promise.all( previews .filter((preview) => preview.url === hostedPreviewURL) .map((preview) => removePreview(preview.id, config)), ); - const hasDevelopmentPreview = previews.some( - (preview) => preview.url === starterLocalPreviewURL, - ); + const hasDevelopmentPreview = previews.some((preview) => preview.url === adapter.localPreviewUrl); if (!hasDevelopmentPreview) { - await addPreview(starterLocalPreviewConfig, config); + await addPreview(adapter.localPreviewConfig, config); } } catch (error) { await sentryCaptureError(error); console.error( - `Could not configure the local preview. Run \`prismic preview add ${starterLocalPreviewURL} --name ${starterLocalPreviewConfig.name}\` manually. Continuing.`, + `Could not configure the local preview. Run \`prismic preview add ${adapter.localPreviewUrl} --name ${adapter.localPreviewConfig.name}\` manually. Continuing.`, ); } try { - await setSimulatorUrl(starterLocalSimulatorURL, config); + await setSimulatorUrl(adapter.localSimulatorUrl, config); } catch (error) { await sentryCaptureError(error); console.error( - `Could not configure the local slice simulator. Run \`prismic preview set-simulator ${starterLocalSimulatorURL}\` manually. Continuing.`, + `Could not configure the local slice simulator. Run \`prismic preview set-simulator ${adapter.localSimulatorUrl}\` manually. Continuing.`, ); } @@ -348,4 +346,8 @@ async function completeStarterHandoff( ...config, stepIds: ["instantStart_continueBuildingLocally"], }); + + if (await isStarterPackage(starter)) { + await updatePackageJsonName(config.repo); + } } diff --git a/src/lib/packageJson.ts b/src/lib/packageJson.ts index 04059d6..ea3ba6c 100644 --- a/src/lib/packageJson.ts +++ b/src/lib/packageJson.ts @@ -61,6 +61,16 @@ export async function removeDependencies(names: string[]): Promise { await writeFile(packageJsonPath, newContents); } +export async function updatePackageJsonName(name: string): Promise { + const packageJsonPath = await findPackageJson(); + const raw = await readFile(packageJsonPath, "utf8"); + const indent = detectIndent(raw).indent || "\t"; + const packageJson = JSON.parse(raw); + packageJson.name = name; + const newContents = JSON.stringify(packageJson, null, indent) + "\n"; + await writeFile(packageJsonPath, newContents); +} + export async function getNpmPackageVersion(name: string, tag = "latest"): Promise { const url = new URL(`${name}/${tag}`, "https://registry.npmjs.org/"); const { version } = await request(url, { diff --git a/test/init.test.ts b/test/init.test.ts index e56bccd..f4af786 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -315,6 +315,9 @@ it.skip("completes the handoff for a starter project", async ({ const config = JSON.parse(await readFile(new URL("prismic.config.json", project), "utf-8")); expect(config.repositoryName).toBe(repo); + const packageJson = JSON.parse(await readFile(new URL("package.json", project), "utf-8")); + expect(packageJson.name).toBe(repo); + // Seed documents are removed. await expect(access(new URL("documents/", project))).rejects.toThrow(); From 46ce95721e4f818c38c8281b66b45f5c03550033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:02:19 +0100 Subject: [PATCH 19/20] refactor: rename --- src/commands/init.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index c39a9c6..befcc24 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -241,11 +241,13 @@ export default createCommand(config, async ({ values }) => { await removeStarterDocuments(connectedRepository.starter); } - const hasUnsafeModelChanges = + const hasStarterModelChanges = isExistingProjectHandoff && - [customTypeOps, sliceOps].some((ops) => ops.update.length > 0 || ops.delete.length > 0); + [customTypeOps, sliceOps].some( + (ops) => ops.update.length > 0 || ops.delete.length > 0, + ); - if (!hasUnsafeModelChanges) { + if (!hasStarterModelChanges) { for (const slice of sliceOps.update) { await adapter.updateSlice(slice); } @@ -269,7 +271,7 @@ export default createCommand(config, async ({ values }) => { await adapter.generateTypes(); - if (hasUnsafeModelChanges) { + if (hasStarterModelChanges) { console.warn(` Local and remote models differ, so no model files were changed. The project is connected. From 9afabfe769e229846d9869ac117c126d12320607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:48:36 +0100 Subject: [PATCH 20/20] test(init): enable Instant Start handoff e2e tests Unskip starter handoff coverage now that instant-start provisioning returns starter provenance in production, and delete seeded documents before removing custom types in the no-models scenario. Co-authored-by: Cursor --- test/init.test.ts | 18 +++++++++++------- test/prismic.ts | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/test/init.test.ts b/test/init.test.ts index f4af786..a209304 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -7,6 +7,7 @@ import { createInstantStartRepository, createRepository, deleteCustomType, + deleteDocumentsByCustomType, deleteRepository, deleteSlice, getCustomTypes, @@ -292,7 +293,7 @@ describe("with an isolated repository", () => { }, 60_000); }); -it.skip("completes the handoff for a starter project", async ({ +it("completes the handoff for a starter project", async ({ expect, project, prismic, @@ -337,7 +338,7 @@ it.skip("completes the handoff for a starter project", async ({ } }, 120_000); -it.skip("keeps seed documents when the local package does not match the starter", async ({ +it("keeps seed documents when the local package does not match the starter", async ({ expect, project, prismic, @@ -361,7 +362,7 @@ it.skip("keeps seed documents when the local package does not match the starter" } }, 120_000); -it.skip("fails when the starter repository has no models", async ({ +it("fails when the starter repository has no models", async ({ expect, prismic, token, @@ -374,10 +375,13 @@ it.skip("fails when the starter repository has no models", async ({ getCustomTypes({ repo, token, host }), getSlices({ repo, token, host }), ]); - await Promise.all([ - ...customTypes.map((customType) => deleteCustomType(customType.id, { repo, token, host })), - ...slices.map((slice) => deleteSlice(slice.id, { repo, token, host })), - ]); + for (const customType of customTypes) { + await deleteDocumentsByCustomType(customType.id, { repo, token, host }); + await deleteCustomType(customType.id, { repo, token, host }); + } + await Promise.all( + slices.map((slice) => deleteSlice(slice.id, { repo, token, host })), + ); const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); expect(exitCode).toBe(1); diff --git a/test/prismic.ts b/test/prismic.ts index ed421ef..e00c93b 100644 --- a/test/prismic.ts +++ b/test/prismic.ts @@ -118,6 +118,24 @@ export async function deleteCustomType(customTypeId: string, config: RepoConfig) if (!res.ok) throw new Error(`Failed to delete custom type: ${res.status} ${await res.text()}`); } +export async function deleteDocumentsByCustomType( + customTypeId: string, + config: RepoConfig, +): Promise { + const host = config.host ?? DEFAULT_HOST; + const url = new URL("documents", `https://${config.repo}.${host}/core/`); + const res = await fetch(url, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Cookie: `prismic-auth=${config.token}`, + }, + body: JSON.stringify({ customtype_ids: [customTypeId] }), + }); + if (!res.ok) + throw new Error(`Failed to delete documents: ${res.status} ${await res.text()}`); +} + export async function getSlices(config: RepoConfig): Promise { const host = config.host ?? DEFAULT_HOST; const url = new URL("slices", `https://customtypes.${host}/`);