Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
bdbaa1e
feat: add remote modeling commands for custom types, page types, and …
angeloashmore Mar 31, 2026
e59258b
fix: correct Custom Types API URLs and slice command messages
angeloashmore Mar 31, 2026
ebb38ec
test: add e2e tests for custom-type, page-type, and slice commands
angeloashmore Mar 31, 2026
a90b570
fix: add default slice zone to page type and fix test types
angeloashmore Mar 31, 2026
16d120f
feat: add field management commands for remote modeling
angeloashmore Mar 31, 2026
56a739e
fix: add missing primary field to test slice builder
angeloashmore Mar 31, 2026
9cfd1c4
feat: add field edit command for remote modeling
angeloashmore Mar 31, 2026
8c765e3
feat: move sync logic into Adapter and sync after modeling commands
angeloashmore Mar 31, 2026
11dc23c
feat: replace syncModels with granular adapter methods
angeloashmore Apr 1, 2026
2133b8b
Merge branch 'main' into aa/remote-modeling
angeloashmore Apr 9, 2026
cf1f0d6
feat: unify `page-type` and `custom-type` into single `type` command …
angeloashmore Apr 10, 2026
901a0be
feat: add `type edit` command (#106)
angeloashmore Apr 10, 2026
ebf788b
feat: add `slice edit` and `slice edit-variation` commands (#107)
angeloashmore Apr 10, 2026
98ed067
feat: add tab management commands (#108)
angeloashmore Apr 10, 2026
ae4006a
feat: rename `--in` flag to `--from-slice`/`--from-type` (#110)
angeloashmore Apr 10, 2026
75f5bc9
feat: show fields inline in `view` commands and remove `field list` (…
angeloashmore Apr 10, 2026
68f620f
feat: add `field view` command (#112)
angeloashmore Apr 10, 2026
8d68cd1
feat: consolidate link-related field types (#114)
angeloashmore Apr 10, 2026
e23c6f6
feat: improve `content-relationship` help text (#115)
angeloashmore Apr 10, 2026
90227ab
feat: add a consistent table formatter for tabular output (#116)
angeloashmore Apr 10, 2026
774a581
feat: replace name-based model specifiers with IDs (#117)
angeloashmore Apr 10, 2026
29c12b7
Merge branch 'main' into aa/remote-modeling
angeloashmore Apr 11, 2026
af8d9d8
refactor: add `getCustomType` and `getSlice` client functions (#124)
angeloashmore Apr 14, 2026
ddd0555
feat: add `--field` option to `field add content-relationship` and `f…
angeloashmore Apr 14, 2026
8c60568
Merge branch 'main' into aa/remote-modeling
angeloashmore Apr 14, 2026
c3aa214
Merge branch 'main' into aa/remote-modeling
angeloashmore Apr 14, 2026
1903f4b
feat: handle type removal when documents exist (#127)
angeloashmore Apr 14, 2026
d94447a
feat: add field reorder command (#129)
angeloashmore Apr 14, 2026
40f71f0
feat: add `--screenshot` option to `slice add-variation` and `slice e…
angeloashmore Apr 14, 2026
2ef8f27
Merge remote-tracking branch 'origin/main' into aa/remote-modeling
angeloashmore Apr 15, 2026
a493a7f
fix: misc fixes for remote modeling branch
angeloashmore Apr 15, 2026
400e166
fix: resolve nested field lookup and missing existence check
angeloashmore Apr 15, 2026
0fbc9c3
fix: join field IDs before fallback check in error message
angeloashmore Apr 15, 2026
1e28f9d
fix: allow adding placeholder to fields that lack one and remove unus…
angeloashmore Apr 15, 2026
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
84 changes: 84 additions & 0 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { generateTypes } from "prismic-ts-codegen";
import { glob } from "tinyglobby";

import { getCustomTypes, getSlices } from "../clients/custom-types";
import { addRoute, removeRoute, updateRoute } from "../project";
import { readJsonFile, writeFileRecursive } from "../lib/file";
import { stringify } from "../lib/json";
Expand Down Expand Up @@ -180,6 +181,89 @@ export abstract class Adapter {
await this.onCustomTypeDeleted(id);
}

async syncModels(config: {
repo: string;
token: string | undefined;
host: string;
}): Promise<void> {
const { repo, token, host } = config;
await Promise.all([
this.syncSlices({ repo, token, host, generateTypes: false }),
this.syncCustomTypes({ repo, token, host, generateTypes: false }),
]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parallel sync may cause race conditions on shared resources

Medium Severity

syncModels runs syncSlices and syncCustomTypes in parallel via Promise.all, whereas the old code in sync.ts ran them sequentially. Both paths invoke adapter callbacks (onSliceCreated, onCustomTypeCreated, etc.) and filesystem operations like createSliceIndexFile and addRoute/updateRoute. If framework-specific adapter implementations modify shared resources (e.g., route config files, shared index files), the concurrent execution could cause race conditions or data corruption that was previously impossible with sequential execution.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1e28f9d. Configure here.

await this.generateTypes();
}

async syncSlices(config: {
repo: string;
token: string | undefined;
host: string;
generateTypes?: boolean;
}): Promise<void> {
const { repo, token, host, generateTypes = true } = config;

const remoteSlices = await getSlices({ repo, token, host });
const localSlices = await this.getSlices();

// Handle slices update
for (const remoteSlice of remoteSlices) {
const localSlice = localSlices.find((slice) => slice.model.id === remoteSlice.id);
if (localSlice) await this.updateSlice(remoteSlice);
}

// Handle slices deletion
for (const localSlice of localSlices) {
const existsRemotely = remoteSlices.some((slice) => slice.id === localSlice.model.id);
if (!existsRemotely) await this.deleteSlice(localSlice.model.id);
}

// Handle slices creation
for (const remoteSlice of remoteSlices) {
const existsLocally = localSlices.some((slice) => slice.model.id === remoteSlice.id);
if (!existsLocally) await this.createSlice(remoteSlice);
}

if (generateTypes) await this.generateTypes();
}

async syncCustomTypes(config: {
repo: string;
token: string | undefined;
host: string;
generateTypes?: boolean;
}): Promise<void> {
const { repo, token, host, generateTypes = true } = config;

const remoteCustomTypes = await getCustomTypes({ repo, token, host });
const localCustomTypes = await this.getCustomTypes();

// Handle custom types update
for (const remoteCustomType of remoteCustomTypes) {
const localCustomType = localCustomTypes.find(
(customType) => customType.model.id === remoteCustomType.id,
);
if (localCustomType) await this.updateCustomType(remoteCustomType);
}

// Handle custom types deletion
for (const localCustomType of localCustomTypes) {
const existsRemotely = remoteCustomTypes.some(
(customType) => customType.id === localCustomType.model.id,
);
if (!existsRemotely) await this.deleteCustomType(localCustomType.model.id);
}

// Handle custom types creation
for (const remoteCustomType of remoteCustomTypes) {
const existsLocally = localCustomTypes.some(
(customType) => customType.model.id === remoteCustomType.id,
);
if (!existsLocally) await this.createCustomType(remoteCustomType);
}

if (generateTypes) await this.generateTypes();
}

async generateTypes(): Promise<URL> {
const projectRoot = await findProjectRoot();
const output = new URL(TYPES_FILENAME, projectRoot);
Expand Down
199 changes: 199 additions & 0 deletions src/clients/custom-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes";

import { createHash } from "node:crypto";
import * as z from "zod/mini";

import { NotFoundRequestError, request } from "../lib/request";
import { appendTrailingSlash } from "../lib/url";

export async function getCustomTypes(config: {
repo: string;
Expand All @@ -23,6 +27,66 @@ export async function getCustomTypes(config: {
}
}

export async function getCustomType(
id: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<CustomType> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL(`customtypes/${encodeURIComponent(id)}`, customTypesServiceUrl);
try {
return await request<CustomType>(url, {
headers: { repository: repo, Authorization: `Bearer ${token}` },
});
} catch (error) {
if (error instanceof NotFoundRequestError) {
error.message = `Type not found: ${id}`;
}
throw error;
}
}

export async function insertCustomType(
model: CustomType,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL("customtypes/insert", customTypesServiceUrl);
await request(url, {
method: "POST",
headers: { repository: repo, Authorization: `Bearer ${token}` },
body: model,
});
}

export async function updateCustomType(
model: CustomType,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL("customtypes/update", customTypesServiceUrl);
await request(url, {
method: "POST",
headers: { repository: repo, Authorization: `Bearer ${token}` },
body: model,
});
}

export async function removeCustomType(
id: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL(`customtypes/${encodeURIComponent(id)}`, customTypesServiceUrl);
await request(url, {
method: "DELETE",
headers: { repository: repo, Authorization: `Bearer ${token}` },
});
}

export async function getSlices(config: {
repo: string;
token: string | undefined;
Expand All @@ -44,6 +108,141 @@ export async function getSlices(config: {
}
}

export async function getSlice(
id: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<SharedSlice> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL(`slices/${encodeURIComponent(id)}`, customTypesServiceUrl);
try {
return await request<SharedSlice>(url, {
headers: { repository: repo, Authorization: `Bearer ${token}` },
});
} catch (error) {
if (error instanceof NotFoundRequestError) {
error.message = `Slice not found: ${id}`;
}
throw error;
}
}

export async function insertSlice(
model: SharedSlice,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL("slices/insert", customTypesServiceUrl);
await request(url, {
method: "POST",
headers: { repository: repo, Authorization: `Bearer ${token}` },
body: model,
});
}

export async function updateSlice(
model: SharedSlice,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL("slices/update", customTypesServiceUrl);
await request(url, {
method: "POST",
headers: { repository: repo, Authorization: `Bearer ${token}` },
body: model,
});
}

export async function removeSlice(
id: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const { repo, token, host } = config;
const customTypesServiceUrl = getCustomTypesServiceUrl(host);
const url = new URL(`slices/${encodeURIComponent(id)}`, customTypesServiceUrl);
await request(url, {
method: "DELETE",
headers: { repository: repo, Authorization: `Bearer ${token}` },
});
}

const AclCreateResponseSchema = z.object({
values: z.object({
url: z.string(),
fields: z.record(z.string(), z.string()),
}),
imgixEndpoint: z.string(),
});

const SUPPORTED_IMAGE_MIME_TYPES: Record<string, string> = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
};

export async function uploadScreenshot(
blob: Blob,
config: {
sliceId: string;
variationId: string;
repo: string;
token: string | undefined;
host: string;
},
): Promise<URL> {
const { sliceId, variationId, repo, token, host } = config;

const type = blob.type;
if (!(type in SUPPORTED_IMAGE_MIME_TYPES)) {
throw new UnsupportedFileTypeError(type);
}

const aclUrl = new URL("create", getAclProviderUrl(host));
const acl = await request(aclUrl, {
headers: { Repository: repo, Authorization: `Bearer ${token}` },
schema: AclCreateResponseSchema,
});

const extension = SUPPORTED_IMAGE_MIME_TYPES[type];
const digest = createHash("md5")
.update(new Uint8Array(await blob.arrayBuffer()))
.digest("hex");
const key = `${repo}/shared-slices/${sliceId}/${variationId}/${digest}${extension}`;

const formData = new FormData();
for (const [field, value] of Object.entries(acl.values.fields)) {
formData.append(field, value);
}
formData.append("key", key);
formData.append("Content-Type", type);
formData.append("file", blob);

await request(acl.values.url, { method: "POST", body: formData });

const url = new URL(key, appendTrailingSlash(acl.imgixEndpoint));
url.searchParams.set("auto", "compress,format");

return url;
}

export class UnsupportedFileTypeError extends Error {
name = "UnsupportedFileTypeError";

constructor(mimeType: string) {
const supportedTypes = Object.keys(SUPPORTED_IMAGE_MIME_TYPES);
super(
`Unsupported file type: ${mimeType || "unknown"}. Supported: ${supportedTypes.join(", ")}`,
);
}
}

function getCustomTypesServiceUrl(host: string): URL {
return new URL(`https://customtypes.${host}/`);
}

function getAclProviderUrl(host: string): URL {
return new URL(`https://acl-provider.${host}/`);
}
55 changes: 55 additions & 0 deletions src/commands/field-add-boolean.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { BooleanField } from "@prismicio/types-internal/lib/customtypes";

import { capitalCase } from "change-case";

import { getHost, getToken } from "../auth";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models";
import { getRepositoryName } from "../project";

const config = {
name: "prismic field add boolean",
description: "Add a boolean field to a slice or custom type.",
positionals: {
id: { description: "Field ID", required: true },
},
options: {
...TARGET_OPTIONS,
label: { type: "string", description: "Field label" },
"default-value": { type: "boolean", description: "Default value" },
"true-label": { type: "string", description: "Label for true value" },
"false-label": { type: "string", description: "Label for false value" },
},
} satisfies CommandConfig;

export default createCommand(config, async ({ positionals, values }) => {
const [id] = positionals;
const {
label,
"default-value": default_value,
"true-label": placeholder_true,
"false-label": placeholder_false,
repo = await getRepositoryName(),
} = values;

const token = await getToken();
const host = await getHost();
const [fields, saveModel] = await resolveModel(values, { repo, token, host });
const [targetFields, fieldId] = resolveFieldTarget(fields, id);

const field: BooleanField = {
type: "Boolean",
config: {
label: label ?? capitalCase(fieldId),
default_value,
placeholder_true,
placeholder_false,
},
};

if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`);
targetFields[fieldId] = field;
await saveModel();

console.info(`Field added: ${id}`);
});
Loading