From 26ee33164323530c1a10029a1db27dac7e3a5eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:44:21 +0100 Subject: [PATCH 1/2] feat(dev): keep and attach to releases, target obelix preview stages POC for Types on releases (Milestone 6): - `--keep` keeps the session's hidden release when the session ends. - `--release ` checks the release exists, then syncs with it without seeding, so release-only models are pulled instead of deleted. It keeps the release too. - The session file records `keep`, and stale-session cleanup leaves kept releases alone. - Prints `Release:` and `Page Builder:` lines after `Type Builder:`. - `PRISMIC_STAGE` sends Custom Types requests to `https://api.internal.{host}/{stage}/custom-type/` for obelix PR previews. Co-Authored-By: Claude Opus 5.5 --- src/commands/dev.ts | 77 +++++++++++++++++++------ src/env.ts | 1 + src/lib/prismic/clients/core.ts | 10 ++++ src/lib/prismic/clients/custom-types.ts | 11 ++-- test/dev.test.ts | 48 +++++++++++++++ 5 files changed, 126 insertions(+), 21 deletions(-) diff --git a/src/commands/dev.ts b/src/commands/dev.ts index f23a2a3b..56e82e67 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -13,7 +13,7 @@ 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, getRelease } from "../lib/prismic/clients/core"; import type { CustomTypesConfig } from "../lib/prismic/clients/custom-types"; import { canonicalizeCustomType, @@ -42,7 +42,8 @@ const config = { appear in the Type Builder. 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 + "Page Builder:" lines, and the ID from its "Release:" line. @experimental - This command may change or be removed in any release and does not follow semver. `, @@ -53,39 +54,49 @@ const config = { type: "boolean", description: "Start a new session, even if the previous one has unpulled changes", }, + keep: { type: "boolean", description: "Keep the session's release when it ends" }, + release: { type: "string", description: "Sync with an existing release and keep it" }, "no-browser": { type: "boolean", description: "Skip opening the Type Builder in the browser" }, }, } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { exclusiveOptions(values, ["continue", "new"]); + exclusiveOptions(values, ["continue", "release"]); const adapter = await getAdapter(); const { repo = (await adapter.getEnvironment()) ?? (await getRepositoryName()), continue: continueSession, new: newSession, + keep = false, + release: attachId, "no-browser": noBrowser, } = values; const { token, host } = await getCredentials(); + const stage = env.PRISMIC_STAGE; const sessionPath = await getSessionPath(); const previous = await resolvePreviousSession(adapter, sessionPath, { token, host, + stage, continueSession, newSession, }); let session: Session; if (previous) { - session = { ...previous, pid: process.pid }; + session = { ...previous, pid: process.pid, keep: previous.keep || keep }; console.info(`Continuing your session for ${repo}...`); + } else if (attachId !== undefined) { + console.info(`Attaching to release ${attachId} for ${repo}...`); + session = await attachSession(attachId, { repo, token, host }); } else { console.info(`Preparing your session for ${repo}...`); - session = await createSession({ repo, token, host }); + session = await createSession({ repo, token, host }, keep); } - const release = { repo, token, host, releaseId: session.releaseId }; + const release = { repo, token, host, stage, releaseId: session.releaseId }; const sync = (push = true) => syncModels(adapter, release, session, sessionPath, { push, @@ -97,7 +108,7 @@ export default createCommand(config, async ({ values }) => { const end = async (): Promise => { try { await sync(false); - await endSession(release, sessionPath); + await endSession(release, sessionPath, session.keep); return true; } catch (error) { console.error( @@ -113,9 +124,11 @@ export default createCommand(config, async ({ values }) => { [...(await adapter.getCustomTypeLibraries()), ...(await adapter.getSliceLibraries())], stopping.signal, ); - const models = continueSession - ? await sync() - : await seedRelease(adapter, release, session, sessionPath); + // Seeding pushes deletions, which would wipe an attached release's own models. + const models = + continueSession || attachId !== undefined + ? await sync() + : await seedRelease(adapter, release, session, sessionPath); const types = models.customTypes.length; const slices = models.slices.length; console.info( @@ -125,6 +138,10 @@ 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}`); + console.info(`Release: ${release.releaseId}`); + console.info( + `Page Builder: ${new URL(`builder/upcoming/${release.releaseId}`, `https://${repo}.${host}/`)}`, + ); if (noBrowser) { console.info("Open the URL above to start editing. Changes sync both ways while this runs."); } else { @@ -153,7 +170,11 @@ export default createCommand(config, async ({ values }) => { } console.info("\nEnding the session..."); - if (await end()) console.info("Session ended."); + if (await end()) { + console.info( + session.keep ? `Session ended. Kept release ${session.releaseId}.` : "Session ended.", + ); + } }); type Session = z.infer; @@ -174,18 +195,37 @@ async function saveSession(sessionPath: URL, session: Session): Promise { await writeFileRecursive(sessionPath, stringify(session)); } -async function createSession({ repo, token, host }: CustomTypesConfig): Promise { +async function createSession( + { repo, token, host }: CustomTypesConfig, + keep: boolean, +): Promise { const releaseId = await createRelease( { label: "prismic dev", hidden: true }, { repo, token, host }, ).catch((error) => { throw explainRequestError(error); }); - return { repo, releaseId, pid: process.pid, synced: {} }; + return { repo, releaseId, pid: process.pid, synced: {}, keep }; } -async function endSession({ releaseId, ...config }: Release, sessionPath: URL): Promise { - await deleteRelease(releaseId, config); +async function attachSession( + releaseId: string, + { repo, token, host }: CustomTypesConfig, +): Promise { + // Without a release ID, every write would go to production. + if (!releaseId) throw new CommandError("--release needs a release ID."); + await getRelease(releaseId, { repo, token, host }).catch((error) => { + throw explainRequestError(error); + }); + return { repo, releaseId, pid: process.pid, synced: {}, keep: true }; +} + +async function endSession( + { releaseId, ...config }: Release, + sessionPath: URL, + keep: boolean, +): Promise { + if (!keep) await deleteRelease(releaseId, config); await rm(sessionPath, { force: true }); } @@ -195,11 +235,12 @@ async function resolvePreviousSession( options: { token: string | undefined; host: string; + stage: string | undefined; continueSession?: boolean; newSession?: boolean; }, ): Promise { - const { token, host, continueSession, newSession } = options; + const { token, host, stage, continueSession, newSession } = options; const previous = await readSession(sessionPath); if (previous && isRunning(previous.pid)) { throw new CommandError( @@ -214,12 +255,13 @@ async function resolvePreviousSession( } return previous; } - if (!previous) return; + // A kept release outlives its session, so starting over loses nothing. + if (!previous || previous.keep) return; if (!newSession) { const unpulled = await hasUnpulledChanges( adapter, - { repo: previous.repo, token, host, releaseId: previous.releaseId }, + { repo: previous.repo, token, host, stage, releaseId: previous.releaseId }, previous.synced, ); if (unpulled) { @@ -462,4 +504,5 @@ const SessionSchema = z.object({ releaseId: z.string(), pid: z.number(), synced: z.record(z.string(), z.string()), + keep: z._default(z.boolean(), false), }); diff --git a/src/env.ts b/src/env.ts index bfc218a1..d2fdf464 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,6 +17,7 @@ const Env = z.object({ PRISMIC_CONFIG_DIR: z.optional(z.string()), PRISMIC_TYPE_BUILDER_ENABLED: z.optional(z.stringbool()), PRISMIC_SYNC_POLL_MS: z.optional(z.coerce.number().check(z.gte(100))), + PRISMIC_STAGE: z.optional(z.string()), }); export const env = z.parse(Env, { diff --git a/src/lib/prismic/clients/core.ts b/src/lib/prismic/clients/core.ts index 8d271f38..ce329108 100644 --- a/src/lib/prismic/clients/core.ts +++ b/src/lib/prismic/clients/core.ts @@ -136,6 +136,16 @@ export async function createRelease( return response.id; } +export async function getRelease(id: string, config: CoreConfig): Promise<{ id: string }> { + const { repo, host } = config; + const url = new URL(`core/releases/${encodeURIComponent(id)}`, getCoreServiceUrl(repo, host)); + return coreServiceRequest(url, config, { + schema: z.looseObject({ id: z.string() }), + notFoundMessage: `Release not found: ${id}`, + unknownErrorMessage: "Failed to load the release", + }); +} + export async function deleteRelease(id: string, config: CoreConfig): Promise { const { repo, host } = config; const url = new URL(`core/releases/${encodeURIComponent(id)}`, getCoreServiceUrl(repo, host)); diff --git a/src/lib/prismic/clients/custom-types.ts b/src/lib/prismic/clients/custom-types.ts index 29aed13c..22443a6d 100644 --- a/src/lib/prismic/clients/custom-types.ts +++ b/src/lib/prismic/clients/custom-types.ts @@ -11,15 +11,17 @@ export type CustomTypesConfig = { token: string | undefined; host: string; releaseId?: string; + // An obelix preview stage, like `pull-123`, to send requests to instead of production. + stage?: string; }; export function getCustomTypes(config: CustomTypesConfig): Promise { - const url = new URL("customtypes", getCustomTypesServiceUrl(config.host)); + const url = new URL("customtypes", getCustomTypesServiceUrl(config)); return customTypesServiceRequest(url, config); } export function getSlices(config: CustomTypesConfig): Promise { - const url = new URL("slices", getCustomTypesServiceUrl(config.host)); + const url = new URL("slices", getCustomTypesServiceUrl(config)); return customTypesServiceRequest(url, config); } @@ -30,7 +32,7 @@ export type BulkChange = { }; export async function bulkUpdate(changes: BulkChange[], config: CustomTypesConfig): Promise { - const url = new URL("bulk-update", getCustomTypesServiceUrl(config.host)); + const url = new URL("bulk-update", getCustomTypesServiceUrl(config)); await customTypesServiceRequest(url, config, { method: "POST", json: { changes }, @@ -148,7 +150,8 @@ function screenshotServiceRequest( }); } -function getCustomTypesServiceUrl(host: string): URL { +function getCustomTypesServiceUrl({ host, stage }: CustomTypesConfig): URL { + if (stage) return new URL(`https://api.internal.${host}/${stage}/custom-type/`); return new URL(`https://customtypes.${host}/`); } diff --git a/test/dev.test.ts b/test/dev.test.ts index 5223b2b7..b64515f2 100644 --- a/test/dev.test.ts +++ b/test/dev.test.ts @@ -137,6 +137,54 @@ it.skipIf(process.platform === "win32")( 60_000, ); +it.skipIf(process.platform === "win32")( + "keeps the release with --keep and attaches to it with --release", + async ({ expect, prismic, project, repo, token, host }) => { + const localType = buildCustomType(); + await writeLocalCustomType(project, localType); + + const kept = await startSession(prismic, expect, ["--keep"]); + expect(kept.output()).toContain(`Release: ${kept.releaseId}`); + expect(kept.output()).toContain( + `Page Builder: https://${repo}.${host}/builder/upcoming/${kept.releaseId}`, + ); + kept.proc.kill("SIGINT"); + await kept.proc; + expect(kept.output()).toContain(`Kept release ${kept.releaseId}`); + + const releaseOnlyType = buildCustomType(); + await insertCustomType(releaseOnlyType, { repo, token, host, releaseId: kept.releaseId }); + + const attached = await startSession(prismic, expect, ["--release", kept.releaseId]); + expect(attached.releaseId).toBe(kept.releaseId); + expect(attached.output()).toContain(`Pulled ${releaseOnlyType.id}`); + expect(await readLocalCustomType(project, releaseOnlyType.id)).toMatchObject(releaseOnlyType); + attached.proc.kill("SIGINT"); + await attached.proc; + + const releaseTypeIds = ( + await getCustomTypes({ repo, token, host, releaseId: kept.releaseId }) + ).map((m) => m.id); + expect(releaseTypeIds.sort()).toEqual([localType.id, releaseOnlyType.id].sort()); + }, + 90_000, +); + +it("fails to attach to a missing release", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("dev", ["--release", "missing", "--no-browser"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Release not found: missing"); +}); + +it("sends model requests to the PRISMIC_STAGE preview", async ({ expect, prismic }) => { + const { stdout, stderr, exitCode } = await prismic("dev", ["--no-browser"], { + nodeOptions: { env: { PRISMIC_STAGE: "pull-0" } }, + }); + expect(exitCode).toBe(1); + expect(stdout).not.toContain("Type Builder:"); + expect(stderr).toContain("The session ended."); +}); + describe("with an isolated repository", () => { it.scoped({ isolateRepo: true }); From 23a6d92812083eb141b83d86fd71b4c78511af2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Pereira?= <7235666+jomifepe@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:45:17 +0100 Subject: [PATCH 2/2] test(dev): never open a browser from dev tests Co-Authored-By: Claude Opus 5.5 --- test/dev.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/dev.test.ts b/test/dev.test.ts index b64515f2..def3d711 100644 --- a/test/dev.test.ts +++ b/test/dev.test.ts @@ -301,7 +301,7 @@ async function startSession( args: string[] = [], env?: Record, ) { - const proc = prismic("dev", args, { nodeOptions: { env } }); + const proc = prismic("dev", [...args, "--no-browser"], { nodeOptions: { env } }); const output = captureOutput(proc); const deadline = Date.now() + 30_000; while (!output().includes("Type Builder:")) {