diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 43912ba..0ac2132 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -26,6 +26,12 @@ const TYPES_FILENAME = "prismicio-types.d.ts"; type CustomTypeMeta = { model: CustomType; modelPath: URL; directory: URL; library: URL }; type SharedSliceMeta = { model: SharedSlice; modelPath: URL; directory: URL; library: URL }; +export type LocalDevelopmentPreview = { + name: string; + websiteURL: string; + resolverPath: string; +}; + export async function getAdapter(): Promise { const { dependencies, devDependencies, peerDependencies } = await readPackageJson(); const allDependencies = { ...dependencies, ...devDependencies, ...peerDependencies }; @@ -60,6 +66,19 @@ export abstract class Adapter { abstract readonly environmentEnvVarName: string; + abstract readonly localPreviewConfig: LocalDevelopmentPreview; + + get localPreviewUrl(): string { + return new URL( + this.localPreviewConfig.resolverPath, + this.localPreviewConfig.websiteURL, + ).href; + } + + get localSimulatorUrl(): string { + return new URL("slice-simulator", this.localPreviewConfig.websiteURL).href; + } + abstract onProjectInitialized(): Promise | void; abstract onSliceCreated(model: SharedSlice, library: URL): Promise | void; abstract onSliceUpdated(model: SharedSlice): Promise | void; @@ -83,7 +102,7 @@ export abstract class Adapter { } async getSliceLibraries(): Promise { - let libraries = await getLibraries(); + const libraries = await getLibraries(); if (libraries) return libraries; const defaultSliceLibrary = await this.getDefaultSliceLibrary(); return [defaultSliceLibrary]; diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index b4fab6d..c456fbc 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -34,6 +34,12 @@ export class NextJsAdapter extends Adapter { readonly environmentEnvVarName = "NEXT_PUBLIC_PRISMIC_ENVIRONMENT"; + readonly localPreviewConfig = { + name: "Development", + websiteURL: "http://localhost:3000", + resolverPath: "/api/preview", + }; + async setupProject(): Promise { await addDependencies({ "@prismicio/client": `^${await getNpmPackageVersion("@prismicio/client")}`, @@ -53,15 +59,12 @@ export class NextJsAdapter extends Adapter { const simulatorUrl = await getSimulatorUrl({ repo, token, host }); if (!simulatorUrl) { - await setSimulatorUrl("http://localhost:3000/slice-simulator", { repo, token, host }); + await setSimulatorUrl(this.localSimulatorUrl, { repo, token, host }); } const previews = await getPreviews({ repo, token, host }); if (previews.length === 0) { - await addPreview( - { name: "Development", websiteURL: "http://localhost:3000", resolverPath: "/api/preview" }, - { repo, token, host }, - ); + await addPreview(this.localPreviewConfig, { repo, token, host }); } } diff --git a/src/adapters/nuxt.ts b/src/adapters/nuxt.ts index abe5463..84fa4f5 100644 --- a/src/adapters/nuxt.ts +++ b/src/adapters/nuxt.ts @@ -29,6 +29,12 @@ export class NuxtAdapter extends Adapter { readonly environmentEnvVarName = "NUXT_PUBLIC_PRISMIC_ENVIRONMENT"; + readonly localPreviewConfig = { + name: "Development", + websiteURL: "http://localhost:3000", + resolverPath: "/preview", + }; + async setupProject(): Promise { await addDependencies({ "@prismicio/client": `^${await getNpmPackageVersion("@prismicio/client")}`, @@ -46,15 +52,12 @@ export class NuxtAdapter extends Adapter { const simulatorUrl = await getSimulatorUrl({ repo, token, host }); if (!simulatorUrl) { - await setSimulatorUrl("http://localhost:3000/slice-simulator", { repo, token, host }); + await setSimulatorUrl(this.localSimulatorUrl, { repo, token, host }); } const previews = await getPreviews({ repo, token, host }); if (previews.length === 0) { - await addPreview( - { name: "Development", websiteURL: "http://localhost:3000", resolverPath: "/preview" }, - { repo, token, host }, - ); + await addPreview(this.localPreviewConfig, { repo, token, host }); } } diff --git a/src/adapters/sveltekit.ts b/src/adapters/sveltekit.ts index ebc58a4..dc42bf3 100644 --- a/src/adapters/sveltekit.ts +++ b/src/adapters/sveltekit.ts @@ -1,4 +1,7 @@ -import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes"; +import type { + CustomType, + SharedSlice, +} from "@prismicio/types-internal/lib/customtypes"; import { pascalCase } from "change-case"; import { loadFile } from "magicast"; @@ -36,6 +39,12 @@ export class SvelteKitAdapter extends Adapter { readonly environmentEnvVarName = "PUBLIC_PRISMIC_ENVIRONMENT"; + readonly localPreviewConfig = { + name: "Development", + websiteURL: "http://localhost:5173", + resolverPath: "/api/preview", + }; + async setupProject(): Promise { await addDependencies({ "@prismicio/client": `^${await getNpmPackageVersion("@prismicio/client")}`, @@ -57,15 +66,12 @@ export class SvelteKitAdapter extends Adapter { const simulatorUrl = await getSimulatorUrl({ repo, token, host }); if (!simulatorUrl) { - await setSimulatorUrl("http://localhost:5173/slice-simulator", { repo, token, host }); + await setSimulatorUrl(this.localSimulatorUrl, { repo, token, host }); } const previews = await getPreviews({ repo, token, host }); if (previews.length === 0) { - await addPreview( - { name: "Development", websiteURL: "http://localhost:5173", resolverPath: "/api/preview" }, - { repo, token, host }, - ); + await addPreview(this.localPreviewConfig, { repo, token, host }); } } diff --git a/src/commands/init.ts b/src/commands/init.ts index a1b27c1..befcc24 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,23 +1,41 @@ +import { rm } from "node:fs/promises"; + import type { Profile } from "../lib/prismic/clients/user"; -import { getAdapter } from "../adapters"; +import { getAdapter, type Adapter } from "../adapters"; import { createLoginSession, getCredentials } from "../auth"; import { DEFAULT_PRISMIC_HOST, env } from "../env"; import { openBrowser } from "../lib/browser"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { diffArrays } from "../lib/diff"; -import { installDependencies, readPackageJson, removeDependencies } from "../lib/packageJson"; +import { installDependencies, readPackageJson, removeDependencies, updatePackageJsonName } from "../lib/packageJson"; +import { + addPreview, + getPreviews, + removePreview, + setSimulatorUrl, +} from "../lib/prismic/clients/core"; import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types"; +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 { sentryCaptureError } from "../lib/sentry"; 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 +72,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 +131,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 +144,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 +155,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 +197,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 +221,135 @@ 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) { + 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); } - for (const customType of customTypeOps.insert) { - await adapter.createCustomType(customType); + + const hasStarterModelChanges = + isExistingProjectHandoff && + [customTypeOps, sliceOps].some( + (ops) => ops.update.length > 0 || ops.delete.length > 0, + ); + + if (!hasStarterModelChanges) { + 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 (hasStarterModelChanges) { + 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(adapter, 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 isStarterPackage(starter: NonNullable): Promise { + const packageJson = await readPackageJson(); + const starterPackageName = starter.id.split("/").at(-1); + return Boolean(starterPackageName && packageJson.name === starterPackageName); +} + +async function removeStarterDocuments(starter: NonNullable): Promise { + if (!(await isStarterPackage(starter))) { + console.warn( + "Starter seed documents were not removed because the local package does not match the repository starter.", + ); + return; + } + + const projectRoot = await findProjectRoot(); + await rm(new URL("documents/", projectRoot), { recursive: true, force: true }); +} + +async function completeStarterHandoff( + adapter: Adapter, + starter: NonNullable, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + try { + const hostedPreviewURL = new URL( + adapter.localPreviewConfig.resolverPath, + starter.deploymentUrl, + ).href; + const previews = await getPreviews(config); + await Promise.all( + previews + .filter((preview) => preview.url === hostedPreviewURL) + .map((preview) => removePreview(preview.id, config)), + ); + const hasDevelopmentPreview = previews.some((preview) => preview.url === adapter.localPreviewUrl); + if (!hasDevelopmentPreview) { + await addPreview(adapter.localPreviewConfig, config); + } + } catch (error) { + await sentryCaptureError(error); + console.error( + `Could not configure the local preview. Run \`prismic preview add ${adapter.localPreviewUrl} --name ${adapter.localPreviewConfig.name}\` manually. Continuing.`, + ); + } + + try { + await setSimulatorUrl(adapter.localSimulatorUrl, config); + } catch (error) { + await sentryCaptureError(error); + console.error( + `Could not configure the local slice simulator. Run \`prismic preview set-simulator ${adapter.localSimulatorUrl}\` manually. Continuing.`, + ); + } + + await completeOnboardingStepsSilently({ + ...config, + stepIds: ["instantStart_continueBuildingLocally"], + }); + + if (await isStarterPackage(starter)) { + await updatePackageJsonName(config.repo); + } +} diff --git a/src/lib/packageJson.ts b/src/lib/packageJson.ts index 9b71f11..ea3ba6c 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())), @@ -60,6 +61,16 @@ export async function removeDependencies(names: string[]): Promise { await writeFile(packageJsonPath, newContents); } +export async function updatePackageJsonName(name: string): Promise { + const packageJsonPath = await findPackageJson(); + const raw = await readFile(packageJsonPath, "utf8"); + const indent = detectIndent(raw).indent || "\t"; + const packageJson = JSON.parse(raw); + packageJson.name = name; + const newContents = JSON.stringify(packageJson, null, indent) + "\n"; + await writeFile(packageJsonPath, newContents); +} + export async function getNpmPackageVersion(name: string, tag = "latest"): Promise { const url = new URL(`${name}/${tag}`, "https://registry.npmjs.org/"); const { version } = await request(url, { diff --git a/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/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..a209304 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -1,12 +1,21 @@ -import { access, readFile, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { describe } from "vitest"; -import { captureOutput, it } from "./it"; +import { buildCustomType, captureOutput, it, readLocalCustomType, writeLocalCustomType } from "./it"; import { addPreview, + createInstantStartRepository, createRepository, + deleteCustomType, + deleteDocumentsByCustomType, deleteRepository, + deleteSlice, + getCustomTypes, + getOnboardingCompletedSteps, getPreviews, getRepository, + getSlices, + insertCustomType, setSimulatorUrl, } from "./prismic"; @@ -16,10 +25,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 +113,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)); @@ -218,3 +248,145 @@ it("installs dependencies", { timeout: 30_000 }, async ({ expect, project, prism // Verify the stubbed npm was invoked (it creates package-lock.json) await expect(access(new URL("package-lock.json", project))).resolves.toBeUndefined(); }); + +it("warns and keeps local models when reconnecting with model differences", async ({ + expect, + project, + prismic, + repo, +}) => { + // A local-only model makes the local/remote diff contain a deletion, which + // blocks the automatic sync during an existing-project reconnect. + const localOnly = buildCustomType(); + await writeLocalCustomType(project, localOnly); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Choose the source of truth"); + + const localModel = await readLocalCustomType(project, localOnly.id); + expect(localModel).toEqual(localOnly); +}, 60_000); + +describe("with an isolated repository", () => { + it.scoped({ isolateRepo: true }); + + it("warns and keeps local models when reconnecting with a modified model", async ({ + expect, + project, + prismic, + repo, + token, + host, + }) => { + const model = buildCustomType(); + await insertCustomType(model, { repo, token, host }); + const modified = { ...model, label: `${model.label}Modified` }; + await writeLocalCustomType(project, modified); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("Choose the source of truth"); + + const localModel = await readLocalCustomType(project, model.id); + expect(localModel).toEqual(modified); + }, 60_000); +}); + +it("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); + + const packageJson = JSON.parse(await readFile(new URL("package.json", project), "utf-8")); + expect(packageJson.name).toBe(repo); + + // Seed documents are removed. + await expect(access(new URL("documents/", project))).rejects.toThrow(); + + // 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("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("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 }), + ]); + for (const customType of customTypes) { + await deleteDocumentsByCustomType(customType.id, { repo, token, host }); + await deleteCustomType(customType.id, { repo, token, host }); + } + await Promise.all( + slices.map((slice) => deleteSlice(slice.id, { repo, token, host })), + ); + + const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]); + expect(exitCode).toBe(1); + 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..e00c93b 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 }, @@ -90,6 +118,24 @@ export async function deleteCustomType(customTypeId: string, config: RepoConfig) if (!res.ok) throw new Error(`Failed to delete custom type: ${res.status} ${await res.text()}`); } +export async function deleteDocumentsByCustomType( + customTypeId: string, + config: RepoConfig, +): Promise { + const host = config.host ?? DEFAULT_HOST; + const url = new URL("documents", `https://${config.repo}.${host}/core/`); + const res = await fetch(url, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Cookie: `prismic-auth=${config.token}`, + }, + body: JSON.stringify({ customtype_ids: [customTypeId] }), + }); + if (!res.ok) + throw new Error(`Failed to delete documents: ${res.status} ${await res.text()}`); +} + export async function getSlices(config: RepoConfig): Promise { const host = config.host ?? DEFAULT_HOST; const url = new URL("slices", `https://customtypes.${host}/`);