prismic 0.0.0-canary.2cfb4a8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ import * as v from "valibot";
2
+
3
+ import { readToken } from "./auth";
4
+
5
+ type CustomRequestInit = Omit<RequestInit, "body"> & {
6
+ body?: RequestInit["body"] | Record<PropertyKey, unknown>;
7
+ };
8
+
9
+ export type RequestResponse<T> = SuccessfulRequestResponse<T> | FailedRequestResponse;
10
+ export type ParsedRequestResponse<T> =
11
+ | RequestResponse<T>
12
+ | { ok: false; value: unknown; error: v.ValiError<v.GenericSchema<T>> };
13
+ export type SuccessfulRequestResponse<T> = { ok: true; value: T };
14
+ export type FailedRequestResponse = {
15
+ ok: false;
16
+ value: unknown;
17
+ error: RequestError | ForbiddenRequestError | UnauthorizedRequestError;
18
+ };
19
+ export type FailedParsedRequestResponse<T> =
20
+ | FailedRequestResponse
21
+ | { ok: false; value: unknown; error: v.ValiError<v.GenericSchema<T>> };
22
+
23
+ export async function request<T>(
24
+ input: RequestInfo | URL,
25
+ init?: CustomRequestInit,
26
+ ): Promise<RequestResponse<T>>;
27
+ export async function request<T>(
28
+ input: RequestInfo | URL,
29
+ init: CustomRequestInit & { schema: v.GenericSchema<T> },
30
+ ): Promise<ParsedRequestResponse<T>>;
31
+ export async function request<T>(
32
+ input: RequestInfo | URL,
33
+ init: CustomRequestInit & { schema?: v.GenericSchema<T> } = {},
34
+ ): Promise<ParsedRequestResponse<T>> {
35
+ const { credentials = "include" } = init;
36
+
37
+ const headers = new Headers(init.headers);
38
+ headers.set("Accept", "application/json");
39
+ if (credentials === "include") {
40
+ const token = await readToken();
41
+ if (token) headers.set("Cookie", `SESSION=fake_session; prismic-auth=${token}`);
42
+ }
43
+ if (!headers.has("Content-Type") && init.body) {
44
+ headers.set("Content-Type", "application/json");
45
+ }
46
+ if (init.body instanceof FormData) {
47
+ headers.delete("Content-Type");
48
+ }
49
+
50
+ const body =
51
+ headers.get("Content-Type") === "application/json"
52
+ ? JSON.stringify(init.body)
53
+ : (init.body as RequestInit["body"]);
54
+
55
+ const response = await fetch(input, { ...init, body, headers });
56
+
57
+ const value = await response
58
+ .clone()
59
+ .json()
60
+ .catch(() => response.clone().text());
61
+
62
+ if (response.ok) {
63
+ if (!init?.schema) return { ok: true, value };
64
+
65
+ try {
66
+ const parsed = v.parse(init.schema, value);
67
+ return { ok: true, value: parsed };
68
+ } catch (error) {
69
+ if (v.isValiError<v.GenericSchema<T>>(error)) {
70
+ return { ok: false, value, error };
71
+ }
72
+ throw error;
73
+ }
74
+ } else {
75
+ if (response.status === 401) {
76
+ const error = new UnauthorizedRequestError(response);
77
+ return { ok: false, value, error };
78
+ } else if (response.status === 403) {
79
+ const error = new ForbiddenRequestError(response);
80
+ return { ok: false, value, error };
81
+ } else {
82
+ const error = new RequestError(response);
83
+ return { ok: false, value, error };
84
+ }
85
+ }
86
+ }
87
+
88
+ export class RequestError extends Error {
89
+ name = "RequestError" as const;
90
+ response: Response;
91
+
92
+ constructor(response: Response) {
93
+ super(`fetch failed: ${response.url}`);
94
+ this.response = response;
95
+ }
96
+
97
+ async text(): Promise<string> {
98
+ return this.response.clone().text();
99
+ }
100
+
101
+ async json(): Promise<unknown> {
102
+ return this.response.clone().json();
103
+ }
104
+
105
+ get status(): number {
106
+ return this.response.status;
107
+ }
108
+
109
+ get statusText(): string {
110
+ return this.response.statusText;
111
+ }
112
+ }
113
+
114
+ export class ForbiddenRequestError extends RequestError {}
115
+
116
+ export class UnauthorizedRequestError extends RequestError {}
@@ -0,0 +1,145 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import packageJson from "../../package.json" with { type: "json" };
8
+
9
+ const SEGMENT_WRITE_KEY =
10
+ process.env.PRISMIC_ENV && process.env.PRISMIC_ENV !== "production"
11
+ ? "Ng5oKJHCGpSWplZ9ymB7Pu7rm0sTDeiG"
12
+ : "cGjidifKefYb6EPaGaqpt8rQXkv5TD6P";
13
+ const SEGMENT_TRACK_URL = "https://api.segment.io/v1/track";
14
+
15
+ let enabled = false;
16
+ let anonymousId = "";
17
+ let authorization = "";
18
+ const appContext = { app: { name: packageJson.name, version: packageJson.version } };
19
+ const eventQueue: Array<{ event: string; properties: Record<string, unknown> }> = [];
20
+
21
+ export async function initSegment(): Promise<void> {
22
+ try {
23
+ enabled = await isTelemetryEnabled();
24
+ if (!enabled) {
25
+ return;
26
+ }
27
+
28
+ anonymousId = randomUUID();
29
+ authorization = `Basic ${btoa(SEGMENT_WRITE_KEY + ":")}`;
30
+ process.on("exit", flushTelemetry);
31
+ } catch {
32
+ enabled = false;
33
+ }
34
+ }
35
+
36
+ export function trackStart(command: string): void {
37
+ if (!enabled) {
38
+ return;
39
+ }
40
+
41
+ eventQueue.push({
42
+ event: "Prismic CLI Start",
43
+ properties: {
44
+ commandType: command,
45
+ fullCommand: process.argv.join(" "),
46
+ },
47
+ });
48
+ }
49
+
50
+ export function trackEnd(
51
+ command: string,
52
+ success: boolean,
53
+ error?: unknown,
54
+ ): void {
55
+ if (!enabled) {
56
+ return;
57
+ }
58
+
59
+ const properties: Record<string, unknown> = {
60
+ commandType: command,
61
+ fullCommand: process.argv.join(" "),
62
+ success,
63
+ };
64
+
65
+ if (error !== undefined) {
66
+ const message = error instanceof Error ? error.message : String(error);
67
+ properties.error = message.slice(0, 512);
68
+ }
69
+
70
+ eventQueue.push({ event: "Prismic CLI End", properties });
71
+ }
72
+
73
+ /**
74
+ * Spawns a detached subprocess to send queued telemetry events.
75
+ * The main process exits immediately; the subprocess handles HTTP delivery.
76
+ */
77
+ function flushTelemetry(): void {
78
+ if (eventQueue.length === 0) {
79
+ return;
80
+ }
81
+
82
+ try {
83
+ const payload = Buffer.from(
84
+ JSON.stringify({
85
+ url: SEGMENT_TRACK_URL,
86
+ authorization,
87
+ events: eventQueue.map((e) => ({
88
+ anonymousId,
89
+ event: e.event,
90
+ properties: { nodeVersion: process.versions.node, ...e.properties },
91
+ context: appContext,
92
+ timestamp: new Date().toISOString(),
93
+ })),
94
+ }),
95
+ ).toString("base64");
96
+
97
+ const child = spawn(
98
+ process.execPath,
99
+ ["--input-type=module", "-e", FLUSH_SCRIPT, payload],
100
+ { detached: true, stdio: "ignore" },
101
+ );
102
+ child.unref();
103
+ } catch {
104
+ // Silent failure — never breaks the CLI
105
+ }
106
+
107
+ eventQueue.length = 0;
108
+ }
109
+
110
+ const FLUSH_SCRIPT = `
111
+ const {url, authorization, events} = JSON.parse(Buffer.from(process.argv[1], "base64"));
112
+ const h = {"Content-Type": "application/json", Authorization: authorization};
113
+ await Promise.allSettled(events.map(e => fetch(url, {method: "POST", headers: h, body: JSON.stringify(e)}).catch(() => {})));
114
+ `;
115
+
116
+ async function isTelemetryEnabled(): Promise<boolean> {
117
+ try {
118
+ // Check user-level .prismicrc
119
+ const userRc = await readJsonFile(join(homedir(), ".prismicrc"));
120
+ if (userRc?.telemetry === false) {
121
+ return false;
122
+ }
123
+
124
+ // Check project-level .prismicrc
125
+ const projectRc = await readJsonFile(join(process.cwd(), ".prismicrc"));
126
+ if (projectRc?.telemetry === false) {
127
+ return false;
128
+ }
129
+
130
+ return true;
131
+ } catch {
132
+ return true;
133
+ }
134
+ }
135
+
136
+ async function readJsonFile(
137
+ path: string,
138
+ ): Promise<Record<string, unknown> | undefined> {
139
+ try {
140
+ const contents = await readFile(path, "utf8");
141
+ return JSON.parse(contents);
142
+ } catch {
143
+ return undefined;
144
+ }
145
+ }
@@ -0,0 +1,63 @@
1
+ import * as Sentry from "@sentry/node-core/light";
2
+
3
+ import packageJson from "../../package.json" with { type: "json" };
4
+
5
+ const SENTRY_DSN =
6
+ import.meta.env.PRISMIC_SENTRY_DSN ||
7
+ "https://e1886b1775bd397cd1afc60bfd2ebfc8@o146123.ingest.us.sentry.io/4510445143588864";
8
+
9
+ function isSentryEnabled(): boolean {
10
+ if (import.meta.env.PRISMIC_SENTRY_ENABLED === undefined) {
11
+ return import.meta.env.PROD;
12
+ }
13
+
14
+ return import.meta.env.PRISMIC_SENTRY_ENABLED === "true";
15
+ }
16
+
17
+ export function setupSentry(): void {
18
+ try {
19
+ if (!isSentryEnabled()) {
20
+ return;
21
+ }
22
+
23
+ Sentry.init({
24
+ dsn: SENTRY_DSN,
25
+ release: packageJson.version,
26
+ environment: import.meta.env.PRISMIC_SENTRY_ENVIRONMENT ?? "production",
27
+ defaultIntegrations: false,
28
+ integrations: [],
29
+ maxValueLength: 2_500,
30
+ });
31
+
32
+ Sentry.setContext("Process", {
33
+ command: process.argv.join(" "),
34
+ cwd: process.cwd(),
35
+ });
36
+ } catch {
37
+ // Silent failure — never breaks the CLI
38
+ }
39
+ }
40
+
41
+ export async function captureError(error: unknown): Promise<void> {
42
+ try {
43
+ if (!isSentryEnabled()) {
44
+ return;
45
+ }
46
+
47
+ Sentry.captureException(
48
+ error,
49
+ error instanceof Error
50
+ ? { extra: { cause: error.cause, fullCommand: process.argv.join(" ") } }
51
+ : {},
52
+ );
53
+
54
+ await Sentry.flush();
55
+ } catch {
56
+ // Silent failure — never breaks the CLI
57
+ }
58
+ }
59
+
60
+ // Re-exports for future devtools-parity integration points
61
+ export const setUser = Sentry.setUser;
62
+ export const setTag = Sentry.setTag;
63
+ export const setContext = Sentry.setContext;
@@ -0,0 +1,10 @@
1
+ import baseDedent from "dedent";
2
+
3
+ export function humanReadable(id: string): string {
4
+ return id
5
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
6
+ .replace(/[_-]+/g, " ")
7
+ .replace(/\b\w/g, (c) => c.toUpperCase());
8
+ }
9
+
10
+ export const dedent = baseDedent.withOptions({ alignValues: true });
package/src/lib/url.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { readHost } from "./auth";
2
+
3
+ export async function getRepoUrl(repo: string): Promise<URL> {
4
+ const host = await readHost();
5
+ host.hostname = `${repo}.${host.hostname}`;
6
+ return appendTrailingSlash(host);
7
+ }
8
+
9
+ export async function getInternalApiUrl(): Promise<URL> {
10
+ const host = await readHost();
11
+ host.hostname = `api.internal.${host.hostname}`;
12
+ return appendTrailingSlash(host);
13
+ }
14
+
15
+ export async function getUserServiceUrl(): Promise<URL> {
16
+ const host = await readHost();
17
+ host.hostname = `user-service.${host.hostname}`;
18
+ return appendTrailingSlash(host);
19
+ }
20
+
21
+ export async function getAuthUrl(): Promise<URL> {
22
+ const host = await readHost();
23
+ host.hostname = `auth.${host.hostname}`;
24
+ return appendTrailingSlash(host);
25
+ }
26
+
27
+ export function appendTrailingSlash(url: string | URL): URL {
28
+ const newURL = new URL(url);
29
+ if (!newURL.pathname.endsWith("/")) newURL.pathname += "/";
30
+ return newURL;
31
+ }
package/src/login.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { exec } from "node:child_process";
2
+ import { parseArgs } from "node:util";
3
+
4
+ import { createLoginSession } from "./lib/auth";
5
+
6
+ const HELP = `
7
+ Log in to Prismic via browser.
8
+
9
+ USAGE
10
+ prismic login [flags]
11
+
12
+ FLAGS
13
+ -h, --help Show help for command
14
+
15
+ LEARN MORE
16
+ Use \`prismic <command> --help\` for more information about a command.
17
+ `.trim();
18
+
19
+ export async function login(): Promise<void> {
20
+ const { values } = parseArgs({
21
+ args: process.argv.slice(3),
22
+ options: { help: { type: "boolean", short: "h" } },
23
+ });
24
+
25
+ if (values.help) {
26
+ console.info(HELP);
27
+ return;
28
+ }
29
+
30
+ const { email } = await createLoginSession({
31
+ onReady: (url) => {
32
+ console.info("Opening browser to complete login...");
33
+ console.info(`If the browser doesn't open, visit: ${url}`);
34
+ openBrowser(url);
35
+ },
36
+ });
37
+
38
+ console.info(`Logged in to Prismic as ${email}`);
39
+ }
40
+
41
+ function openBrowser(url: URL): void {
42
+ const cmd =
43
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
44
+ exec(`${cmd} "${url.toString()}"`);
45
+ }
package/src/logout.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { parseArgs } from "node:util";
2
+
3
+ import { removeToken } from "./lib/auth";
4
+
5
+ const HELP = `
6
+ Log out of Prismic.
7
+
8
+ USAGE
9
+ prismic logout [flags]
10
+
11
+ FLAGS
12
+ -h, --help Show help for command
13
+
14
+ LEARN MORE
15
+ Use \`prismic <command> --help\` for more information about a command.
16
+ `.trim();
17
+
18
+ export async function logout(): Promise<void> {
19
+ const { values } = parseArgs({
20
+ args: process.argv.slice(3),
21
+ options: { help: { type: "boolean", short: "h" } },
22
+ });
23
+
24
+ if (values.help) {
25
+ console.info(HELP);
26
+ return;
27
+ }
28
+
29
+ const ok = await removeToken();
30
+ if (ok) {
31
+ console.info("Logged out of Prismic");
32
+ } else {
33
+ console.error("Logout failed. You can log out manually by deleting the file.");
34
+ process.exitCode = 1;
35
+ }
36
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,259 @@
1
+ import { createHash } from "node:crypto";
2
+ import { setTimeout } from "node:timers/promises";
3
+ import { parseArgs } from "node:util";
4
+
5
+ import { isAuthenticated } from "./lib/auth";
6
+ import { safeGetRepositoryFromConfig } from "./lib/config";
7
+ import { fetchRemoteCustomTypes, fetchRemoteSlices } from "./lib/custom-types-api";
8
+ import { type FrameworkAdapter, getFramework } from "./framework";
9
+ import { trackEnd } from "./lib/segment";
10
+ import { dedent } from "./lib/string";
11
+
12
+ const HELP = `
13
+ Sync slices, page types, and custom types from Prismic to local files.
14
+
15
+ Remote models are the source of truth. Local files are created, updated,
16
+ or deleted to match.
17
+
18
+ USAGE
19
+ prismic sync [flags]
20
+
21
+ FLAGS
22
+ -r, --repo string Repository domain
23
+ -w, --watch Watch for changes and sync continuously
24
+ -h, --help Show help for command
25
+ `.trim();
26
+
27
+ // 5 seconds balances responsiveness with API load
28
+ const POLL_INTERVAL_MS = 5000;
29
+ const MAX_BACKOFF_MS = 60000; // Cap backoff at 1 minute
30
+ const MAX_CONSECUTIVE_ERRORS = 10;
31
+
32
+ export async function sync(): Promise<void> {
33
+ const {
34
+ values: { help, repo = await safeGetRepositoryFromConfig(), watch },
35
+ } = parseArgs({
36
+ args: process.argv.slice(3), // skip: node, script, "sync"
37
+ options: {
38
+ repo: { type: "string", short: "r" },
39
+ watch: { type: "boolean", short: "w" },
40
+ help: { type: "boolean", short: "h" },
41
+ },
42
+ allowPositionals: false,
43
+ });
44
+
45
+ if (help) {
46
+ console.info(HELP);
47
+ return;
48
+ }
49
+
50
+ if (!repo) {
51
+ console.error("Missing prismic.config.json or --repo option");
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+
56
+ if (!(await isAuthenticated())) {
57
+ console.error("Not logged in. Run `prismic login` first.");
58
+ process.exitCode = 1;
59
+ return;
60
+ }
61
+
62
+ const framework = await getFramework();
63
+ if (!framework) {
64
+ console.error("Could not detect a supported framework (Next.js, Nuxt, or SvelteKit).");
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+
69
+ console.info(`Syncing from repository: ${repo}`);
70
+
71
+ if (watch) {
72
+ await watchForChanges(repo, framework);
73
+ } else {
74
+ await syncSlices(repo, framework);
75
+ await syncCustomTypes(repo, framework);
76
+
77
+ console.info("Sync complete");
78
+ }
79
+ }
80
+
81
+ async function watchForChanges(repo: string, framework: FrameworkAdapter) {
82
+ const remoteSlicesResult = await fetchRemoteSlices(repo);
83
+ if (!remoteSlicesResult.ok) {
84
+ console.error(`Failed to fetch remote slices: ${remoteSlicesResult.error}`);
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+ const initialRemoteSlices = remoteSlicesResult.value;
89
+
90
+ const remoteCustomTypesResult = await fetchRemoteCustomTypes(repo);
91
+ if (!remoteCustomTypesResult.ok) {
92
+ console.error(`Failed to fetch remote custom types: ${remoteCustomTypesResult.error}`);
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+ const initialRemoteCustomTypes = remoteCustomTypesResult.value;
97
+
98
+ await syncSlices(repo, framework);
99
+ await syncCustomTypes(repo, framework);
100
+
101
+ console.info(dedent`
102
+ Initial sync completed!
103
+
104
+ Watching for changes (polling every ${POLL_INTERVAL_MS / 1000}s),
105
+ Press Ctrl+C to stop
106
+ `);
107
+
108
+ let lastRemoteSlicesHash = hash(initialRemoteSlices);
109
+ let lastRemoteCustomTypesHash = hash(initialRemoteCustomTypes);
110
+
111
+ let consecutiveErrors = 0;
112
+
113
+ // Handle all common termination signals
114
+ process.on("SIGINT", shutdown); // Ctrl+C
115
+ process.on("SIGTERM", shutdown); // kill command
116
+ process.on("SIGHUP", shutdown); // terminal closed
117
+ process.on("SIGQUIT", shutdown); // Ctrl+\
118
+ if (process.platform === "win32") {
119
+ process.on("SIGBREAK", shutdown); // Windows Ctrl+Break
120
+ }
121
+
122
+ while (true) {
123
+ await setTimeout(exponentialMs(consecutiveErrors));
124
+
125
+ try {
126
+ const remoteSlicesResult = await fetchRemoteSlices(repo);
127
+ if (!remoteSlicesResult.ok) continue;
128
+ const remoteSlicesHash = hash(remoteSlicesResult.value);
129
+ const slicesChanged = remoteSlicesHash !== lastRemoteSlicesHash;
130
+
131
+ const remoteCustomTypesResult = await fetchRemoteCustomTypes(repo);
132
+ if (!remoteCustomTypesResult.ok) continue;
133
+ const remoteCustomTypesHash = hash(remoteCustomTypesResult.value);
134
+ const customTypesChanged = remoteCustomTypesHash !== lastRemoteCustomTypesHash;
135
+
136
+ if (slicesChanged || customTypesChanged) {
137
+ if (slicesChanged) {
138
+ await syncSlices(repo, framework);
139
+ lastRemoteSlicesHash = remoteSlicesHash;
140
+ }
141
+ if (customTypesChanged) {
142
+ await syncCustomTypes(repo, framework);
143
+ lastRemoteCustomTypesHash = remoteCustomTypesHash;
144
+ }
145
+ }
146
+
147
+ // Reset error count on success
148
+ consecutiveErrors = 0;
149
+ } catch (error) {
150
+ consecutiveErrors++;
151
+
152
+ const message = error instanceof Error ? error.message : "Unknown error";
153
+
154
+ const nextDelay = Math.min(
155
+ POLL_INTERVAL_MS * Math.pow(2, consecutiveErrors - 1),
156
+ MAX_BACKOFF_MS,
157
+ );
158
+
159
+ console.error(`Error checking for changes: ${message}. Retrying in ${nextDelay / 1000}s...`);
160
+
161
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
162
+ throw new Error(`Too many consecutive errors (${MAX_CONSECUTIVE_ERRORS}), stopping watch.`);
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ export async function syncSlices(repo: string, framework: FrameworkAdapter): Promise<void> {
169
+ const remoteSlicesResult = await fetchRemoteSlices(repo);
170
+ if (!remoteSlicesResult.ok) {
171
+ console.error(`Failed to fetch remote slices: ${remoteSlicesResult.error}`);
172
+ process.exitCode = 1;
173
+ return;
174
+ }
175
+ const remoteSlices = remoteSlicesResult.value;
176
+ const localSlices = await framework.getSlices();
177
+
178
+ // Handle slices update
179
+ for (const remoteSlice of remoteSlices) {
180
+ const localSlice = localSlices.find((slice) => slice.model.id === remoteSlice.id);
181
+ if (localSlice) {
182
+ await framework.updateSlice(remoteSlice);
183
+ }
184
+ }
185
+
186
+ // Handle slices deletion
187
+ for (const localSlice of localSlices) {
188
+ const existsRemotely = remoteSlices.some((slice) => slice.id === localSlice.model.id);
189
+ if (!existsRemotely) {
190
+ await framework.deleteSlice(localSlice.model.id);
191
+ }
192
+ }
193
+
194
+ // Handle slices creation
195
+ const defaultLibrary = await framework.getDefaultSliceLibrary();
196
+ for (const remoteSlice of remoteSlices) {
197
+ const existsLocally = localSlices.some((slice) => slice.model.id === remoteSlice.id);
198
+ if (!existsLocally) {
199
+ await framework.createSlice(remoteSlice, defaultLibrary);
200
+ }
201
+ }
202
+ }
203
+
204
+ export async function syncCustomTypes(repo: string, framework: FrameworkAdapter): Promise<void> {
205
+ const remoteCustomTypesResult = await fetchRemoteCustomTypes(repo);
206
+ if (!remoteCustomTypesResult.ok) {
207
+ console.error(`Failed to fetch remote custom types: ${remoteCustomTypesResult.error}`);
208
+ process.exitCode = 1;
209
+ return;
210
+ }
211
+ const remoteCustomTypes = remoteCustomTypesResult.value;
212
+ const localCustomTypes = await framework.getCustomTypes();
213
+
214
+ // Handle custom types update
215
+ for (const remoteCustomType of remoteCustomTypes) {
216
+ const localCustomType = localCustomTypes.find(
217
+ (customType) => customType.model.id === remoteCustomType.id,
218
+ );
219
+ if (localCustomType) {
220
+ await framework.updateCustomType(remoteCustomType);
221
+ }
222
+ }
223
+
224
+ // Handle custom types deletion
225
+ for (const localCustomType of localCustomTypes) {
226
+ const existsRemotely = remoteCustomTypes.some(
227
+ (customType) => customType.id === localCustomType.model.id,
228
+ );
229
+ if (!existsRemotely) {
230
+ await framework.deleteCustomType(localCustomType.model.id);
231
+ }
232
+ }
233
+
234
+ // Handle custom types creation
235
+ for (const remoteCustomType of remoteCustomTypes) {
236
+ const existsLocally = localCustomTypes.some(
237
+ (customType) => customType.model.id === remoteCustomType.id,
238
+ );
239
+ if (!existsLocally) {
240
+ await framework.createCustomType(remoteCustomType);
241
+ }
242
+ }
243
+ }
244
+
245
+ function shutdown(): void {
246
+ console.info("Watch stopped. Goodbye!");
247
+ trackEnd("sync", true);
248
+ process.exit(0);
249
+ }
250
+
251
+ // Exponential backoff: 5s, 10s, 20s, 40s, 60s (capped)
252
+ function exponentialMs(base: number): number {
253
+ if (base === 0) return POLL_INTERVAL_MS;
254
+ return Math.min(POLL_INTERVAL_MS * Math.pow(2, base - 1), MAX_BACKOFF_MS);
255
+ }
256
+
257
+ function hash(data: unknown): string {
258
+ return createHash("sha256").update(JSON.stringify(data)).digest("hex");
259
+ }