Fork Horse repository, listed by its owner. Publisher snapshot.

scripts/deploy.ts

Snapshot at 34e3175

Containing directory

37,953 bytes · typescriptView raw
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