diff --git a/src/commands/dev.ts b/src/commands/dev.ts index f23a2a3b..372725e9 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -13,7 +13,13 @@ import { openBrowser } from "../lib/browser"; import { createCommand, type CommandConfig, CommandError, exclusiveOptions } from "../lib/command"; import { readJsonFile, watchFiles, writeFileRecursive } from "../lib/file"; import { stringify } from "../lib/json"; -import { createRelease, deleteRelease } from "../lib/prismic/clients/core"; +import { + createRelease, + deleteRelease, + getPreviews, + getReleasePreviewURL, + refreshRelease, +} from "../lib/prismic/clients/core"; import type { CustomTypesConfig } from "../lib/prismic/clients/custom-types"; import { canonicalizeCustomType, @@ -41,8 +47,12 @@ const config = { Type Builder changes are saved to local files, and local file changes appear in the Type Builder. + The "Preview:" line opens your website with these models, and the + preview updates as they change. + The command runs until you stop it. Agents should run it in the - background and give the user the URL from its "Type Builder:" line. + background and give the user the URLs from its "Type Builder:" and + "Preview:" lines. @experimental - This command may change or be removed in any release and does not follow semver. `, @@ -86,12 +96,23 @@ export default createCommand(config, async ({ values }) => { session = await createSession({ repo, token, host }); } const release = { repo, token, host, releaseId: session.releaseId }; + // The release preview only shows model changes once rebuilt. A failed rebuild stays pending and is retried on the next sync. + let needsRefresh = true; + const refresh = async () => { + if (!needsRefresh) return; + await refreshRelease(release.releaseId, release); + needsRefresh = false; + }; + const changed = (message: (ids: string[]) => string) => (ids: string[]) => { + needsRefresh = true; + log(message(ids)); + }; const sync = (push = true) => syncModels(adapter, release, session, sessionPath, { push, - onPull: (ids) => log(`↓ Pulled ${names(ids)}`), - onPush: (ids) => log(`↑ Pushed ${names(ids)}`), - onDelete: (ids) => log(`− Deleted ${names(ids)}`), + onPull: changed((ids) => `↓ Pulled ${names(ids)}`), + onPush: changed((ids) => `↑ Pushed ${names(ids)}`), + onDelete: changed((ids) => `− Deleted ${names(ids)}`), }); const end = async (): Promise => { @@ -116,6 +137,7 @@ export default createCommand(config, async ({ values }) => { const models = continueSession ? await sync() : await seedRelease(adapter, release, session, sessionPath); + await refresh().catch(() => {}); const types = models.customTypes.length; const slices = models.slices.length; console.info( @@ -125,8 +147,11 @@ export default createCommand(config, async ({ values }) => { const url = new URL("builder/types", `https://${repo}.${host}/`); url.searchParams.set("r", release.releaseId); console.info(`Type Builder: ${url}`); + await printPreviewURL(release); if (noBrowser) { - console.info("Open the URL above to start editing. Changes sync both ways while this runs."); + console.info( + "Open the Type Builder URL above to start editing. Changes sync both ways while this runs.", + ); } else { openBrowser(url); console.info("Opened in your browser. Changes sync both ways while this runs."); @@ -138,7 +163,11 @@ export default createCommand(config, async ({ values }) => { if (message !== lastError) log(`! ${message}`, console.error); lastError = message; }; - await syncUntilStopped(sync, waitForChange, stopping.signal, { + const syncAndRefresh = async () => { + await sync(); + await refresh(); + }; + await syncUntilStopped(syncAndRefresh, waitForChange, stopping.signal, { onSync: () => { lastError = undefined; }, @@ -184,6 +213,19 @@ async function createSession({ repo, token, host }: CustomTypesConfig): Promise< return { repo, releaseId, pid: process.pid, synced: {} }; } +async function printPreviewURL({ releaseId, ...config }: Release): Promise { + try { + const [preview] = await getPreviews(config); + if (!preview) { + console.info("To preview your website with these models, run `prismic preview add `."); + return; + } + console.info(`Preview: ${await getReleasePreviewURL(releaseId, preview.id, config)}`); + } catch (error) { + console.error(`Could not create a preview link: ${await getErrorMessage(error)}`); + } +} + async function endSession({ releaseId, ...config }: Release, sessionPath: URL): Promise { await deleteRelease(releaseId, config); await rm(sessionPath, { force: true }); diff --git a/src/lib/prismic/clients/core.ts b/src/lib/prismic/clients/core.ts index 8d271f38..598f6d96 100644 --- a/src/lib/prismic/clients/core.ts +++ b/src/lib/prismic/clients/core.ts @@ -146,6 +146,38 @@ export async function deleteRelease(id: string, config: CoreConfig): Promise { + const { repo, host } = config; + const url = new URL( + `core/releases/${encodeURIComponent(id)}/refresh`, + getCoreServiceUrl(repo, host), + ); + await coreServiceRequest(url, config, { + method: "POST", + notFoundMessage: `Release not found: ${id}`, + unknownErrorMessage: "Failed to refresh the release preview", + }); +} + +export async function getReleasePreviewURL( + releaseId: string, + previewId: string, + config: CoreConfig, +): Promise { + const { repo, host } = config; + const url = new URL( + `core/previews/session/releases/${encodeURIComponent(releaseId)}`, + getCoreServiceUrl(repo, host), + ); + url.searchParams.set("previewId", previewId); + const response = await coreServiceRequest(url, config, { + schema: z.looseObject({ preview_url: z.string() }), + notFoundMessage: `Release not found: ${releaseId}`, + unknownErrorMessage: "Failed to create a preview link", + }); + return response.preview_url; +} + function coreServiceRequest( url: URL, config: CoreConfig, diff --git a/test/dev.test.ts b/test/dev.test.ts index 5223b2b7..4f3225db 100644 --- a/test/dev.test.ts +++ b/test/dev.test.ts @@ -16,7 +16,14 @@ import { writeLocalCustomType, writeLocalSlice, } from "./it"; -import { getCustomTypes, getSlices, insertCustomType, updateCustomType } from "./prismic"; +import { + addPreview, + getCustomTypes, + getReleasePreviewToken, + getSlices, + insertCustomType, + updateCustomType, +} from "./prismic"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("dev", ["--help"]); @@ -188,6 +195,7 @@ describe("with an isolated repository", () => { const { proc, output, releaseId } = await startSession(prismic, expect); expect(output()).toContain(`https://${repo}.${host}/builder/types?r=`); + await expect.poll(output, { timeout: 30_000 }).toContain("prismic preview add"); const releaseTypeIds = (await getCustomTypes({ repo, token, host, releaseId })).map( (m) => m.id, @@ -218,6 +226,31 @@ describe("with an isolated repository", () => { expect(await readdir(new URL(".config/prismic/dev/", home))).toEqual([]); }, 120_000); + it("rebuilds the release preview when a model changes", async ({ + expect, + prismic, + project, + repo, + token, + host, + }) => { + await addPreview("https://example.com/api/preview", "Test", { repo, token, host }); + const customType = buildCustomType(); + await writeLocalCustomType(project, customType); + + const { output, releaseId } = await startSession(prismic, expect); + await expect + .poll(output, { timeout: 30_000 }) + .toContain("Preview: https://example.com/api/preview?token="); + const before = await getReleasePreviewToken(releaseId, { repo, token, host }); + + await writeLocalCustomType(project, { ...customType, label: "Edited" }); + await expect.poll(output, { timeout: 30_000 }).toContain(`Pushed ${customType.id}`); + await expect + .poll(() => getReleasePreviewToken(releaseId, { repo, token, host }), { timeout: 30_000 }) + .not.toBe(before); + }, 90_000); + it("keeps syncing after a rejected change", async ({ expect, prismic, diff --git a/test/prismic.ts b/test/prismic.ts index 55bcd76d..a66d94de 100644 --- a/test/prismic.ts +++ b/test/prismic.ts @@ -303,6 +303,25 @@ export async function addPreview( if (!res.ok) throw new Error(`Failed to add preview: ${res.status} ${await res.text()}`); } +export async function getReleasePreviewToken( + releaseId: string, + config: RepoConfig, +): Promise { + const host = config.host ?? DEFAULT_HOST; + const [preview] = await getPreviews(config); + const url = new URL( + `core/previews/session/releases/${releaseId}`, + `https://${config.repo}.${host}/`, + ); + url.searchParams.set("previewId", preview.id); + const res = await fetch(url, { + headers: { Cookie: `prismic-auth=${config.token}` }, + }); + if (!res.ok) throw new Error(`Failed to get release preview: ${res.status} ${await res.text()}`); + const data = await res.json(); + return new URL(data.preview_url).searchParams.get("token"); +} + export async function getLocales( config: RepoConfig, ): Promise<{ id: string; label: string; customName: string | null; isMaster: boolean }[]> {