Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 67 additions & 8 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -290,3 +301,51 @@ async function removeStarterDocuments(starter: NonNullable<Repository["starter"]
const projectRoot = await findProjectRoot();
await rm(new URL("documents/", projectRoot), { recursive: true, force: true });
}

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";

async function completeStarterHandoff(
starter: NonNullable<Repository["starter"]>,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
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"],
});
}
18 changes: 0 additions & 18 deletions src/lib/prismic/clients/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,24 +62,6 @@ export async function removePreview(id: string, config: CoreConfig): Promise<voi
});
}

export async function hasPreviewByURL(url: string, config: CoreConfig): Promise<boolean> {
const previews = await getPreviews(config);
return previews.some((preview) => preview.url === url);
}

export async function removePreviewsByURL(
urls: Iterable<string>,
config: CoreConfig,
): Promise<void> {
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(),
Expand Down
95 changes: 0 additions & 95 deletions src/lib/starter.ts

This file was deleted.

148 changes: 146 additions & 2 deletions test/init.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { access, readFile, rm, writeFile } from "node:fs/promises";
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { describe } from "vitest";

import { captureOutput, it } from "./it";
import { buildCustomType, captureOutput, it, readLocalCustomType, writeLocalCustomType } from "./it";
import {
addPreview,
createInstantStartRepository,
createRepository,
deleteCustomType,
deleteRepository,
deleteSlice,
getCustomTypes,
getOnboardingCompletedSteps,
getPreviews,
getRepository,
getSlices,
insertCustomType,
setSimulatorUrl,
} from "./prismic";

Expand Down Expand Up @@ -239,3 +247,139 @@ it("installs dependencies", { timeout: 30_000 }, async ({ expect, project, prism
// Verify the stubbed npm was invoked (it creates package-lock.json)
await expect(access(new URL("package-lock.json", project))).resolves.toBeUndefined();
});

it("warns and keeps local models when reconnecting with model differences", async ({
expect,
project,
prismic,
repo,
}) => {
// A local-only model makes the local/remote diff contain a deletion, which
// blocks the automatic sync during an existing-project reconnect.
const localOnly = buildCustomType();
await writeLocalCustomType(project, localOnly);

const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("Choose the source of truth");

const localModel = await readLocalCustomType(project, localOnly.id);
expect(localModel).toEqual(localOnly);
}, 60_000);

describe("with an isolated repository", () => {
it.scoped({ isolateRepo: true });

it("warns and keeps local models when reconnecting with a modified model", async ({
expect,
project,
prismic,
repo,
token,
host,
}) => {
const model = buildCustomType();
await insertCustomType(model, { repo, token, host });
const modified = { ...model, label: `${model.label}Modified` };
await writeLocalCustomType(project, modified);

const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("Choose the source of truth");

const localModel = await readLocalCustomType(project, model.id);
expect(localModel).toEqual(modified);
}, 60_000);
});

it.skip("completes the handoff for a starter project", async ({
expect,
project,
prismic,
token,
host,
password,
}) => {
const repo = await createInstantStartRepository({ token, host });
try {
await writeFile(
new URL("package.json", project),
JSON.stringify({ name: "next-instant-start", dependencies: { next: "latest" } }),
);
await mkdir(new URL("documents/", project), { recursive: true });
await writeFile(new URL("documents/homepage.json", project), "{}");

const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]);
expect(exitCode, stderr).toBe(0);

const config = JSON.parse(await readFile(new URL("prismic.config.json", project), "utf-8"));
expect(config.repositoryName).toBe(repo);

// Seed documents are removed.
await expect(access(new URL("documents/", project))).rejects.toThrow();

// The hosted preview is replaced by the local Development preview.
const previews = await getPreviews({ repo, token, host });
const previewLabels = previews.map((preview) => preview.label);
expect(previewLabels).toContain("Development");
expect(previewLabels).not.toContain("Production");

const repository = await getRepository({ repo, token, host });
expect(repository.simulatorUrl).toBe("http://localhost:3000/slice-simulator");

const completedSteps = await getOnboardingCompletedSteps({ repo, token, host });
expect(completedSteps).toContain("instantStart_continueBuildingLocally");
} finally {
await deleteRepository(repo, { token, password, host });
}
}, 120_000);

it.skip("keeps seed documents when the local package does not match the starter", async ({
expect,
project,
prismic,
token,
host,
password,
}) => {
const repo = await createInstantStartRepository({ token, host });
try {
// The fixture package.json has no name, so it cannot match the starter.
await mkdir(new URL("documents/", project), { recursive: true });
await writeFile(new URL("documents/homepage.json", project), "{}");

const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("Starter seed documents were not removed");

await expect(access(new URL("documents/", project))).resolves.toBeUndefined();
} finally {
await deleteRepository(repo, { token, password, host });
}
}, 120_000);

it.skip("fails when the starter repository has no models", async ({
expect,
prismic,
token,
host,
password,
}) => {
const repo = await createInstantStartRepository({ token, host });
try {
const [customTypes, slices] = await Promise.all([
getCustomTypes({ repo, token, host }),
getSlices({ repo, token, host }),
]);
await Promise.all([
...customTypes.map((customType) => deleteCustomType(customType.id, { repo, token, host })),
...slices.map((slice) => deleteSlice(slice.id, { repo, token, host })),
]);

const { stderr, exitCode } = await prismic("init", ["--repo", repo, "--no-setup"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("has no starter models");
} finally {
await deleteRepository(repo, { token, password, host });
}
}, 120_000);
Loading
Loading