Fork Horse repository, listed by its owner. Publisher snapshot.
scripts/deploy.ts
Snapshot at 34e3175
1import { withCoreSecrets } from "./core-secrets.ts";
2import { existsSync } from "node:fs";
3import { readFile, rm, writeFile } from "node:fs/promises";
4import { spawnSync } from "node:child_process";
5import { fileURLToPath, pathToFileURL } from "node:url";
6import { dirname, join, relative, resolve } from "node:path";
7import { parse, printParseErrorCode, type ParseError } from "jsonc-parser";
8import { pnpmCommand } from "../cloudflare-os/scripts/pnpm-command.ts";
9import { resolveBinEntry } from "../cloudflare-os/scripts/bin-entry.ts";
10import { coreConfig, validateCore } from "./core-deployment.ts";
11import { validateForkhorseGatekeeper } from "./gatekeeper-deployment.ts";
12import { AI_GATEWAY_PROVIDERS } from "./deployment-config.ts";
13import type {
14 BaseConfigs,
15 BuildCommand,
16 DeploymentConfig,
17 GeneratedConfigs,
18 ProdWranglerConfig,
19 RouterRoute,
20} from "./deployment-config.ts";
21
22const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
23// One deployment per checkout; use separate worktrees for concurrent deploys.
24const generatedName = "wrangler.prod.jsonc";
25const packageDirs = {
26 router: "cloudflare-os/packages/router",
27 workshop: "cloudflare-os/packages/workshop-backend",
28 context: "cloudflare-os/packages/gatekeeper-context",
29 scheduler: "cloudflare-os/packages/gatekeeper-scheduler",
30 mcp: "cloudflare-os/packages/gatekeeper-mcp",
31 errorReporter: "packages/error-reporter",
32 core: "packages/forkhorse-core",
33 forkhorseGatekeeper: "packages/forkhorse-gatekeeper",
34} as const;
35const generatedPaths = Object.fromEntries(
36 Object.entries(packageDirs).map(([name, dir]) => [name, join(root, dir, generatedName)]),
37) as Record<keyof typeof packageDirs, string>;
38const defaultContextArtifactsNamespace = "gatekeeper-context-collections";
39const accountIdPattern = /^[a-f\d]{32}$/i;
40
41const requiredPaths = [
42 "accountId",
43 "workers.router.name",
44 "workers.workshop.name",
45 "workers.context.name",
46 "workers.scheduler.name",
47 "workers.mcp.name",
48 "access.issuer",
49 "access.audience",
50 "access.admins",
51 "aiGateway.enabled",
52 "errorReporting.enabled",
53 "observability.enabled",
54 "observability.headSamplingRate",
55 "observability.logs.invocationLogs",
56 "observability.traces.enabled",
57 "observability.traces.headSamplingRate",
58];
59
60// `aiGateway.accountId` is deliberately absent: null is its normal value, meaning "the gateway
61// lives in the deployment's own account".
62const aiGatewayPaths = [
63 "aiGateway.name",
64 "aiGateway.providers",
65];
66
67const errorReportingPaths = [
68 "workers.errorReporter.name",
69 "errorReporting.environment",
70];
71
72const resourcePaths = [
73 "context.kvNamespaceId",
74 "resources.blueprintsKvNamespaceId",
75 "resources.avatarsKvNamespaceId",
76 "resources.blueprintContentBucket",
77];
78
79function valueAt(object: DeploymentConfig, path: string): unknown {
80 return path.split(".").reduce<unknown>(
81 (value, key) => (value as Record<string, unknown> | undefined)?.[key], object);
82}
83
84/**
85 * The deployment's public origin: the address the router answers on.
86 *
87 * Two things read it, and both are load-bearing in different ways. `PUBLIC_BASE_URL` is what
88 * upstream builds absolute links and OAuth redirect URIs from. The Context Gatekeeper's
89 * `sharingDomain` prop is a data-isolation boundary, so a value that changes silently hides
90 * collections rather than breaking a link.
91 *
92 * `validateConfig` guarantees that one of the two sources below is present, and that they agree when
93 * both are.
94 */
95export function publicOrigin(config: DeploymentConfig): string {
96 const explicit = config.publicBaseUrl;
97 if (explicit) return explicit.replace(/\/$/, "");
98 return `https://${config.workers.router.route.customDomain!}`;
99}
100
101function validatePublicBaseUrl(config: DeploymentConfig, route: RouterRoute): void {
102 const value = config.publicBaseUrl;
103 if (value === undefined) {
104 throw new Error(
105 "publicBaseUrl must be present. Use null to derive it from " +
106 "workers.router.route.customDomain.");
107 }
108 if (value === null) {
109 if (!route.customDomain) {
110 throw new Error(
111 "publicBaseUrl is required on a workersDev route. The account's workers.dev subdomain is " +
112 "not in deployment.jsonc and wrangler exposes no command to look it up, so there is " +
113 "nothing to derive the public origin from -- and PUBLIC_BASE_URL and the Context sharing " +
114 "boundary both need one. Set it to https://<router-name>.<subdomain>.workers.dev.");
115 }
116 return;
117 }
118 if (typeof value !== "string") {
119 throw new Error("publicBaseUrl must be null or a string.");
120 }
121 let origin: string;
122 try {
123 origin = new URL(value).origin;
124 } catch {
125 throw new Error("publicBaseUrl must be an HTTPS origin such as https://os.example.com.");
126 }
127 if (!value.startsWith("https://") || origin !== value) {
128 throw new Error(
129 "publicBaseUrl must be an HTTPS origin only, with no path and no trailing slash.");
130 }
131 if (route.customDomain && value !== `https://${route.customDomain}`) {
132 throw new Error(
133 `publicBaseUrl (${value}) does not match workers.router.route.customDomain ` +
134 `(${route.customDomain}). Context data would then be scoped to a hostname this deployment ` +
135 "does not answer on. Leave publicBaseUrl null to derive it from the custom domain.");
136 }
137 // On a workersDev route there is nothing to cross-check the value against the way a custom domain
138 // checks itself, so check its shape instead. The account's workers.dev subdomain is unknowable
139 // here, but the rest of the hostname is not: wrangler serves the Worker at
140 // <worker-name>.<subdomain>.workers.dev, so anything else is a typo or an unrelated host -- and it
141 // would silently become both PUBLIC_BASE_URL and the Context isolation boundary, hiding existing
142 // Context data and breaking every absolute link and OAuth redirect the backend builds.
143 if (route.workersDev) {
144 const labels = new URL(value).host.split(".");
145 const [worker, subdomain, ...suffix] = labels;
146 const routerName = config.workers.router.name;
147 if (labels.length !== 4 || suffix.join(".") !== "workers.dev" ||
148 !/^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/.test(subdomain ?? "")) {
149 throw new Error(
150 `publicBaseUrl (${value}) is not a workers.dev origin. On a workersDev route it must be ` +
151 `https://${routerName}.<subdomain>.workers.dev, where <subdomain> is the account's ` +
152 "workers.dev subdomain. It becomes PUBLIC_BASE_URL and the Context sharing boundary, so a " +
153 "hostname this deployment does not answer on hides Context data and breaks redirects.");
154 }
155 if (worker !== routerName) {
156 throw new Error(
157 `publicBaseUrl (${value}) names Worker "${worker}", but the router is ` +
158 `"${routerName}". The router is what answers on the public origin, so this origin belongs ` +
159 `to a different Worker. Use https://${routerName}.${subdomain}.workers.dev, or change ` +
160 "workers.router.name if the other name is the one you meant.");
161 }
162 }
163}
164
165export function validateConfig(config: DeploymentConfig): DeploymentConfig {
166 const activePaths = [
167 ...requiredPaths,
168 ...(config.aiGateway?.enabled ? aiGatewayPaths : []),
169 ...(config.errorReporting?.enabled ? errorReportingPaths : []),
170 ];
171 for (const path of activePaths) {
172 const value = valueAt(config, path);
173 if (value === undefined || value === null || value === "" || Array.isArray(value) && !value.length) {
174 throw new Error(`Missing required deployment value: ${path}`);
175 }
176 }
177
178 for (const path of resourcePaths) {
179 const value = valueAt(config, path);
180 if (value === undefined || value !== null && (typeof value !== "string" || !value)) {
181 throw new Error(`Deployment resource must be null or a non-empty string: ${path}`);
182 }
183 }
184
185 let activeConfig: DeploymentConfig = config.aiGateway.enabled
186 ? config
187 : { ...config, aiGateway: { enabled: false } };
188 if (!config.errorReporting.enabled) {
189 activeConfig = {
190 ...activeConfig,
191 workers: { ...activeConfig.workers, errorReporter: undefined },
192 errorReporting: { enabled: false },
193 };
194 }
195 if (!config.core?.enabled) activeConfig = { ...activeConfig, core: undefined };
196 if (!config.forkhorseGatekeeper?.enabled) {
197 activeConfig = { ...activeConfig, forkhorseGatekeeper: undefined };
198 }
199 // The sample is retired. Older owner files may retain these unused placeholders.
200 activeConfig = { ...activeConfig, customGatekeeper: undefined,
201 workers: { ...activeConfig.workers, customGatekeeper: undefined } };
202 const placeholder = JSON.stringify(activeConfig).match(/<[^>]+>/)?.[0];
203 if (placeholder) throw new Error(`Replace deployment placeholder ${placeholder}.`);
204
205 const stringPaths = activePaths.filter((path) => ![
206 "access.admins",
207 "aiGateway.enabled",
208 "aiGateway.providers",
209 "errorReporting.enabled",
210 "observability.enabled",
211 "observability.headSamplingRate",
212 "observability.logs.invocationLogs",
213 "observability.traces.enabled",
214 "observability.traces.headSamplingRate",
215 ].includes(path));
216 for (const path of stringPaths) {
217 if (typeof valueAt(config, path) !== "string") {
218 throw new Error(`Deployment value must be a string: ${path}`);
219 }
220 }
221
222 if (!accountIdPattern.test(config.accountId)) {
223 throw new Error("Cloudflare account IDs must be 32 hexadecimal characters.");
224 }
225 const workerNames = Object.entries(config.workers)
226 .filter(([key]) => key !== "customGatekeeper")
227 .filter(([key]) => key !== "errorReporter" || config.errorReporting.enabled)
228 .map(([, worker]) => worker.name);
229 if (new Set(workerNames).size !== workerNames.length) {
230 throw new Error(
231 "Router, Workshop, Context, Scheduler and MCP Worker names must be unique.");
232 }
233 if (!workerNames.every((name) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name))) {
234 throw new Error("Worker names must use lowercase letters, numbers, and hyphens.");
235 }
236
237 const route = config.workers.router.route;
238 if (!route || Boolean(route.workersDev) === Boolean(route.customDomain)) {
239 throw new Error("Set exactly one router route: workersDev or customDomain.");
240 }
241 if (route.workersDev !== undefined && route.workersDev !== true) {
242 throw new Error("Router workersDev must be boolean true when selected.");
243 }
244 if (route.customDomain !== undefined && typeof route.customDomain !== "string") {
245 throw new Error("Router customDomain must be a string.");
246 }
247 const hostnamePattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
248 if (route.customDomain && !hostnamePattern.test(route.customDomain)) {
249 throw new Error("Router customDomain must be a lowercase hostname.");
250 }
251
252 validatePublicBaseUrl(config, route);
253
254 const sharingDomain = config.context.sharingDomain;
255 if (sharingDomain !== null &&
256 (typeof sharingDomain !== "string" || !sharingDomain.trim())) {
257 throw new Error(
258 "context.sharingDomain must be null or a non-empty string. null scopes Context data to the " +
259 "deployment's public origin, which is what the hosted deploy does.");
260 }
261
262 const issuer = new URL(config.access.issuer);
263 if (issuer.protocol !== "https:" ||
264 issuer.origin !== config.access.issuer.replace(/\/$/, "")) {
265 throw new Error("Cloudflare Access issuer must be an HTTPS origin only.");
266 }
267 if (!config.access.audience.trim() || config.access.audience !== config.access.audience.trim()) {
268 throw new Error("Cloudflare Access audience must not be blank or padded with whitespace.");
269 }
270 if (!Array.isArray(config.access.admins) ||
271 !config.access.admins.every((email) =>
272 typeof email === "string" && /^[^@\s]+@[^@\s]+$/.test(email))) {
273 throw new Error("Every Access administrator must be an email address.");
274 }
275
276 validateAiGateway(config);
277
278 if (typeof config.errorReporting.enabled !== "boolean") {
279 throw new Error("Error reporting enabled must be a boolean.");
280 }
281 const release = config.errorReporting.release;
282 if (release !== null && release !== undefined &&
283 (typeof release !== "string" || !release.trim() || release !== release.trim())) {
284 throw new Error("Error reporting release must be null or a non-padded string.");
285 }
286
287 const artifactsConfig = config.context.artifacts;
288 if (artifactsConfig !== undefined &&
289 (artifactsConfig === null || typeof artifactsConfig !== "object" ||
290 Array.isArray(artifactsConfig))) {
291 throw new Error("Context Artifacts configuration must be an object when present.");
292 }
293 const artifactsEnabled = artifactsConfig?.enabled;
294 if (artifactsEnabled !== undefined && typeof artifactsEnabled !== "boolean") {
295 throw new Error("Context Artifacts enabled must be a boolean.");
296 }
297 const artifactsNamespace = artifactsConfig?.namespace;
298 if (artifactsNamespace !== undefined &&
299 (typeof artifactsNamespace !== "string" ||
300 !/^[a-z\d][a-z\d._-]*$/i.test(artifactsNamespace))) {
301 throw new Error("Context Artifacts namespace must be omitted or start with a letter or number and use only letters, numbers, dots, underscores, and hyphens.");
302 }
303
304 const sampling = config.observability.headSamplingRate;
305 if (typeof config.observability.enabled !== "boolean") {
306 throw new Error("Observability enabled must be a boolean.");
307 }
308 if (typeof sampling !== "number" || sampling < 0 || sampling > 1) {
309 throw new Error("Observability headSamplingRate must be between 0 and 1.");
310 }
311 if (typeof config.observability.logs.invocationLogs !== "boolean" ||
312 typeof config.observability.traces.enabled !== "boolean") {
313 throw new Error("Observability log and trace controls must be booleans.");
314 }
315 const traceSampling = config.observability.traces.headSamplingRate;
316 if (typeof traceSampling !== "number" || traceSampling < 0 || traceSampling > 1) {
317 throw new Error("Observability trace sampling must be between 0 and 1.");
318 }
319 validateCore(config);
320 validateForkhorseGatekeeper(config);
321 return config;
322}
323
324/**
325 * How the Workshop reaches AI Gateway, derived rather than configured.
326 *
327 * The Worker cannot discover its own account at runtime, so it cannot tell whether the gateway it
328 * is pointed at is one its `WORKERS_AI` binding can reach. This script can: it holds both account
329 * IDs. Everything below follows from comparing them.
330 */
331export interface AiGatewayPlan {
332 /** The account owning the gateway, with `aiGateway.accountId: null` resolved. */
333 gatewayAccountId: string;
334 /** Whether the gateway lives outside the deployment's own account. */
335 crossAccount: boolean;
336 /** Whether `CF_AI_GATEWAY_API_TOKEN` has to be installed before the Workshop will start a chat. */
337 needsToken: boolean;
338 /** One sentence per reason a token is needed. Empty when the binding transport covers it all. */
339 tokenReasons: string[];
340}
341
342/**
343 * {@link AiGatewayPlan} for `config`, or null when the deployment advertises no model catalog.
344 *
345 * Both IDs are lowercased first. `accountIdPattern` accepts either case, so the same account can be
346 * written two ways across `accountId` and `aiGateway.accountId`; comparing the raw strings would
347 * read that as cross-account and demand a token for a gateway the binding can reach in-account.
348 * Lowercase is also the form the dashboard and the API expect, so it is what the vars carry.
349 */
350export function aiGatewayPlan(config: DeploymentConfig): AiGatewayPlan | null {
351 if (!config.aiGateway.enabled) return null;
352 const deploymentAccountId = config.accountId.toLowerCase();
353 const gatewayAccountId = (config.aiGateway.accountId ?? config.accountId).toLowerCase();
354 const crossAccount = gatewayAccountId !== deploymentAccountId;
355 const tokenReasons: string[] = [];
356 if (crossAccount) {
357 tokenReasons.push(
358 `aiGateway.accountId (${gatewayAccountId}) is not the deployment account, so the generated ` +
359 "config sets CF_AI_GATEWAY_USE_BINDING=false: the Workers AI binding only reaches gateways " +
360 "in the Worker's own account. That leaves the HTTPS transport, which needs a Run + Read " +
361 "CF_AI_GATEWAY_API_TOKEN. The opt-out is a flag rather than an unbound WORKERS_AI because " +
362 "webFetch's toMarkdown() runs on that binding too.");
363 }
364 if (config.aiGateway.providers?.includes("google")) {
365 tokenReasons.push(
366 "The google provider needs CF_AI_GATEWAY_API_TOKEN: pi's Google adapter refuses a custom " +
367 "fetch, so Google inference cannot ride the Workers AI binding transport.");
368 }
369 return { gatewayAccountId, crossAccount, needsToken: tokenReasons.length > 0, tokenReasons };
370}
371
372/**
373 * The deploy-time half of `AiGatewayConfig`'s constructor checks
374 * (cloudflare-os/packages/workshop-backend/src/ai-gateway.ts), mirroring `resolveAiGateway()` in
375 * cloudflare-os/scripts/preview/staging-config.ts. A configuration the backend would reject belongs
376 * in a failed `pnpm check`, not in somebody's first chat.
377 */
378function validateAiGateway(config: DeploymentConfig): void {
379 if (config.aiGateway.workersAi !== undefined) {
380 throw new Error(
381 "aiGateway.workersAi does nothing: Workers AI rides the same gateway route as every other " +
382 "provider. Leaving the key in place deploys a Workshop with an empty model picker. Delete " +
383 "it; list \"cloudflare\" in aiGateway.providers to keep Workers AI models.");
384 }
385 if (typeof config.aiGateway.enabled !== "boolean") {
386 throw new Error("AI Gateway enabled must be a boolean.");
387 }
388 if (!config.aiGateway.enabled) return;
389
390 const providers = config.aiGateway.providers;
391 if (!Array.isArray(providers) || providers.length === 0 ||
392 !providers.every((provider) => AI_GATEWAY_PROVIDERS.includes(provider))) {
393 throw new Error(
394 `AI Gateway providers must be a non-empty subset of ${AI_GATEWAY_PROVIDERS.join(", ")}.`);
395 }
396
397 const gatewayAccountId = config.aiGateway.accountId;
398 if (gatewayAccountId !== undefined && gatewayAccountId !== null &&
399 (typeof gatewayAccountId !== "string" || !accountIdPattern.test(gatewayAccountId))) {
400 throw new Error(
401 "aiGateway.accountId must be null or 32 hexadecimal characters. null reuses the " +
402 "deployment's own account, which is what lets the Workers AI binding reach the gateway " +
403 "without an API token.");
404 }
405}
406
407function routeConfig(route: RouterRoute) {
408 return route.workersDev
409 ? { workers_dev: true, routes: undefined }
410 : { workers_dev: false, routes: [{ pattern: route.customDomain!, custom_domain: true }] };
411}
412
413function setCommon(
414 config: ProdWranglerConfig,
415 deployment: DeploymentConfig,
416 name: string,
417 route: RouterRoute = { workersDev: false },
418): void {
419 config.account_id = deployment.accountId;
420 config.name = name;
421 config.workers_dev = route.workersDev;
422 delete config.routes;
423 if (route.customDomain) Object.assign(config, routeConfig(route));
424 // The router is the Access-protected origin, and a preview URL is an unauthenticated path around
425 // it. Off on every Worker: the Workers behind the router have no business being publicly reachable
426 // either.
427 config.preview_urls = false;
428 config.observability = {
429 ...config.observability,
430 enabled: deployment.observability.enabled,
431 head_sampling_rate: deployment.observability.headSamplingRate,
432 logs: {
433 ...config.observability?.logs,
434 invocation_logs: deployment.observability.logs.invocationLogs,
435 },
436 traces: {
437 ...config.observability?.traces,
438 enabled: deployment.observability.traces.enabled,
439 head_sampling_rate: deployment.observability.traces.headSamplingRate,
440 },
441 };
442}
443
444export function generateConfigs(config: DeploymentConfig, bases: BaseConfigs): GeneratedConfigs {
445 validateConfig(config);
446 const router = structuredClone(bases.router);
447 const workshop = structuredClone(bases.workshop);
448 const context = structuredClone(bases.context);
449 const scheduler = structuredClone(bases.scheduler);
450 const mcp = structuredClone(bases.mcp);
451 const errorReporter = config.errorReporting.enabled
452 ? structuredClone(bases.errorReporter)
453 : undefined;
454 const core = coreConfig(config, bases);
455 const forkhorseGatekeeper = config.forkhorseGatekeeper?.enabled
456 ? structuredClone(bases.forkhorseGatekeeper)
457 : undefined;
458 if (config.forkhorseGatekeeper?.enabled && !forkhorseGatekeeper) {
459 throw new Error("Missing vendor base Wrangler config.");
460 }
461 const origin = publicOrigin(config);
462
463 setCommon(router, config, config.workers.router.name, config.workers.router.route);
464 router.services = [
465 { binding: "WORKSHOP_BACKEND", service: config.workers.workshop.name },
466 // No entrypoint and no props: the router forwards whole HTTP requests, unlike the backend's
467 // vendor-RPC bindings. The binding name is what picks the /gatekeeper/<name> path.
468 { binding: "GATEKEEPER_CONTEXT", service: config.workers.context.name },
469 { binding: "GATEKEEPER_SCHEDULER", service: config.workers.scheduler.name },
470 { binding: "GATEKEEPER_MCP", service: config.workers.mcp.name },
471 ];
472
473 setCommon(workshop, config, config.workers.workshop.name);
474 workshop.vars = {
475 ADMINS: config.access.admins,
476 CF_ACCESS_ISS: config.access.issuer.replace(/\/$/, ""),
477 CF_ACCESS_AUD: config.access.audience,
478 // Upstream builds OAuth redirect URIs and other absolute links from this. The backend has no
479 // public route of its own, so the router's origin is the only correct value.
480 PUBLIC_BASE_URL: origin,
481 };
482 const gateway = aiGatewayPlan(config);
483 if (gateway) {
484 Object.assign(workshop.vars, {
485 CF_AI_GATEWAY: config.aiGateway.name,
486 CF_AI_GATEWAY_ACCOUNT_ID: gateway.gatewayAccountId,
487 CF_AI_GATEWAY_PROVIDERS: config.aiGateway.providers!.join(","),
488 ...(gateway.crossAccount ? { CF_AI_GATEWAY_USE_BINDING: "false" } : {}),
489 });
490 // Only when a token is genuinely needed. On the common path the WORKERS_AI binding is the
491 // transport and is pre-authenticated in-account, so demanding a secret would block a deploy
492 // that has everything it needs. Where one IS needed, wrangler refusing to deploy without it is
493 // a better check than anything this script could do.
494 if (gateway.needsToken) {
495 workshop.secrets = {
496 required: [...new Set([
497 ...(workshop.secrets?.required ?? []),
498 "CF_AI_GATEWAY_API_TOKEN",
499 ])],
500 };
501 }
502 }
503 // Unconditional: as well as being the AI Gateway transport, this binding is what webFetch's
504 // toMarkdown() runs on.
505 workshop.ai = { binding: "WORKERS_AI" };
506 workshop.services = [
507 ...(config.errorReporting.enabled ? [{
508 binding: "ERROR_REPORTER",
509 service: config.workers.errorReporter!.name,
510 entrypoint: "ErrorReporter",
511 props: {
512 service: config.workers.workshop.name,
513 environment: config.errorReporting.environment,
514 ...(config.errorReporting.release ? { release: config.errorReporting.release } : {}),
515 },
516 }] : []),
517 {
518 binding: "GATEKEEPER_CONTEXT",
519 service: config.workers.context.name,
520 entrypoint: "GatekeeperVendor",
521 props: { sharingDomain: config.context.sharingDomain ?? origin },
522 },
523 // No props: unlike Context, the Scheduler scopes nothing to a domain -- its schedules live in
524 // its own `ScheduleDriver`/`SchedulerGatekeeper` Durable Objects, which belong to that Worker's
525 // script identity and are reached through `ctx.exports` rather than a binding.
526 {
527 binding: "GATEKEEPER_SCHEDULER",
528 service: config.workers.scheduler.name,
529 entrypoint: "GatekeeperVendor",
530 },
531 {
532 binding: "GATEKEEPER_MCP",
533 service: config.workers.mcp.name,
534 entrypoint: "GatekeeperVendor",
535 },
536 ];
537 workshop.kv_namespaces = [
538 { binding: "BLUEPRINTS", ...(config.resources.blueprintsKvNamespaceId
539 ? { id: config.resources.blueprintsKvNamespaceId } : {}) },
540 { binding: "AVATARS", ...(config.resources.avatarsKvNamespaceId
541 ? { id: config.resources.avatarsKvNamespaceId } : {}) },
542 ];
543 workshop.r2_buckets = [
544 { binding: "BLUEPRINT_CONTENT", ...(config.resources.blueprintContentBucket
545 ? { bucket_name: config.resources.blueprintContentBucket } : {}) },
546 ];
547 // The OS router serves its frontend; the optional core has its own separate public route.
548 delete workshop.assets;
549
550 setCommon(context, config, config.workers.context.name);
551 context.kv_namespaces = [
552 { binding: "CONTEXT_COLLECTIONS", ...(config.context.kvNamespaceId
553 ? { id: config.context.kvNamespaceId } : {}) },
554 ];
555 if (config.context.artifacts?.enabled ?? false) {
556 context.artifacts = [{
557 binding: "ARTIFACTS",
558 namespace: config.context.artifacts?.namespace ?? defaultContextArtifactsNamespace,
559 }];
560 } else {
561 delete context.artifacts;
562 }
563
564 // Nothing but the common block: the Scheduler takes no vars, no secrets and no storage bindings of
565 // its own -- which is what makes it installable with no user interaction upstream, and deployable
566 // here without adding a configuration surface for it.
567 setCommon(scheduler, config, config.workers.scheduler.name);
568
569 // Preserve upstream's SQLite DO migrations and secure endpoint checks. OAuth callbacks return
570 // through the router's MCP HTTP binding, not the upstream localhost development fallback.
571 setCommon(mcp, config, config.workers.mcp.name);
572 mcp.vars = { ...mcp.vars, BASE_URL: `${origin}/gatekeeper/mcp` };
573
574 if (errorReporter) {
575 setCommon(errorReporter, config, config.workers.errorReporter!.name);
576 }
577
578 if (core) setCommon(core, config, config.core!.workerName, { customDomain: config.core!.hostname });
579
580 if (forkhorseGatekeeper) {
581 const service = config.forkhorseGatekeeper!.workerName;
582 setCommon(forkhorseGatekeeper, config, service);
583 forkhorseGatekeeper.vars = { BASE_URL: `${origin}/gatekeeper/forkhorse` };
584 // HTTP forwarding and vendor RPC use separate entrypoints, matching pinned OS discovery.
585 router.services!.push({ binding: "GATEKEEPER_FORKHORSE", service });
586 workshop.services!.push({ binding: "GATEKEEPER_FORKHORSE", service, entrypoint: "GatekeeperVendor" });
587 }
588
589 return {
590 ...(forkhorseGatekeeper && { forkhorseGatekeeper }),
591 ...(core && { core }),
592 router, workshop, context, scheduler, mcp,
593 ...(errorReporter && { errorReporter }),
594 };
595}
596
597// `--no-cache` goes before the task name. Everything after it is `[ADDITIONAL_ARGS]`, forwarded to
598// the task's own command -- `vp run -F x build --no-cache` reaches `tsc` as an unknown option.
599
600/** `vp run --no-cache <task>` for a package in the submodule's workspace. */
601function submoduleBuild(pkg: string, task = "build"): string[] {
602 return ["--dir", "cloudflare-os", "exec", "vp", "run", "-F", pkg, "--no-cache", task];
603}
604
605/** `vp run --no-cache <task>` for a package in this repository's own workspace. */
606function ownBuild(pkg: string, task = "build"): string[] {
607 return ["exec", "vp", "run", "-F", pkg, "--no-cache", task];
608}
609
610/**
611 * The build steps `pnpm check` and `pnpm deploy` run, in order, from the repository root.
612 *
613 * Every one goes through `vp run` rather than `pnpm --filter <pkg> build`. Two of the three
614 * submodule targets have no `build` *script* at all any more -- they have a Vite+ *task*, which
615 * `pnpm --filter` cannot see -- and `vp run` runs scripts and tasks alike, so one form covers both.
616 *
617 * `--no-cache` on every one. A cache hit is only as good as its fingerprint, which is cheap to get
618 * wrong on a build you can re-run and expensive on a deploy you cannot; it is upstream's rule for
619 * the same reason (cloudflare-os/scripts/deploy-scripts.test.ts). It also restores the full ambient
620 * environment, which is the belt to `workshop-frontend`'s `env: ['VITE_*']` braces: under a *cached*
621 * `vp` run only declared patterns survive, and an undeclared variable is dropped from the command
622 * and from the fingerprint both.
623 *
624 * The ordering matters at the end: the frontend has to build before the router deploy picks up
625 * `../workshop-frontend/dist` as its assets.
626 */
627export function buildCommands(config: DeploymentConfig): BuildCommand[] {
628 return [
629 // `build:app` first, and separately. `gatekeeper-context`'s `build` is a package.json script
630 // that spawns `vp run --cache build:app` itself, and the outer `--no-cache` does not reach a
631 // nested invocation carrying its own flag -- measured: the configurator app replayed from
632 // cache. Rebuilding it here from source is what upstream's own `deploy` script does; the
633 // `build` step below then type-checks and replays the bytes this step just wrote.
634 { args: submoduleBuild("@gadgets/gatekeeper-context", "build:app") },
635 { args: submoduleBuild("@gadgets/gatekeeper-context") },
636 // The Scheduler's `build` nests the same cached `vp run build:app`, so it needs the same pair.
637 { args: submoduleBuild("@gadgets/gatekeeper-scheduler", "build:app") },
638 { args: submoduleBuild("@gadgets/gatekeeper-scheduler") },
639 // Match upstream's deploy prerequisite; the package's build task also type-checks the Worker.
640 { args: submoduleBuild("@gadgets/mcp-gatekeeper", "build:configurator") },
641 { args: submoduleBuild("@gadgets/mcp-gatekeeper") },
642 ...(config.core?.enabled ? [
643 { args: ownBuild("forkhorse-mcp") },
644 { args: ownBuild("forkhorse-dashboard") },
645 { args: ownBuild("forkhorse-core") },
646 ] : []),
647 ...(config.forkhorseGatekeeper?.enabled ? [{ args: ownBuild("forkhorse-gatekeeper") }] : []),
648 ...(config.errorReporting.enabled ? [{ args: ownBuild("error-reporter") }] : []),
649 // Access mode is a build-time constant in the frontend bundle (`src/useAuth.ts`), so it is set
650 // here rather than inherited: a bundle built under a different value is wrong, not just stale.
651 { args: submoduleBuild("@gadgets/workshop-frontend"), env: { VITE_CF_ACCESS_MODE: "true" } },
652 { args: submoduleBuild("@gadgets/router") },
653 { args: submoduleBuild("@gadgets/workshop-backend") },
654 ];
655}
656
657/** Dependencies deploy before the Workshop and the router that binds them. */
658export function deploymentOrder(config: DeploymentConfig): (keyof typeof packageDirs)[] {
659 return [
660 ...(config.core?.enabled ? ["core" as const] : []),
661 ...(config.errorReporting.enabled ? ["errorReporter" as const] : []),
662 "context", "scheduler", "mcp",
663 ...(config.forkhorseGatekeeper?.enabled ? ["forkhorseGatekeeper" as const] : []),
664 "workshop", "router",
665 ];
666}
667
668// `allowTrailingComma` because wrangler accepts them and upstream uses them: the Scheduler's base
669// config closes `build`, `migrations` and `observability` with one. Without the option every such
670// comma is a parse *error*, so the deploy refuses a file wrangler itself reads happily.
671const jsoncOptions = { allowTrailingComma: true };
672
673async function readJsonc<T>(path: string): Promise<T> {
674 const errors: ParseError[] = [];
675 const result = parse(await readFile(path, "utf8"), errors, jsoncOptions) as T;
676 if (errors.length) {
677 const where = relative(root, path) || path;
678 throw new Error(`${where}: ${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`);
679 }
680 return result;
681}
682
683// Every validateConfig message names a config path, so say which file those paths live in.
684async function readDeployment(path: string): Promise<DeploymentConfig> {
685 const config = await readJsonc<DeploymentConfig>(path);
686 try {
687 return validateConfig(config);
688 } catch (error) {
689 throw new Error(`${relative(root, path)}: ${(error as Error).message}`, { cause: error });
690 }
691}
692
693function runCommand(
694 command: string,
695 argv: string[],
696 cwd: string,
697 env: NodeJS.ProcessEnv,
698 label: string,
699): void {
700 const result = spawnSync(command, argv, { cwd, env, stdio: "inherit" });
701 if (result.error) throw result.error;
702 if (result.status !== 0) {
703 const where = relative(root, cwd) || ".";
704 throw new Error(`${where}: ${label} failed. Its output is above.`);
705 }
706}
707
708// Spawned through pnpmCommand rather than as a bare "pnpm": on Windows the pnpm on PATH is a `.cmd`
709// shim Node refuses to spawn without a shell, and `shell: true` would re-split argv and break any
710// checkout path containing a space.
711function run(args: string[], cwd = root, env: NodeJS.ProcessEnv = process.env): void {
712 const [command, argv] = pnpmCommand(args, env);
713 runCommand(command, argv, cwd, env, `pnpm ${args.join(" ")}`);
714}
715
716/**
717 * `wrangler deploy` for one package, spawned as `node <entry>` when the entry point behind the
718 * `.bin` shim can be found. That saves the ~0.33s `pnpm exec` costs per call and sidesteps the
719 * Windows `.cmd` shim entirely; when it cannot be resolved, the pnpm path is still there.
720 */
721function deployWorker(dir: string, extraArgs: string[], confidential = false): void {
722 const cwd = join(root, dir);
723 const args = ["deploy", "--config", generatedName, ...extraArgs];
724 const entry = resolveBinEntry(cwd, "wrangler");
725 if (confidential) {
726 // Wrangler diagnostics can echo file contents. Capture and discard this one subprocess's
727 // output and disable its disk log; never propagate raw stderr or an exception message.
728 const env = { ...process.env, WRANGLER_LOG_PATH: process.platform === "win32" ? "NUL" : "/dev/null" };
729 const [command, argv] = entry ? [process.execPath, [entry, ...args]] : pnpmCommand(["exec", "wrangler", ...args], env);
730 const result = spawnSync(command, argv, { cwd, env, stdio: "ignore" });
731 if (result.error || result.status !== 0) throw new Error("Core secret deployment failed.");
732 console.log("Core Worker deployed with required secrets; confidential subprocess output suppressed.");
733 } else if (entry) {
734 runCommand(process.execPath, [entry, ...args], cwd, process.env, `wrangler ${args.join(" ")}`);
735 } else {
736 run(["exec", "wrangler", ...args], cwd);
737 }
738}
739
740function requireSubmodule(): void {
741 if (!existsSync(join(root, "cloudflare-os/package.json"))) {
742 throw new Error("CloudflareOS submodule is not initialized. Run git submodule update --init.");
743 }
744}
745
746function build(config: DeploymentConfig): void {
747 for (const { args, env } of buildCommands(config)) {
748 run(args, root, env ? { ...process.env, ...env } : process.env);
749 }
750}
751
752// Said once, up front, rather than discovered when the first chat throws.
753function reportAiGateway(config: DeploymentConfig): void {
754 const gateway = aiGatewayPlan(config);
755 if (!gateway) {
756 console.warn(
757 "\naiGateway.enabled is false: this deployment advertises no model catalog, and each user " +
758 "supplies their own model API keys. A Workshop migrated from the hosted deploy will show an " +
759 "empty model picker -- see docs/migrate-from-hosted.md.");
760 return;
761 }
762 if (!gateway.needsToken) return;
763 // CLOUDFLARE_ACCOUNT_ID pins the account the way the deploys themselves are pinned: every
764 // generated config carries `account_id`, but `wrangler secret put` takes only `--name`
765 console.warn(
766 `\nCF_AI_GATEWAY_API_TOKEN is required by this configuration:\n` +
767 gateway.tokenReasons.map((reason) => ` - ${reason}`).join("\n") +
768 `\nInstall it before deploying:\n CLOUDFLARE_ACCOUNT_ID=${config.accountId} ` +
769 `pnpm exec wrangler secret put CF_AI_GATEWAY_API_TOKEN ` +
770 `--name ${config.workers.workshop.name}\n`);
771}
772
773async function main(): Promise<void> {
774 requireSubmodule();
775 // Fork Horse: a gitignored deployment.local.jsonc holds an owner's real values (account,
776 // hostname, Access audience, admin emails) so the tracked deployment.jsonc stays a template.
777 const localConfig = join(root, "deployment.local.jsonc");
778 const config = await readDeployment(existsSync(localConfig) ? localConfig : join(root, "deployment.jsonc"));
779 const generated = generateConfigs(config, {
780 ...(config.forkhorseGatekeeper?.enabled ? {
781 forkhorseGatekeeper: await readJsonc(join(root, packageDirs.forkhorseGatekeeper, "wrangler.jsonc")),
782 } : {}),
783 core: await readJsonc(join(root, packageDirs.core, "wrangler.jsonc")),
784 router: await readJsonc(join(root, packageDirs.router, "wrangler.jsonc")),
785 workshop: await readJsonc(join(root, packageDirs.workshop, "wrangler.jsonc")),
786 context: await readJsonc(join(root, packageDirs.context, "wrangler.jsonc")),
787 scheduler: await readJsonc(join(root, packageDirs.scheduler, "wrangler.jsonc")),
788 mcp: await readJsonc(join(root, packageDirs.mcp, "wrangler.jsonc")),
789 errorReporter: await readJsonc(join(root, packageDirs.errorReporter, "wrangler.jsonc")),
790 });
791 reportAiGateway(config);
792
793 try {
794 for (const [name, generatedConfig] of Object.entries(generated)) {
795 await writeFile(
796 generatedPaths[name as keyof typeof generatedPaths],
797 JSON.stringify(generatedConfig, null, 2) + "\n");
798 }
799 const check = process.argv.includes("--check");
800 if (check) run(["test"]);
801 build(config);
802 // Wrangler builds Dockerfiles even on dry runs unless container rollout is disabled.
803 // D5 must never build/pull images or contact a registry; live deploy resolves digests.
804 const deployArgs = check ? ["--dry-run", "--containers-rollout", "none"] : [];
805 for (const name of deploymentOrder(config)) {
806 if (!check && name === "core" && config.core?.secretsFile) {
807 await withCoreSecrets(root, config.core.secretsFile, (secretArgs) =>
808 deployWorker(packageDirs[name], [...deployArgs, ...secretArgs], true));
809 } else deployWorker(packageDirs[name], deployArgs);
810 }
811 } finally {
812 await Promise.all(Object.values(generatedPaths).map((path) => rm(path, { force: true })));
813 }
814}
815
816if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
817 try {
818 await main();
819 } catch (error) {
820 // One line, no stack: every failure here is a config or subprocess problem, not a script bug.
821 console.error(`\nDeploy failed. ${(error as Error).message}`);
822 process.exitCode = 1;
823 }
824}
825