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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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(),