Skip to content
Draft
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
77 changes: 60 additions & 17 deletions src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
`,
Expand All @@ -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,
Expand All @@ -97,7 +108,7 @@ export default createCommand(config, async ({ values }) => {
const end = async (): Promise<boolean> => {
try {
await sync(false);
await endSession(release, sessionPath);
await endSession(release, sessionPath, session.keep);
return true;
} catch (error) {
console.error(
Expand All @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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<typeof SessionSchema>;
Expand All @@ -174,18 +195,37 @@ async function saveSession(sessionPath: URL, session: Session): Promise<void> {
await writeFileRecursive(sessionPath, stringify(session));
}

async function createSession({ repo, token, host }: CustomTypesConfig): Promise<Session> {
async function createSession(
{ repo, token, host }: CustomTypesConfig,
keep: boolean,
): Promise<Session> {
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<void> {
await deleteRelease(releaseId, config);
async function attachSession(
releaseId: string,
{ repo, token, host }: CustomTypesConfig,
): Promise<Session> {
// 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<void> {
if (!keep) await deleteRelease(releaseId, config);
await rm(sessionPath, { force: true });
}

Expand All @@ -195,11 +235,12 @@ async function resolvePreviousSession(
options: {
token: string | undefined;
host: string;
stage: string | undefined;
continueSession?: boolean;
newSession?: boolean;
},
): Promise<Session | undefined> {
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(
Expand All @@ -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) {
Expand Down Expand Up @@ -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),
});
1 change: 1 addition & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
10 changes: 10 additions & 0 deletions src/lib/prismic/clients/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const { repo, host } = config;
const url = new URL(`core/releases/${encodeURIComponent(id)}`, getCoreServiceUrl(repo, host));
Expand Down
11 changes: 7 additions & 4 deletions src/lib/prismic/clients/custom-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DynamicCustomTypeModel[]> {
const url = new URL("customtypes", getCustomTypesServiceUrl(config.host));
const url = new URL("customtypes", getCustomTypesServiceUrl(config));
return customTypesServiceRequest<DynamicCustomTypeModel[]>(url, config);
}

export function getSlices(config: CustomTypesConfig): Promise<SharedSliceModel[]> {
const url = new URL("slices", getCustomTypesServiceUrl(config.host));
const url = new URL("slices", getCustomTypesServiceUrl(config));
return customTypesServiceRequest<SharedSliceModel[]>(url, config);
}

Expand All @@ -30,7 +32,7 @@ export type BulkChange = {
};

export async function bulkUpdate(changes: BulkChange[], config: CustomTypesConfig): Promise<void> {
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 },
Expand Down Expand Up @@ -148,7 +150,8 @@ function screenshotServiceRequest<T>(
});
}

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}/`);
}

Expand Down
50 changes: 49 additions & 1 deletion test/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down Expand Up @@ -253,7 +301,7 @@ async function startSession(
args: string[] = [],
env?: Record<string, string>,
) {
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:")) {
Expand Down
Loading