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/lib/packageJson.ts b/src/lib/packageJson.ts index 9b71f11..04059d6 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())), 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(), diff --git a/src/lib/prismic/clients/repository.ts b/src/lib/prismic/clients/repository.ts index fdfe1bf..0003a90 100644 --- a/src/lib/prismic/clients/repository.ts +++ b/src/lib/prismic/clients/repository.ts @@ -8,14 +8,28 @@ 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(), + deploymentUrl: z.url(), }); +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 { @@ -27,7 +41,8 @@ export type OnboardingStep = | "createPrismicProject" | "createPageType" | "createSlice" - | "connectPrismic"; + | "connectPrismic" + | "instantStart_continueBuildingLocally"; const OnboardingStateSchema = z.object({ completedSteps: z.array(z.string()), diff --git a/src/lib/starter.ts b/src/lib/starter.ts new file mode 100644 index 0000000..0455aa8 --- /dev/null +++ b/src/lib/starter.ts @@ -0,0 +1,95 @@ +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/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/repository-client.test.ts b/test/repository-client.test.ts new file mode 100644 index 0000000..8c4cf4e --- /dev/null +++ b/test/repository-client.test.ts @@ -0,0 +1,73 @@ +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 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" }, + }); +}