From 23880197f99dac72a2d11763e6dd94c6943998c1 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 31 Jul 2026 20:05:10 +0000 Subject: [PATCH 1/4] refactor(init): inline starter handoff into the init command Per AGENTS.md file organization rules: - Move src/lib/starter.ts logic into src/commands/init.ts, its only caller. The lib layer must stay application-unaware; this code throws CommandError and prints CLI remediation messages. - Remove hasPreviewByURL/removePreviewsByURL from the core client and inline the orchestration at the call site. Clients are network calls only. - Remove test/starter-handoff.test.ts and test/repository-client.test.ts. Tests mirror src/commands/ one-to-one and never import from src/. Co-Authored-By: Claude Fable 5 --- src/commands/init.ts | 75 ++++++++++++++++++--- src/lib/prismic/clients/core.ts | 18 ----- src/lib/starter.ts | 95 -------------------------- test/repository-client.test.ts | 73 -------------------- test/starter-handoff.test.ts | 115 -------------------------------- 5 files changed, 67 insertions(+), 309 deletions(-) delete mode 100644 src/lib/starter.ts delete mode 100644 test/repository-client.test.ts delete mode 100644 test/starter-handoff.test.ts diff --git a/src/commands/init.ts b/src/commands/init.ts index 51e2441..5525032 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -9,16 +9,22 @@ import { openBrowser } from "../lib/browser"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { diffArrays } from "../lib/diff"; import { installDependencies, readPackageJson, removeDependencies } from "../lib/packageJson"; +import { + addPreview, + getPreviews, + removePreview, + setSimulatorUrl, +} from "../lib/prismic/clients/core"; import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; -import { getRepository, type Repository } from "../lib/prismic/clients/repository"; +import { + completeOnboardingStepsSilently, + getRepository, + type Repository, +} from "../lib/prismic/clients/repository"; import { getProfile } from "../lib/prismic/clients/user"; import { canonicalizeCustomType, canonicalizeSlice } from "../lib/prismic/models"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; -import { - assertStarterRepositoryHasModels, - completeStarterHandoff, - hasUnsafeStarterModelChanges, -} from "../lib/starter"; +import { sentryCaptureError } from "../lib/sentry"; import { type Config, createConfig, @@ -227,12 +233,17 @@ export default createCommand(config, async ({ values }) => { }); if (isExistingProjectHandoff && connectedRepository?.starter) { - assertStarterRepositoryHasModels(repo, remoteCustomTypes, remoteSlices); + if (remoteCustomTypes.length === 0 && remoteSlices.length === 0) { + throw new CommandError( + `Repository "${repo}" has no starter models. Use a repository created from the starter in the Prismic dashboard.`, + ); + } await removeStarterDocuments(connectedRepository.starter); } const hasUnsafeModelChanges = - isExistingProjectHandoff && hasUnsafeStarterModelChanges(customTypeOps, sliceOps); + isExistingProjectHandoff && + [customTypeOps, sliceOps].some((ops) => ops.update.length > 0 || ops.delete.length > 0); if (!hasUnsafeModelChanges) { for (const slice of sliceOps.update) { @@ -290,3 +301,51 @@ async function removeStarterDocuments(starter: NonNullable, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + try { + const hostedPreviewURL = new URL("/api/preview", starter.deploymentUrl).href; + const previews = await getPreviews(config); + await Promise.all( + previews + .filter((preview) => preview.url === hostedPreviewURL) + .map((preview) => removePreview(preview.id, config)), + ); + const hasDevelopmentPreview = previews.some( + (preview) => preview.url === starterLocalPreviewURL, + ); + if (!hasDevelopmentPreview) { + await addPreview(starterLocalPreviewConfig, config); + } + } catch (error) { + await sentryCaptureError(error); + console.error( + `Could not configure the local preview. Run \`prismic preview add ${starterLocalPreviewURL} --name ${starterLocalPreviewConfig.name}\` manually. Continuing.`, + ); + } + + try { + await setSimulatorUrl(starterLocalSimulatorURL, config); + } catch (error) { + await sentryCaptureError(error); + console.error( + `Could not configure the local slice simulator. Run \`prismic preview set-simulator ${starterLocalSimulatorURL}\` manually. Continuing.`, + ); + } + + await completeOnboardingStepsSilently({ + ...config, + stepIds: ["instantStart_continueBuildingLocally"], + }); +} diff --git a/src/lib/prismic/clients/core.ts b/src/lib/prismic/clients/core.ts index 25e2ca9..f9a73d4 100644 --- a/src/lib/prismic/clients/core.ts +++ b/src/lib/prismic/clients/core.ts @@ -62,24 +62,6 @@ export async function removePreview(id: string, config: CoreConfig): Promise { - const previews = await getPreviews(config); - return previews.some((preview) => preview.url === url); -} - -export async function removePreviewsByURL( - urls: Iterable, - config: CoreConfig, -): Promise { - const previews = await getPreviews(config); - const urlSet = new Set(urls); - await Promise.all( - previews - .filter((preview) => urlSet.has(preview.url)) - .map((preview) => removePreview(preview.id, config)), - ); -} - const EnvironmentSchema = z.object({ kind: z.enum(["prod", "stage", "dev"]), name: z.string(), diff --git a/src/lib/starter.ts b/src/lib/starter.ts deleted file mode 100644 index 0455aa8..0000000 --- a/src/lib/starter.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { ArrayDiff } from "./diff"; -import type { Repository } from "./prismic/clients/repository"; - -import { CommandError } from "./command"; -import { - addPreview, - hasPreviewByURL, - removePreviewsByURL, - setSimulatorUrl, -} from "./prismic/clients/core"; -import { completeOnboardingStepsSilently } from "./prismic/clients/repository"; -import { sentryCaptureError } from "./sentry"; - -export const starterLocalPreviewURL = "http://localhost:3000/api/preview"; -const starterLocalPreviewConfig = { - name: "Development", - websiteURL: "http://localhost:3000", - resolverPath: "/api/preview", -}; -const starterLocalSimulatorURL = "http://localhost:3000/slice-simulator"; - -export function assertStarterRepositoryHasModels( - repositoryId: string, - customTypes: unknown[], - slices: unknown[], -): void { - if (customTypes.length > 0 || slices.length > 0) return; - - throw new CommandError( - `Repository "${repositoryId}" has no starter models. Use a repository created from the starter in the Prismic dashboard.`, - ); -} - -export function hasUnsafeStarterModelChanges(...diffs: Array>): boolean { - return diffs.some((diff) => diff.update.length > 0 || diff.delete.length > 0); -} - -export function resolveStarterHostedPreviewURL(starter: Repository["starter"] | undefined): string { - assertStarterProvenance(starter); - - return new URL("/api/preview", starter.deploymentUrl).href; -} - -function assertStarterProvenance( - starter: Repository["starter"] | undefined, -): asserts starter is NonNullable { - if (starter == null) { - throw new CommandError( - "Repository does not have starter provenance. Use a repository created with Instant Start.", - ); - } -} - -export async function completeStarterHandoff( - starter: NonNullable, - config: { repo: string; token: string | undefined; host: string }, -): Promise<{ localPreviewConfigured: boolean }> { - const localPreviewConfigured = await configureStarterPreviews(starter, config); - - try { - await setSimulatorUrl(starterLocalSimulatorURL, config); - } catch (error) { - await sentryCaptureError(error); - console.error( - `Could not configure the local slice simulator. Run \`prismic preview set-simulator ${starterLocalSimulatorURL}\` manually. Continuing.`, - ); - } - - await completeOnboardingStepsSilently({ - ...config, - stepIds: ["instantStart_continueBuildingLocally"], - }); - - return { localPreviewConfigured }; -} - -async function configureStarterPreviews( - starter: NonNullable, - config: { repo: string; token: string | undefined; host: string }, -): Promise { - try { - await removePreviewsByURL([resolveStarterHostedPreviewURL(starter)], config); - const hasDevelopmentPreview = await hasPreviewByURL(starterLocalPreviewURL, config); - if (!hasDevelopmentPreview) { - await addPreview(starterLocalPreviewConfig, config); - } - return true; - } catch (error) { - await sentryCaptureError(error); - console.error( - `Could not configure the local preview. Run \`prismic preview add ${starterLocalPreviewURL} --name ${starterLocalPreviewConfig.name}\` manually. Continuing.`, - ); - return false; - } -} diff --git a/test/repository-client.test.ts b/test/repository-client.test.ts deleted file mode 100644 index 8c4cf4e..0000000 --- a/test/repository-client.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { getRepository } from "../src/lib/prismic/clients/repository"; - -describe("getRepository starter parsing", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - it("defaults missing starter metadata to null", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ quotas: { sliceMachineEnabled: true } }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - await expect( - getRepository({ - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }), - ).resolves.toEqual({ - starter: null, - quotas: { sliceMachineEnabled: true }, - }); - }); - - it("preserves explicit null starter metadata", async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ starter: null }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - await expect( - getRepository({ - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }), - ).resolves.toEqual({ - starter: null, - }); - }); - - it("preserves complete starter provenance", async () => { - const starter = { - id: "prismicio/next-instant-start", - revision: "d2d78ca51884d0d665ec58879d370d033eefaf04", - framework: "next", - deploymentUrl: "https://next-instant-start-2a3svap7o-prismic.vercel.app/", - }; - const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ starter }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - vi.stubGlobal("fetch", fetchMock); - - await expect( - getRepository({ - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }), - ).resolves.toEqual({ starter }); - }); -}); diff --git a/test/starter-handoff.test.ts b/test/starter-handoff.test.ts deleted file mode 100644 index 9838db8..0000000 --- a/test/starter-handoff.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { - assertStarterRepositoryHasModels, - completeStarterHandoff, - hasUnsafeStarterModelChanges, - resolveStarterHostedPreviewURL, -} from "../src/lib/starter"; - -describe("starter handoff", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("builds the hosted preview URL from starter metadata", () => { - expect( - resolveStarterHostedPreviewURL({ - id: "prismicio/custom-starter", - revision: "starter-release", - framework: "custom", - deploymentUrl: "https://starter.example.com/", - }), - ).toBe("https://starter.example.com/api/preview"); - }); - - it("rejects repositories without starter models", () => { - expect(() => assertStarterRepositoryHasModels("my-repo", [], [])).toThrow( - 'Repository "my-repo" has no starter models.', - ); - expect(() => assertStarterRepositoryHasModels("my-repo", [{}], [])).not.toThrow(); - expect(() => assertStarterRepositoryHasModels("my-repo", [], [{}])).not.toThrow(); - }); - - it("only treats updates and deletions as unsafe automatic syncs", () => { - expect( - hasUnsafeStarterModelChanges( - { insert: [], update: [], delete: [] }, - { insert: [{}], update: [], delete: [] }, - ), - ).toBe(false); - expect( - hasUnsafeStarterModelChanges({ - insert: [], - update: [{}], - delete: [], - }), - ).toBe(true); - expect( - hasUnsafeStarterModelChanges({ - insert: [], - update: [], - delete: [{}], - }), - ).toBe(true); - }); - - it("completes previews, simulator, and onboarding", async () => { - let previewReadCount = 0; - const fetchMock = vi.fn(async (input, init) => { - const url = input.toString(); - if (url.includes("/core/repository/preview_configs")) { - previewReadCount += 1; - return jsonResponse({ - results: - previewReadCount === 1 - ? [ - { - id: "hosted-preview", - label: "Production", - url: "https://starter.example.com/api/preview", - }, - ] - : [], - }); - } - if (url.includes("/onboarding")) { - return jsonResponse({ - completedSteps: init?.method === "PATCH" ? ["instantStart_continueBuildingLocally"] : [], - }); - } - return new Response(null, { status: 200 }); - }); - vi.stubGlobal("fetch", fetchMock); - - await expect( - completeStarterHandoff( - { - id: "prismicio/custom-starter", - revision: "starter-release", - framework: "custom", - deploymentUrl: "https://starter.example.com/", - }, - { - repo: "my-repo", - token: "test-token", - host: "prismic.io", - }, - ), - ).resolves.toEqual({ localPreviewConfigured: true }); - - expect(fetchMock).toHaveBeenCalledTimes(7); - expect(fetchMock.mock.calls[3][0].toString()).toContain("/previews/new"); - expect(fetchMock.mock.calls[4][0].toString()).toContain("/core/repository"); - expect(fetchMock.mock.calls[6][0].toString()).toContain( - "/onboarding/instantStart_continueBuildingLocally/toggle", - ); - }); -}); - -function jsonResponse(value: unknown): Response { - return new Response(JSON.stringify(value), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); -} From ec12692b9a3f7fb14dbe5029b7bb848a2315bf1f Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 31 Jul 2026 20:27:00 +0000 Subject: [PATCH 2/4] test(init): cover starter handoff with e2e tests Replaces the removed mocked unit tests with e2e scenarios in test/init.test.ts. The unsafe-model-changes test runs today; the three starter-provenance tests are skipped until prismicio/obelix#1703 deploys the /website-generator/instant-start provenance changes to production. Co-Authored-By: Claude Fable 5 --- test/init.test.ts | 125 +++++++++++++++++++++++++++++++++++++++++++++- test/prismic.ts | 28 +++++++++++ 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/test/init.test.ts b/test/init.test.ts index 8ebb7bf..d327b80 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -1,12 +1,18 @@ -import { access, readFile, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { captureOutput, it } from "./it"; +import { buildCustomType, captureOutput, it, readLocalCustomType, writeLocalCustomType } from "./it"; import { addPreview, + createInstantStartRepository, createRepository, + deleteCustomType, deleteRepository, + deleteSlice, + getCustomTypes, + getOnboardingCompletedSteps, getPreviews, getRepository, + getSlices, setSimulatorUrl, } from "./prismic"; @@ -239,3 +245,118 @@ it("installs dependencies", { timeout: 30_000 }, async ({ expect, project, prism // Verify the stubbed npm was invoked (it creates package-lock.json) await expect(access(new URL("package-lock.json", project))).resolves.toBeUndefined(); }); + +it("warns and keeps local models when reconnecting with model differences", async ({ + expect, + project, + prismic, + repo, +}) => { + // A local-only model makes the local/remote diff contain a deletion, which + // blocks the automatic sync during an existing-project reconnect. + const localOnly = buildCustomType(); + await writeLocalCustomType(project, localOnly); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Choose the source of truth"); + + const localModel = await readLocalCustomType(project, localOnly.id); + expect(localModel).toEqual(localOnly); +}, 60_000); + +// The starter handoff needs a repository with starter provenance, which only +// `POST /website-generator/instant-start` can create. Unskip these once +// prismicio/obelix#1703 is deployed to production; until then the endpoint +// provisions repositories without the `starter` metadata the handoff reads. +it.skip("completes the handoff for a starter project", async ({ + expect, + project, + prismic, + token, + host, + password, +}) => { + const repo = await createInstantStartRepository({ token, host }); + try { + await writeFile( + new URL("package.json", project), + JSON.stringify({ name: "next-instant-start", dependencies: { next: "latest" } }), + ); + await mkdir(new URL("documents/", project), { recursive: true }); + await writeFile(new URL("documents/homepage.json", project), "{}"); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode, stderr).toBe(0); + + const config = JSON.parse(await readFile(new URL("prismic.config.json", project), "utf-8")); + expect(config.repositoryName).toBe(repo); + + // Seed documents are removed. + await expect(access(new URL("documents/", project))).rejects.toThrow(); + + // The hosted preview is replaced by the local Development preview. + const previews = await getPreviews({ repo, token, host }); + const previewLabels = previews.map((preview) => preview.label); + expect(previewLabels).toContain("Development"); + expect(previewLabels).not.toContain("Production"); + + const repository = await getRepository({ repo, token, host }); + expect(repository.simulatorUrl).toBe("http://localhost:3000/slice-simulator"); + + const completedSteps = await getOnboardingCompletedSteps({ repo, token, host }); + expect(completedSteps).toContain("instantStart_continueBuildingLocally"); + } finally { + await deleteRepository(repo, { token, password, host }); + } +}, 120_000); + +it.skip("keeps seed documents when the local package does not match the starter", async ({ + expect, + project, + prismic, + token, + host, + password, +}) => { + const repo = await createInstantStartRepository({ token, host }); + try { + // The fixture package.json has no name, so it cannot match the starter. + await mkdir(new URL("documents/", project), { recursive: true }); + await writeFile(new URL("documents/homepage.json", project), "{}"); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Starter seed documents were not removed"); + + await expect(access(new URL("documents/", project))).resolves.toBeUndefined(); + } finally { + await deleteRepository(repo, { token, password, host }); + } +}, 120_000); + +it.skip("fails when the starter repository has no models", async ({ + expect, + prismic, + token, + host, + password, +}) => { + const repo = await createInstantStartRepository({ token, host }); + try { + const [customTypes, slices] = await Promise.all([ + getCustomTypes({ repo, token, host }), + getSlices({ repo, token, host }), + ]); + await Promise.all([ + ...customTypes.map((customType) => deleteCustomType(customType.id, { repo, token, host })), + ...slices.map((slice) => deleteSlice(slice.id, { repo, token, host })), + ]); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("has no starter models"); + } finally { + await deleteRepository(repo, { token, password, host }); + } +}, 120_000); diff --git a/test/prismic.ts b/test/prismic.ts index e0dea64..ed421ef 100644 --- a/test/prismic.ts +++ b/test/prismic.ts @@ -33,6 +33,34 @@ export async function createRepository(domain: string, config: AuthConfig): Prom throw new Error(`Failed to create repository ${domain}: ${res.status} ${await res.text()}`); } +export async function createInstantStartRepository(config: AuthConfig): Promise { + const host = config.host ?? DEFAULT_HOST; + const url = new URL("website-generator/instant-start", `https://api.internal.${host}/`); + const res = await fetch(url, { + method: "POST", + headers: { Authorization: `Bearer ${config.token}` }, + }); + if (!res.ok) + throw new Error( + `Failed to create Instant Start repository: ${res.status} ${await res.text()}`, + ); + const data = await res.json(); + return data.repositoryId; +} + +export async function getOnboardingCompletedSteps(config: RepoConfig): Promise { + const host = config.host ?? DEFAULT_HOST; + const url = new URL("repository/onboarding", `https://api.internal.${host}/`); + url.searchParams.set("repository", config.repo); + const res = await fetch(url, { + headers: { Authorization: `Bearer ${config.token}`, repository: config.repo }, + }); + if (!res.ok) + throw new Error(`Failed to get onboarding state: ${res.status} ${await res.text()}`); + const data = await res.json(); + return data.completedSteps; +} + export async function deleteRepository( domain: string, config: AuthConfig & { password: string }, From 8e24fb3e28c1fad86a28099ef0385849d73123af Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 31 Jul 2026 20:35:16 +0000 Subject: [PATCH 3/4] test(init): cover modified-model reconnect warning Exercises the canonicalized equality check: a same-id model with different content must count as an update and block the automatic sync. Co-Authored-By: Claude Fable 5 --- test/init.test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/init.test.ts b/test/init.test.ts index d327b80..0f9bc61 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -1,4 +1,5 @@ import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { describe } from "vitest"; import { buildCustomType, captureOutput, it, readLocalCustomType, writeLocalCustomType } from "./it"; import { @@ -13,6 +14,7 @@ import { getPreviews, getRepository, getSlices, + insertCustomType, setSimulatorUrl, } from "./prismic"; @@ -265,6 +267,31 @@ it("warns and keeps local models when reconnecting with model differences", asyn expect(localModel).toEqual(localOnly); }, 60_000); +describe("with an isolated repository", () => { + it.scoped({ isolateRepo: true }); + + it("warns and keeps local models when reconnecting with a modified model", async ({ + expect, + project, + prismic, + repo, + token, + host, + }) => { + const model = buildCustomType(); + await insertCustomType(model, { repo, token, host }); + const modified = { ...model, label: `${model.label}Modified` }; + await writeLocalCustomType(project, modified); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Choose the source of truth"); + + const localModel = await readLocalCustomType(project, model.id); + expect(localModel).toEqual(modified); + }, 60_000); +}); + // The starter handoff needs a repository with starter provenance, which only // `POST /website-generator/instant-start` can create. Unskip these once // prismicio/obelix#1703 is deployed to production; until then the endpoint From 9bf3fb176d964d19a9c25a2ec14ef10dd3343171 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Fri, 31 Jul 2026 20:42:04 +0000 Subject: [PATCH 4/4] test(init): drop starter test skip comment Co-Authored-By: Claude Fable 5 --- test/init.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/init.test.ts b/test/init.test.ts index 0f9bc61..e56bccd 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -292,10 +292,6 @@ describe("with an isolated repository", () => { }, 60_000); }); -// The starter handoff needs a repository with starter provenance, which only -// `POST /website-generator/instant-start` can create. Unskip these once -// prismicio/obelix#1703 is deployed to production; until then the endpoint -// provisions repositories without the `starter` metadata the handoff reads. it.skip("completes the handoff for a starter project", async ({ expect, project,