@treeseed/sdk 0.4.13 → 0.5.0

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.
Files changed (82) hide show
  1. package/dist/control-plane-client.d.ts +60 -1
  2. package/dist/control-plane-client.js +59 -0
  3. package/dist/control-plane.d.ts +1 -1
  4. package/dist/control-plane.js +11 -4
  5. package/dist/d1-store.d.ts +58 -0
  6. package/dist/d1-store.js +64 -0
  7. package/dist/dispatch.js +6 -0
  8. package/dist/graph/schema.js +4 -0
  9. package/dist/index.d.ts +5 -1
  10. package/dist/index.js +32 -0
  11. package/dist/knowledge-coop.d.ts +223 -0
  12. package/dist/knowledge-coop.js +82 -0
  13. package/dist/model-registry.js +79 -0
  14. package/dist/operations/providers/default.js +126 -7
  15. package/dist/operations/services/config-runtime.d.ts +102 -24
  16. package/dist/operations/services/config-runtime.js +896 -160
  17. package/dist/operations/services/deploy.d.ts +223 -15
  18. package/dist/operations/services/deploy.js +626 -55
  19. package/dist/operations/services/github-automation.d.ts +60 -0
  20. package/dist/operations/services/github-automation.js +138 -0
  21. package/dist/operations/services/key-agent.d.ts +118 -0
  22. package/dist/operations/services/key-agent.js +476 -0
  23. package/dist/operations/services/knowledge-coop-launch.d.ts +90 -0
  24. package/dist/operations/services/knowledge-coop-launch.js +753 -0
  25. package/dist/operations/services/knowledge-coop-packaging.d.ts +59 -0
  26. package/dist/operations/services/knowledge-coop-packaging.js +234 -0
  27. package/dist/operations/services/local-dev.d.ts +0 -1
  28. package/dist/operations/services/local-dev.js +1 -14
  29. package/dist/operations/services/project-platform.d.ts +42 -182
  30. package/dist/operations/services/project-platform.js +162 -59
  31. package/dist/operations/services/railway-deploy.d.ts +1 -0
  32. package/dist/operations/services/railway-deploy.js +31 -13
  33. package/dist/operations/services/runtime-tools.d.ts +52 -5
  34. package/dist/operations/services/runtime-tools.js +186 -26
  35. package/dist/operations/services/watch-dev.js +2 -4
  36. package/dist/operations/services/workspace-preflight.d.ts +4 -4
  37. package/dist/operations/services/workspace-preflight.js +22 -20
  38. package/dist/operations-registry.js +7 -2
  39. package/dist/platform/contracts.d.ts +39 -3
  40. package/dist/platform/deploy-config.d.ts +12 -1
  41. package/dist/platform/deploy-config.js +214 -15
  42. package/dist/platform/deploy-runtime.d.ts +1 -0
  43. package/dist/platform/deploy-runtime.js +10 -2
  44. package/dist/platform/env.yaml +93 -61
  45. package/dist/platform/environment.d.ts +13 -2
  46. package/dist/platform/environment.js +90 -20
  47. package/dist/platform/plugins/constants.d.ts +1 -0
  48. package/dist/platform/plugins/constants.js +7 -6
  49. package/dist/platform/tenant/runtime-config.js +8 -1
  50. package/dist/platform/tenant-config.js +4 -0
  51. package/dist/platform/utils/site-config-schema.js +18 -0
  52. package/dist/plugin-default.js +2 -2
  53. package/dist/scripts/key-agent.js +165 -0
  54. package/dist/scripts/tenant-build.js +4 -1
  55. package/dist/scripts/tenant-check.js +4 -1
  56. package/dist/scripts/tenant-deploy.js +43 -4
  57. package/dist/scripts/tenant-dev.js +0 -1
  58. package/dist/sdk-types.d.ts +2 -2
  59. package/dist/sdk-types.js +2 -0
  60. package/dist/sdk.d.ts +13 -0
  61. package/dist/sdk.js +40 -0
  62. package/dist/stores/knowledge-coop-store.d.ts +56 -0
  63. package/dist/stores/knowledge-coop-store.js +482 -0
  64. package/dist/treeseed/template-catalog/templates/starter-basic/template/package.json +6 -2
  65. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/api/server.js +4 -0
  66. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/config.yaml +25 -0
  67. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/decisions/adopt-initial-proposal-loop.mdx +22 -0
  68. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/people/starter-steward.mdx +11 -0
  69. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/content/proposals/establish-initial-proposal-loop.mdx +17 -0
  70. package/dist/treeseed/template-catalog/templates/starter-basic/template/src/manifest.yaml +17 -10
  71. package/dist/treeseed/template-catalog/templates/starter-basic/template/treeseed.site.yaml +69 -7
  72. package/dist/treeseed/template-catalog/templates/starter-basic/template.config.json +1 -0
  73. package/dist/workflow/operations.d.ts +98 -0
  74. package/dist/workflow/operations.js +229 -7
  75. package/dist/workflow-state.d.ts +54 -2
  76. package/dist/workflow-state.js +170 -24
  77. package/dist/workflow-support.d.ts +1 -1
  78. package/dist/workflow-support.js +32 -2
  79. package/dist/workflow.d.ts +29 -0
  80. package/package.json +1 -1
  81. package/templates/github/deploy.workflow.yml +11 -1
  82. package/dist/scripts/sync-dev-vars.js +0 -6
@@ -3,14 +3,14 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
3
3
  import { dirname, relative, resolve } from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { createInterface } from "node:readline/promises";
6
- import { deriveCloudflareWorkerName } from "../../platform/deploy-config.js";
6
+ import { deriveCloudflareWorkerName, resolveTreeseedWebCachePolicy } from "../../platform/deploy-config.js";
7
7
  import { loadCliDeployConfig, resolveWranglerBin } from "./runtime-tools.js";
8
8
  const DEFAULT_COMPATIBILITY_DATE = "2026-04-05";
9
9
  const DEFAULT_COMPATIBILITY_FLAGS = ["nodejs_compat"];
10
10
  const GENERATED_ROOT = ".treeseed/generated";
11
11
  const STATE_ROOT = ".treeseed/state";
12
12
  const PERSISTENT_SCOPES = /* @__PURE__ */ new Set(["local", "staging", "prod"]);
13
- const MANAGED_SERVICE_KEYS = ["api", "agents", "manager", "worker", "runner", "workdayStart", "workdayReport"];
13
+ const MANAGED_SERVICE_KEYS = ["api", "manager", "worker", "workdayStart", "workdayReport"];
14
14
  const TRESEED_ENVELOPE_SCHEMA_GENERATION = "runtime-envelopes-v1";
15
15
  const TRESEED_MIGRATION_WAVE_ID = "0005_runtime_envelopes";
16
16
  const TRESEED_SUPPORTED_PAYLOAD_RANGE = { min: 1, max: 1 };
@@ -121,6 +121,18 @@ function targetWorkerName(deployConfig, target) {
121
121
  }
122
122
  return `${baseName}-${sanitizeSegment(target.branchName)}`;
123
123
  }
124
+ function targetScopedResourceName(baseName, target) {
125
+ if (!baseName) {
126
+ return baseName;
127
+ }
128
+ if (target.kind === "persistent") {
129
+ if (target.scope === "prod") {
130
+ return baseName;
131
+ }
132
+ return `${baseName}-${target.scope}`;
133
+ }
134
+ return `${baseName}-${sanitizeSegment(target.branchName)}`;
135
+ }
124
136
  function targetWorkersDevUrl(workerName) {
125
137
  return `https://${workerName}.workers.dev`;
126
138
  }
@@ -130,32 +142,38 @@ function relativeFromGeneratedRoot(targetPath, generatedRoot) {
130
142
  function buildPublicVars(deployConfig) {
131
143
  const contentRuntimeProvider = deployConfig.providers?.content?.runtime ?? "team_scoped_r2_overlay";
132
144
  const contentPublishProvider = deployConfig.providers?.content?.publish ?? contentRuntimeProvider;
133
- const contentDefaultTeamId = deployConfig.hosting?.teamId ?? deployConfig.slug;
145
+ const contentServingMode = envOrNull("TREESEED_CONTENT_SERVING_MODE") ?? deployConfig.providers?.content?.serving ?? "local_collections";
146
+ const contentDefaultTeamId = resolveConfiguredHostedTeamId(deployConfig);
134
147
  const contentManifestKeyTemplate = deployConfig.cloudflare.r2?.manifestKeyTemplate ?? "teams/{teamId}/published/common.json";
135
148
  const contentPreviewRootTemplate = deployConfig.cloudflare.r2?.previewRootTemplate ?? "teams/{teamId}/previews";
136
149
  const contentManifestKey = contentManifestKeyTemplate.replaceAll("{teamId}", contentDefaultTeamId);
137
- const hostedProject = (deployConfig.hosting?.kind ?? "self_hosted_project") === "hosted_project";
150
+ const managedRuntime = deployConfig.runtime?.mode === "treeseed_managed";
138
151
  const workerRailway = deployConfig.services?.worker?.railway ?? {};
152
+ const webCachePolicy = resolveTreeseedWebCachePolicy(deployConfig);
139
153
  return {
140
154
  TREESEED_HOSTING_KIND: deployConfig.hosting?.kind ?? "self_hosted_project",
141
155
  TREESEED_HOSTING_REGISTRATION: deployConfig.hosting?.registration ?? "none",
142
- TREESEED_MARKET_API_BASE_URL: deployConfig.hosting?.marketBaseUrl ?? "",
143
- TREESEED_HOSTING_TEAM_ID: deployConfig.hosting?.teamId ?? contentDefaultTeamId,
144
- TREESEED_PROJECT_ID: deployConfig.hosting?.projectId ?? deployConfig.slug,
156
+ TREESEED_HUB_MODE: deployConfig.hub?.mode ?? "treeseed_hosted",
157
+ TREESEED_RUNTIME_MODE: deployConfig.runtime?.mode ?? "none",
158
+ TREESEED_RUNTIME_REGISTRATION: deployConfig.runtime?.registration ?? "none",
159
+ TREESEED_MARKET_API_BASE_URL: resolveConfiguredMarketBaseUrl(deployConfig),
160
+ TREESEED_HOSTING_TEAM_ID: contentDefaultTeamId,
161
+ TREESEED_PROJECT_ID: resolveConfiguredProjectId(deployConfig),
145
162
  TREESEED_AGENT_EXECUTION_PROVIDER: deployConfig.providers?.agents?.execution ?? "stub",
146
163
  TREESEED_AGENT_REPOSITORY_PROVIDER: deployConfig.providers?.agents?.repository ?? "stub",
147
164
  TREESEED_AGENT_VERIFICATION_PROVIDER: deployConfig.providers?.agents?.verification ?? "stub",
148
165
  TREESEED_CONTENT_RUNTIME_PROVIDER: contentRuntimeProvider,
149
166
  TREESEED_CONTENT_PUBLISH_PROVIDER: contentPublishProvider,
167
+ TREESEED_CONTENT_SERVING_MODE: contentServingMode,
150
168
  TREESEED_CONTENT_DEFAULT_TEAM_ID: contentDefaultTeamId,
151
169
  TREESEED_CONTENT_MANIFEST_KEY: contentManifestKey,
152
170
  TREESEED_CONTENT_MANIFEST_KEY_TEMPLATE: contentManifestKeyTemplate,
153
171
  TREESEED_CONTENT_PREVIEW_ROOT_TEMPLATE: contentPreviewRootTemplate,
154
172
  TREESEED_EDITORIAL_PREVIEW_ROOT: contentPreviewRootTemplate.replaceAll("{teamId}", contentDefaultTeamId),
155
173
  TREESEED_EDITORIAL_PREVIEW_TTL_HOURS: String(deployConfig.cloudflare.r2?.previewTtlHours ?? 168),
156
- TREESEED_CONTENT_BUCKET_NAME: deployConfig.cloudflare.r2?.bucketName ?? "",
157
- TREESEED_CONTENT_PUBLIC_BASE_URL: deployConfig.cloudflare.r2?.publicBaseUrl ?? "",
158
- TREESEED_WORKER_POOL_SCALER: envOrNull("TREESEED_WORKER_POOL_SCALER") ?? (hostedProject ? "railway" : ""),
174
+ TREESEED_CONTENT_BUCKET_NAME: resolveConfiguredContentBucketName(deployConfig),
175
+ TREESEED_CONTENT_PUBLIC_BASE_URL: resolveConfiguredContentPublicBaseUrl(deployConfig),
176
+ TREESEED_WORKER_POOL_SCALER: envOrNull("TREESEED_WORKER_POOL_SCALER") ?? (managedRuntime ? "railway" : ""),
159
177
  TREESEED_WORKDAY_TIMEZONE: envOrNull("TREESEED_WORKDAY_TIMEZONE") ?? "",
160
178
  TREESEED_WORKDAY_WINDOWS_JSON: envOrNull("TREESEED_WORKDAY_WINDOWS_JSON") ?? "",
161
179
  TREESEED_WORKDAY_TASK_CREDIT_BUDGET: envOrNull("TREESEED_WORKDAY_TASK_CREDIT_BUDGET") ?? "",
@@ -170,7 +188,19 @@ function buildPublicVars(deployConfig) {
170
188
  TREESEED_RAILWAY_PROJECT_ID: envOrNull("TREESEED_RAILWAY_PROJECT_ID") ?? workerRailway.projectId ?? "",
171
189
  TREESEED_RAILWAY_ENVIRONMENT_ID: envOrNull("TREESEED_RAILWAY_ENVIRONMENT_ID") ?? "",
172
190
  TREESEED_RAILWAY_WORKER_SERVICE_ID: envOrNull("TREESEED_RAILWAY_WORKER_SERVICE_ID") ?? workerRailway.serviceId ?? "",
173
- TREESEED_PUBLIC_TURNSTILE_SITE_KEY: envOrNull("TREESEED_PUBLIC_TURNSTILE_SITE_KEY") ?? ""
191
+ TREESEED_PUBLIC_TURNSTILE_SITE_KEY: envOrNull("TREESEED_PUBLIC_TURNSTILE_SITE_KEY") ?? "",
192
+ TREESEED_WEB_CACHE_SOURCE_BROWSER_TTL_SECONDS: String(webCachePolicy.sourcePages.browserTtlSeconds),
193
+ TREESEED_WEB_CACHE_SOURCE_EDGE_TTL_SECONDS: String(webCachePolicy.sourcePages.edgeTtlSeconds),
194
+ TREESEED_WEB_CACHE_SOURCE_STALE_WHILE_REVALIDATE_SECONDS: String(webCachePolicy.sourcePages.staleWhileRevalidateSeconds),
195
+ TREESEED_WEB_CACHE_SOURCE_STALE_IF_ERROR_SECONDS: String(webCachePolicy.sourcePages.staleIfErrorSeconds),
196
+ TREESEED_WEB_CACHE_CONTENT_BROWSER_TTL_SECONDS: String(webCachePolicy.contentPages.browserTtlSeconds),
197
+ TREESEED_WEB_CACHE_CONTENT_EDGE_TTL_SECONDS: String(webCachePolicy.contentPages.edgeTtlSeconds),
198
+ TREESEED_WEB_CACHE_CONTENT_STALE_WHILE_REVALIDATE_SECONDS: String(webCachePolicy.contentPages.staleWhileRevalidateSeconds),
199
+ TREESEED_WEB_CACHE_CONTENT_STALE_IF_ERROR_SECONDS: String(webCachePolicy.contentPages.staleIfErrorSeconds),
200
+ TREESEED_WEB_CACHE_R2_BROWSER_TTL_SECONDS: String(webCachePolicy.r2PublishedObjects.browserTtlSeconds),
201
+ TREESEED_WEB_CACHE_R2_EDGE_TTL_SECONDS: String(webCachePolicy.r2PublishedObjects.edgeTtlSeconds),
202
+ TREESEED_WEB_CACHE_R2_STALE_WHILE_REVALIDATE_SECONDS: String(webCachePolicy.r2PublishedObjects.staleWhileRevalidateSeconds),
203
+ TREESEED_WEB_CACHE_R2_STALE_IF_ERROR_SECONDS: String(webCachePolicy.r2PublishedObjects.staleIfErrorSeconds)
174
204
  };
175
205
  }
176
206
  function buildSecretMap(deployConfig, state) {
@@ -193,7 +223,7 @@ function defaultStateFromConfig(deployConfig, target) {
193
223
  const suffix = target.kind === "persistent" ? target.scope : sanitizeSegment(target.branchName);
194
224
  const contentManifestKeyTemplate = deployConfig.cloudflare.r2?.manifestKeyTemplate ?? "teams/{teamId}/published/common.json";
195
225
  const contentPreviewRootTemplate = deployConfig.cloudflare.r2?.previewRootTemplate ?? "teams/{teamId}/previews";
196
- const contentDefaultTeamId = deployConfig.hosting?.teamId ?? deployConfig.slug;
226
+ const contentDefaultTeamId = resolveConfiguredHostedTeamId(deployConfig);
197
227
  const contentManifestKey = contentManifestKeyTemplate.replaceAll("{teamId}", contentDefaultTeamId);
198
228
  return {
199
229
  version: 2,
@@ -221,15 +251,15 @@ function defaultStateFromConfig(deployConfig, target) {
221
251
  },
222
252
  queues: {
223
253
  agentWork: {
224
- name: deployConfig.cloudflare.queueName ?? "agent-work",
225
- dlqName: deployConfig.cloudflare.dlqName ?? "agent-work-dlq",
254
+ name: targetScopedResourceName(deployConfig.cloudflare.queueName ?? "agent-work", target),
255
+ dlqName: targetScopedResourceName(deployConfig.cloudflare.dlqName ?? "agent-work-dlq", target),
226
256
  binding: deployConfig.cloudflare.queueBinding ?? "AGENT_WORK_QUEUE",
227
257
  queueId: null,
228
258
  dlqId: null
229
259
  }
230
260
  },
231
261
  pages: {
232
- projectName: target.kind === "persistent" ? target.scope === "prod" ? deployConfig.cloudflare.pages?.projectName ?? deployConfig.slug : deployConfig.cloudflare.pages?.previewProjectName ?? `${deployConfig.cloudflare.pages?.projectName ?? deployConfig.slug}-staging` : deployConfig.cloudflare.pages?.previewProjectName ?? `${deployConfig.cloudflare.pages?.projectName ?? deployConfig.slug}-preview`,
262
+ projectName: target.kind === "persistent" ? target.scope === "prod" ? resolveConfiguredPagesProjectName(deployConfig) : resolveConfiguredPagesPreviewProjectName(deployConfig) : `${resolveConfiguredPagesProjectName(deployConfig)}-preview`,
233
263
  productionBranch: deployConfig.cloudflare.pages?.productionBranch ?? "main",
234
264
  stagingBranch: deployConfig.cloudflare.pages?.stagingBranch ?? "staging",
235
265
  buildOutputDir: deployConfig.cloudflare.pages?.buildOutputDir ?? "dist",
@@ -239,9 +269,9 @@ function defaultStateFromConfig(deployConfig, target) {
239
269
  runtimeProvider: deployConfig.providers?.content?.runtime ?? "team_scoped_r2_overlay",
240
270
  publishProvider: deployConfig.providers?.content?.publish ?? deployConfig.providers?.content?.runtime ?? "team_scoped_r2_overlay",
241
271
  defaultTeamId: contentDefaultTeamId,
242
- r2Binding: deployConfig.cloudflare.r2?.binding ?? null,
243
- bucketName: deployConfig.cloudflare.r2?.bucketName ?? null,
244
- publicBaseUrl: deployConfig.cloudflare.r2?.publicBaseUrl ?? null,
272
+ r2Binding: resolveConfiguredContentBucketBinding(deployConfig),
273
+ bucketName: resolveConfiguredContentBucketName(deployConfig),
274
+ publicBaseUrl: resolveConfiguredContentPublicBaseUrl(deployConfig) || null,
245
275
  manifestKeyTemplate: contentManifestKeyTemplate,
246
276
  previewRootTemplate: contentPreviewRootTemplate,
247
277
  previewTtlHours: deployConfig.cloudflare.r2?.previewTtlHours ?? 168,
@@ -252,12 +282,45 @@ function defaultStateFromConfig(deployConfig, target) {
252
282
  hosting: {
253
283
  kind: deployConfig.hosting?.kind ?? "self_hosted_project",
254
284
  registration: deployConfig.hosting?.registration ?? "none",
255
- marketBaseUrl: deployConfig.hosting?.marketBaseUrl ?? null,
256
- teamId: deployConfig.hosting?.teamId ?? contentDefaultTeamId,
257
- projectId: deployConfig.hosting?.projectId ?? deployConfig.slug
285
+ marketBaseUrl: resolveConfiguredMarketBaseUrl(deployConfig) || null,
286
+ teamId: contentDefaultTeamId,
287
+ projectId: resolveConfiguredProjectId(deployConfig)
288
+ },
289
+ hub: {
290
+ mode: deployConfig.hub?.mode ?? "treeseed_hosted"
291
+ },
292
+ runtime: {
293
+ mode: deployConfig.runtime?.mode ?? "none",
294
+ registration: deployConfig.runtime?.registration ?? "none",
295
+ marketBaseUrl: resolveConfiguredMarketBaseUrl(deployConfig) || null,
296
+ teamId: contentDefaultTeamId,
297
+ projectId: resolveConfiguredProjectId(deployConfig)
298
+ },
299
+ webCache: {
300
+ webHost: null,
301
+ contentHost: null,
302
+ webZoneId: null,
303
+ contentZoneId: null,
304
+ webRulesetId: null,
305
+ contentRulesetId: null,
306
+ rulesManaged: false,
307
+ lastSyncedAt: null,
308
+ lastVerifiedAt: null,
309
+ lastError: null,
310
+ deployPurge: {
311
+ lastPurgedAt: null,
312
+ purgeCount: 0,
313
+ lastError: null
314
+ },
315
+ contentPurge: {
316
+ lastPurgedAt: null,
317
+ purgeCount: 0,
318
+ lastError: null
319
+ }
258
320
  },
259
321
  generatedSecrets: {},
260
322
  readiness: {
323
+ phase: "pending",
261
324
  configured: false,
262
325
  provisioned: false,
263
326
  deployable: false,
@@ -265,6 +328,8 @@ function defaultStateFromConfig(deployConfig, target) {
265
328
  initializedAt: null,
266
329
  lastValidatedAt: null,
267
330
  lastConfigFingerprint: null,
331
+ blockers: [],
332
+ warnings: [],
268
333
  lastValidationSummary: null
269
334
  },
270
335
  lastDeployedUrl: target.kind === "branch" ? targetWorkersDevUrl(workerName) : null,
@@ -385,6 +450,48 @@ function loadDeployState(tenantRoot, deployConfig, options = {}) {
385
450
  teamId: defaults.hosting?.teamId ?? persisted.hosting?.teamId ?? deployConfig.slug,
386
451
  projectId: defaults.hosting?.projectId ?? persisted.hosting?.projectId ?? deployConfig.slug
387
452
  },
453
+ hub: {
454
+ ...defaults.hub ?? {},
455
+ ...persisted.hub ?? {},
456
+ mode: defaults.hub?.mode ?? persisted.hub?.mode ?? "treeseed_hosted"
457
+ },
458
+ runtime: {
459
+ ...defaults.runtime ?? {},
460
+ ...persisted.runtime ?? {},
461
+ mode: defaults.runtime?.mode ?? persisted.runtime?.mode ?? "none",
462
+ registration: defaults.runtime?.registration ?? persisted.runtime?.registration ?? "none",
463
+ marketBaseUrl: defaults.runtime?.marketBaseUrl ?? persisted.runtime?.marketBaseUrl ?? null,
464
+ teamId: defaults.runtime?.teamId ?? persisted.runtime?.teamId ?? deployConfig.slug,
465
+ projectId: defaults.runtime?.projectId ?? persisted.runtime?.projectId ?? deployConfig.slug
466
+ },
467
+ webCache: {
468
+ ...defaults.webCache ?? {},
469
+ ...persisted.webCache ?? {},
470
+ webHost: persisted.webCache?.webHost ?? persisted.webCache?.publicHost ?? defaults.webCache?.webHost ?? null,
471
+ contentHost: persisted.webCache?.contentHost ?? defaults.webCache?.contentHost ?? null,
472
+ webZoneId: persisted.webCache?.webZoneId ?? persisted.webCache?.zoneId ?? defaults.webCache?.webZoneId ?? null,
473
+ contentZoneId: persisted.webCache?.contentZoneId ?? defaults.webCache?.contentZoneId ?? null,
474
+ webRulesetId: persisted.webCache?.webRulesetId ?? persisted.webCache?.rulesetId ?? defaults.webCache?.webRulesetId ?? null,
475
+ contentRulesetId: persisted.webCache?.contentRulesetId ?? defaults.webCache?.contentRulesetId ?? null,
476
+ rulesManaged: persisted.webCache?.rulesManaged ?? defaults.webCache?.rulesManaged ?? false,
477
+ lastSyncedAt: persisted.webCache?.lastSyncedAt ?? defaults.webCache?.lastSyncedAt ?? null,
478
+ lastVerifiedAt: persisted.webCache?.lastVerifiedAt ?? defaults.webCache?.lastVerifiedAt ?? null,
479
+ lastError: persisted.webCache?.lastError ?? defaults.webCache?.lastError ?? null,
480
+ deployPurge: {
481
+ ...defaults.webCache?.deployPurge ?? {},
482
+ ...persisted.webCache?.deployPurge ?? {},
483
+ lastPurgedAt: persisted.webCache?.deployPurge?.lastPurgedAt ?? defaults.webCache?.deployPurge?.lastPurgedAt ?? null,
484
+ purgeCount: persisted.webCache?.deployPurge?.purgeCount ?? defaults.webCache?.deployPurge?.purgeCount ?? 0,
485
+ lastError: persisted.webCache?.deployPurge?.lastError ?? defaults.webCache?.deployPurge?.lastError ?? null
486
+ },
487
+ contentPurge: {
488
+ ...defaults.webCache?.contentPurge ?? {},
489
+ ...persisted.webCache?.contentPurge ?? {},
490
+ lastPurgedAt: persisted.webCache?.contentPurge?.lastPurgedAt ?? defaults.webCache?.contentPurge?.lastPurgedAt ?? null,
491
+ purgeCount: persisted.webCache?.contentPurge?.purgeCount ?? defaults.webCache?.contentPurge?.purgeCount ?? 0,
492
+ lastError: persisted.webCache?.contentPurge?.lastError ?? defaults.webCache?.contentPurge?.lastError ?? null
493
+ }
494
+ },
388
495
  pages: {
389
496
  ...defaults.pages ?? {},
390
497
  ...persisted.pages ?? {},
@@ -402,12 +509,14 @@ function loadDeployState(tenantRoot, deployConfig, options = {}) {
402
509
  MANAGED_SERVICE_KEYS.map((serviceKey) => {
403
510
  const defaultService = defaults.services?.[serviceKey] ?? {};
404
511
  const persistedService = persisted.services?.[serviceKey] ?? {};
512
+ const effectiveDeploymentTimestamp = persistedService.lastDeploymentTimestamp ?? defaultService.lastDeploymentTimestamp ?? null;
405
513
  return [
406
514
  serviceKey,
407
515
  {
408
516
  ...defaultService,
409
517
  ...persistedService,
410
518
  enabled: defaultService.enabled,
519
+ initialized: persistedService.initialized === true && Boolean(effectiveDeploymentTimestamp),
411
520
  provider: defaultService.provider,
412
521
  projectId: defaultService.projectId ?? persistedService.projectId ?? null,
413
522
  projectName: defaultService.projectName ?? persistedService.projectName ?? null,
@@ -418,6 +527,7 @@ function loadDeployState(tenantRoot, deployConfig, options = {}) {
418
527
  environment: defaultService.environment ?? persistedService.environment ?? null,
419
528
  schedule: defaultService.schedule ?? persistedService.schedule ?? null,
420
529
  publicBaseUrl: defaultService.publicBaseUrl ?? persistedService.publicBaseUrl ?? null,
530
+ lastDeploymentTimestamp: effectiveDeploymentTimestamp,
421
531
  lastDeployedUrl: persistedService.lastDeployedUrl ?? defaultService.publicBaseUrl ?? null,
422
532
  lastScheduleSyncAt: persistedService.lastScheduleSyncAt ?? defaultService.lastScheduleSyncAt ?? null
423
533
  }
@@ -453,8 +563,8 @@ function buildWranglerConfigContents(tenantRoot, deployConfig, state, options =
453
563
  const migrationsDir = relativeFromGeneratedRoot(resolve(tenantRoot, "migrations"), generatedRoot);
454
564
  const vars = buildPublicVars(deployConfig);
455
565
  const r2Config = deployConfig.cloudflare.r2;
456
- const r2Binding = r2Config?.binding ?? "TREESEED_CONTENT_BUCKET";
457
- const r2BucketName = r2Config?.bucketName ?? `${deployConfig.slug}-content`;
566
+ const r2Binding = resolveConfiguredContentBucketBinding(deployConfig);
567
+ const r2BucketName = resolveConfiguredContentBucketName(deployConfig);
458
568
  return [
459
569
  `name = ${renderTomlString(workerName)}`,
460
570
  `compatibility_date = ${renderTomlString(DEFAULT_COMPATIBILITY_DATE)}`,
@@ -545,6 +655,10 @@ function parseWranglerJsonOutput(result, label) {
545
655
  }
546
656
  return JSON.parse(source);
547
657
  }
658
+ function isWranglerAlreadyExistsError(error, matchers) {
659
+ const message = error instanceof Error ? error.message : String(error);
660
+ return matchers.some((matcher) => matcher.test(message));
661
+ }
548
662
  function listKvNamespaces(tenantRoot, env) {
549
663
  const result = runWrangler(["kv", "namespace", "list"], {
550
664
  cwd: tenantRoot,
@@ -595,19 +709,318 @@ function isPlaceholderResourceId(value) {
595
709
  return value.startsWith("local-") || value.startsWith("dryrun-") || value.endsWith("-id") || value.endsWith("-preview-id");
596
710
  }
597
711
  function buildProvisioningSummary(deployConfig, state, target) {
712
+ const webCachePolicy = resolveTreeseedWebCachePolicy(deployConfig);
598
713
  return {
599
714
  target: deployTargetLabel(target),
600
715
  workerName: state.workerName ?? targetWorkerName(deployConfig, target),
601
716
  siteUrl: target.kind === "branch" ? targetWorkersDevUrl(state.workerName) : deployConfig.siteUrl,
602
- accountId: deployConfig.cloudflare.accountId,
717
+ accountId: resolveConfiguredCloudflareAccountId(deployConfig),
603
718
  pages: state.pages ?? null,
604
719
  formGuardKv: state.kvNamespaces.FORM_GUARD_KV,
605
720
  sessionKv: state.kvNamespaces.SESSION,
606
721
  siteDataDb: state.d1Databases.SITE_DATA_DB,
607
722
  queue: state.queues?.agentWork ?? null,
608
- content: state.content ?? null
723
+ content: state.content ?? null,
724
+ webCache: {
725
+ webHost: state.webCache?.webHost ?? null,
726
+ contentHost: state.webCache?.contentHost ?? null,
727
+ rulesManaged: state.webCache?.rulesManaged === true,
728
+ lastSyncedAt: state.webCache?.lastSyncedAt ?? null,
729
+ lastError: state.webCache?.lastError ?? null,
730
+ policy: webCachePolicy,
731
+ deployPurge: state.webCache?.deployPurge ?? null,
732
+ contentPurge: state.webCache?.contentPurge ?? null
733
+ }
734
+ };
735
+ }
736
+ function safeUrl(value) {
737
+ try {
738
+ return new URL(value);
739
+ } catch {
740
+ return null;
741
+ }
742
+ }
743
+ function normalizePathPrefix(pathname) {
744
+ const normalized = String(pathname ?? "").replace(/\/+$/u, "");
745
+ if (!normalized || normalized === "/") {
746
+ return "";
747
+ }
748
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
749
+ }
750
+ function resolvePublicWebCacheTarget(deployConfig) {
751
+ const parsed = safeUrl(deployConfig.surfaces?.web?.publicBaseUrl ?? deployConfig.siteUrl);
752
+ if (!parsed) {
753
+ return null;
754
+ }
755
+ return {
756
+ host: parsed.hostname,
757
+ pathPrefix: normalizePathPrefix(parsed.pathname)
609
758
  };
610
759
  }
760
+ function resolvePublicContentCacheTarget(deployConfig) {
761
+ const parsed = safeUrl(resolveConfiguredContentPublicBaseUrl(deployConfig));
762
+ if (!parsed) {
763
+ return null;
764
+ }
765
+ return {
766
+ host: parsed.hostname,
767
+ pathPrefix: normalizePathPrefix(parsed.pathname)
768
+ };
769
+ }
770
+ function shouldManageCloudflareWebCacheRules(deployConfig, target) {
771
+ if (target.kind !== "persistent" || target.scope !== "prod") {
772
+ return false;
773
+ }
774
+ if ((deployConfig.surfaces?.web?.provider ?? deployConfig.providers?.deploy) !== "cloudflare") {
775
+ return false;
776
+ }
777
+ const webTarget = resolvePublicWebCacheTarget(deployConfig);
778
+ return Boolean(webTarget?.host && !webTarget.host.endsWith(".workers.dev") && !webTarget.host.endsWith(".pages.dev"));
779
+ }
780
+ function cloudflareApiRequest(path, { method = "GET", body, env, allowFailure = false } = {}) {
781
+ const response = spawnSync(
782
+ process.execPath,
783
+ [
784
+ "--input-type=module",
785
+ "-e",
786
+ `import { readFileSync } from 'node:fs';
787
+ const input = JSON.parse(readFileSync(0, 'utf8') || '{}');
788
+ const response = await fetch(input.url, {
789
+ method: input.method,
790
+ headers: {
791
+ authorization: 'Bearer ' + input.token,
792
+ 'content-type': 'application/json',
793
+ },
794
+ body: input.body ? JSON.stringify(input.body) : undefined,
795
+ });
796
+ const payload = await response.json().catch(async () => ({ success: false, errors: [{ message: await response.text() }] }));
797
+ process.stdout.write(JSON.stringify({ ok: response.ok, payload }));`
798
+ ],
799
+ {
800
+ stdio: ["pipe", "pipe", "pipe"],
801
+ encoding: "utf8",
802
+ env: { ...process.env, ...env ?? {} },
803
+ input: JSON.stringify({
804
+ url: `https://api.cloudflare.com/client/v4${path}`,
805
+ method,
806
+ body,
807
+ token: env?.CLOUDFLARE_API_TOKEN ?? process.env.CLOUDFLARE_API_TOKEN ?? ""
808
+ })
809
+ }
810
+ );
811
+ if (response.status !== 0 && !allowFailure) {
812
+ throw new Error(response.stderr?.trim() || `Cloudflare API request failed: ${method} ${path}`);
813
+ }
814
+ const parsed = JSON.parse(response.stdout?.trim() || '{"ok":false,"payload":{"success":false,"errors":[{"message":"empty response"}]}}');
815
+ if (!parsed.ok && !allowFailure) {
816
+ const details = Array.isArray(parsed.payload?.errors) ? parsed.payload.errors.map((entry) => entry?.message ?? JSON.stringify(entry)).join("; ") : "unknown error";
817
+ throw new Error(details || `Cloudflare API request failed: ${method} ${path}`);
818
+ }
819
+ return parsed.payload;
820
+ }
821
+ function resolveCloudflareZoneIdForHost(deployConfig, host, env) {
822
+ if (deployConfig.cloudflare.zoneId) {
823
+ return deployConfig.cloudflare.zoneId;
824
+ }
825
+ const result = cloudflareApiRequest(`/zones?name=${encodeURIComponent(host)}`, { env, allowFailure: true });
826
+ const exact = Array.isArray(result?.result) ? result.result.find((zone) => zone?.name === host) : null;
827
+ if (exact?.id) {
828
+ return exact.id;
829
+ }
830
+ const fallback = cloudflareApiRequest("/zones", { env, allowFailure: true });
831
+ const zones = Array.isArray(fallback?.result) ? fallback.result : [];
832
+ const matched = zones.filter((zone) => typeof zone?.name === "string" && (host === zone.name || host.endsWith(`.${zone.name}`))).sort((left, right) => String(right.name).length - String(left.name).length)[0];
833
+ return matched?.id ?? null;
834
+ }
835
+ function listCloudflareZoneRulesets(zoneId, env) {
836
+ const result = cloudflareApiRequest(`/zones/${zoneId}/rulesets`, { env, allowFailure: true });
837
+ return Array.isArray(result?.result) ? result.result : [];
838
+ }
839
+ function buildTreeseedManagedCloudflareCacheRules(deployConfig, cacheTarget, kind) {
840
+ if (!cacheTarget?.host) {
841
+ return [];
842
+ }
843
+ const policy = resolveTreeseedWebCachePolicy(deployConfig);
844
+ const cachePolicy = kind === "web" ? policy.contentPages : policy.r2PublishedObjects;
845
+ const hostExpression = `(http.host eq "${cacheTarget.host}")`;
846
+ const pathExpression = cacheTarget.pathPrefix ? `(starts_with(http.request.uri.path, "${cacheTarget.pathPrefix}/") or (http.request.uri.path eq "${cacheTarget.pathPrefix}"))` : "true";
847
+ if (kind === "content") {
848
+ return [
849
+ {
850
+ description: "treeseed-managed: cache public r2 objects",
851
+ expression: `((${hostExpression}) and (${pathExpression}) and (http.request.method in {"GET" "HEAD"}))`,
852
+ action: "set_cache_settings",
853
+ action_parameters: {
854
+ cache: true,
855
+ edge_ttl: {
856
+ mode: "override_origin",
857
+ default: cachePolicy.edgeTtlSeconds
858
+ },
859
+ browser_ttl: {
860
+ mode: "override_origin",
861
+ default: cachePolicy.browserTtlSeconds
862
+ }
863
+ },
864
+ enabled: true
865
+ }
866
+ ];
867
+ }
868
+ const sourcePaths = policy.sourcePages.paths.map((path) => path === "/" ? "/" : path.replace(/\/+$/u, ""));
869
+ const sourcePathExpression = sourcePaths.length > 0 ? `(${sourcePaths.map((path) => `(http.request.uri.path eq "${path}")`).join(" or ")})` : "false";
870
+ const notSourcePathExpression = sourcePaths.length > 0 ? `not ${sourcePathExpression}` : "true";
871
+ return [
872
+ {
873
+ description: "treeseed-managed: bypass preview and dynamic routes",
874
+ expression: `((${hostExpression}) and ((starts_with(http.request.uri.path, "/api/")) or (http.request.uri.path eq "/api") or (starts_with(http.request.uri.path, "/auth")) or (starts_with(http.request.uri.path, "/admin")) or (starts_with(http.request.uri.path, "/app")) or (starts_with(http.request.uri.path, "/internal")) or (http.request.uri.query contains "preview=") or (http.cookie contains "treeseed-content-preview=")))`,
875
+ action: "set_cache_settings",
876
+ action_parameters: {
877
+ cache: false
878
+ },
879
+ enabled: true
880
+ },
881
+ {
882
+ description: "treeseed-managed: cache source html routes",
883
+ expression: `((${hostExpression}) and (${pathExpression}) and (${sourcePathExpression}) and (http.request.method in {"GET" "HEAD"}))`,
884
+ action: "set_cache_settings",
885
+ action_parameters: {
886
+ cache: true,
887
+ edge_ttl: {
888
+ mode: "override_origin",
889
+ default: policy.sourcePages.edgeTtlSeconds
890
+ },
891
+ browser_ttl: {
892
+ mode: "override_origin",
893
+ default: policy.sourcePages.browserTtlSeconds
894
+ }
895
+ },
896
+ enabled: true
897
+ },
898
+ {
899
+ description: "treeseed-managed: cache content html routes",
900
+ expression: `((${hostExpression}) and (${pathExpression}) and (${notSourcePathExpression}) and (http.request.method in {"GET" "HEAD"}) and (http.request.uri.path.extension eq "") and not (starts_with(http.request.uri.path, "/api/")) and not (http.request.uri.path eq "/api") and not (starts_with(http.request.uri.path, "/auth")) and not (starts_with(http.request.uri.path, "/admin")) and not (starts_with(http.request.uri.path, "/app")) and not (starts_with(http.request.uri.path, "/internal")) and not (http.request.uri.query contains "preview=") and not (http.cookie contains "treeseed-content-preview="))`,
901
+ action: "set_cache_settings",
902
+ action_parameters: {
903
+ cache: true,
904
+ edge_ttl: {
905
+ mode: "override_origin",
906
+ default: cachePolicy.edgeTtlSeconds
907
+ },
908
+ browser_ttl: {
909
+ mode: "override_origin",
910
+ default: cachePolicy.browserTtlSeconds
911
+ }
912
+ },
913
+ enabled: true
914
+ }
915
+ ];
916
+ }
917
+ function reconcileCloudflareCacheRulesForTarget(role, deployConfig, state, cacheTarget, env, { dryRun = false } = {}) {
918
+ const roleKey = role === "web" ? "Web" : "Content";
919
+ if (!cacheTarget?.host) {
920
+ return { managed: false, skipped: true, reason: "missing_host" };
921
+ }
922
+ const zoneId = resolveCloudflareZoneIdForHost(deployConfig, cacheTarget.host, env);
923
+ if (!zoneId) {
924
+ return { managed: false, skipped: true, reason: "zone_unresolved" };
925
+ }
926
+ const desiredRules = buildTreeseedManagedCloudflareCacheRules(deployConfig, cacheTarget, role);
927
+ state.webCache[role === "web" ? "webHost" : "contentHost"] = cacheTarget.host;
928
+ state.webCache[role === "web" ? "webZoneId" : "contentZoneId"] = zoneId;
929
+ if (dryRun) {
930
+ return { managed: true, dryRun: true, zoneId, host: cacheTarget.host, rules: desiredRules };
931
+ }
932
+ const rulesets = listCloudflareZoneRulesets(zoneId, env);
933
+ const existing = rulesets.find((ruleset) => ruleset?.phase === "http_request_cache_settings") ?? null;
934
+ const prefix = `treeseed-managed:${roleKey.toLowerCase()}:`;
935
+ const unmanagedRules = Array.isArray(existing?.rules) ? existing.rules.filter((rule) => typeof rule?.description !== "string" || !rule.description.startsWith(prefix)) : [];
936
+ const rules = [
937
+ ...unmanagedRules,
938
+ ...desiredRules.map((rule) => ({ ...rule, description: `${prefix} ${rule.description}` }))
939
+ ];
940
+ const payload = existing ? cloudflareApiRequest(`/zones/${zoneId}/rulesets/${existing.id}`, {
941
+ method: "PUT",
942
+ body: { rules },
943
+ env
944
+ }) : cloudflareApiRequest(`/zones/${zoneId}/rulesets`, {
945
+ method: "POST",
946
+ body: {
947
+ name: `Treeseed Managed ${roleKey} Cache Rules`,
948
+ kind: "zone",
949
+ phase: "http_request_cache_settings",
950
+ rules
951
+ },
952
+ env
953
+ });
954
+ const rulesetId = payload?.result?.id ?? existing?.id ?? null;
955
+ state.webCache[role === "web" ? "webRulesetId" : "contentRulesetId"] = rulesetId;
956
+ return { managed: true, zoneId, host: cacheTarget.host, rulesetId };
957
+ }
958
+ function reconcileCloudflareWebCacheRules(tenantRoot, deployConfig, state, target, { dryRun = false } = {}) {
959
+ if (!shouldManageCloudflareWebCacheRules(deployConfig, target)) {
960
+ const webTarget2 = resolvePublicWebCacheTarget(deployConfig);
961
+ const contentTarget2 = resolvePublicContentCacheTarget(deployConfig);
962
+ state.webCache.webHost = webTarget2?.host ?? null;
963
+ state.webCache.contentHost = contentTarget2?.host ?? null;
964
+ state.webCache.rulesManaged = false;
965
+ state.webCache.lastError = null;
966
+ return { managed: false, skipped: true, reason: "unsupported_target_or_host" };
967
+ }
968
+ const env = {
969
+ CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN ?? ""
970
+ };
971
+ if (!env.CLOUDFLARE_API_TOKEN) {
972
+ state.webCache.webHost = resolvePublicWebCacheTarget(deployConfig)?.host ?? null;
973
+ state.webCache.contentHost = resolvePublicContentCacheTarget(deployConfig)?.host ?? null;
974
+ state.webCache.rulesManaged = false;
975
+ state.webCache.lastError = "CLOUDFLARE_API_TOKEN is required to manage Cloudflare Cache Rules.";
976
+ return { managed: false, skipped: true, reason: "missing_api_token" };
977
+ }
978
+ const webTarget = resolvePublicWebCacheTarget(deployConfig);
979
+ const contentTarget = resolvePublicContentCacheTarget(deployConfig);
980
+ const results = [];
981
+ if (webTarget?.host) {
982
+ results.push(reconcileCloudflareCacheRulesForTarget("web", deployConfig, state, webTarget, env, { dryRun }));
983
+ }
984
+ if (contentTarget?.host) {
985
+ results.push(reconcileCloudflareCacheRulesForTarget("content", deployConfig, state, contentTarget, env, { dryRun }));
986
+ }
987
+ state.webCache.rulesManaged = true;
988
+ state.webCache.lastSyncedAt = (/* @__PURE__ */ new Date()).toISOString();
989
+ state.webCache.lastError = null;
990
+ return { managed: true, results };
991
+ }
992
+ function purgeCloudflareCacheByUrls(urls, deployConfig, { env } = {}) {
993
+ const uniqueUrls = [...new Set((urls ?? []).filter(Boolean))];
994
+ if (uniqueUrls.length === 0) {
995
+ return [];
996
+ }
997
+ const grouped = /* @__PURE__ */ new Map();
998
+ for (const urlValue of uniqueUrls) {
999
+ const parsed = safeUrl(urlValue);
1000
+ if (!parsed) {
1001
+ continue;
1002
+ }
1003
+ const zoneId = resolveCloudflareZoneIdForHost(deployConfig, parsed.hostname, env);
1004
+ if (!zoneId) {
1005
+ continue;
1006
+ }
1007
+ const current = grouped.get(zoneId) ?? [];
1008
+ current.push(parsed.toString());
1009
+ grouped.set(zoneId, current);
1010
+ }
1011
+ return [...grouped.entries()].map(([zoneId, files]) => {
1012
+ const payload = cloudflareApiRequest(`/zones/${zoneId}/purge_cache`, {
1013
+ method: "POST",
1014
+ body: { files: [...new Set(files)] },
1015
+ env
1016
+ });
1017
+ return {
1018
+ zoneId,
1019
+ count: [...new Set(files)].length,
1020
+ success: payload?.success === true
1021
+ };
1022
+ });
1023
+ }
611
1024
  function queueName(entry) {
612
1025
  return entry?.queue_name ?? entry?.queueName ?? entry?.name ?? null;
613
1026
  }
@@ -619,12 +1032,106 @@ function hasProvisionedCloudflareResources(state) {
619
1032
  state?.pages?.projectName && state?.pages?.url && state?.d1Databases?.SITE_DATA_DB?.databaseId && state?.kvNamespaces?.FORM_GUARD_KV?.id && state?.kvNamespaces?.SESSION?.id && state?.queues?.agentWork?.name && state?.content?.bucketName
620
1033
  );
621
1034
  }
1035
+ function absoluteUrlForPath(baseUrl, path) {
1036
+ const parsed = safeUrl(baseUrl);
1037
+ if (!parsed) {
1038
+ return null;
1039
+ }
1040
+ const normalizedPath = String(path ?? "").startsWith("/") ? String(path) : `/${String(path ?? "")}`;
1041
+ return new URL(normalizedPath, parsed).toString();
1042
+ }
1043
+ function resolveSourcePagePurgeUrls(deployConfig) {
1044
+ const webBaseUrl = deployConfig.surfaces?.web?.publicBaseUrl ?? deployConfig.siteUrl;
1045
+ const paths = resolveTreeseedWebCachePolicy(deployConfig).sourcePages.paths;
1046
+ return paths.map((path) => absoluteUrlForPath(webBaseUrl, path)).filter(Boolean);
1047
+ }
1048
+ function recordCachePurgeResult(targetState, results, error = null) {
1049
+ if (error) {
1050
+ targetState.lastError = error instanceof Error ? error.message : String(error);
1051
+ return;
1052
+ }
1053
+ targetState.lastPurgedAt = (/* @__PURE__ */ new Date()).toISOString();
1054
+ targetState.purgeCount = Array.isArray(results) ? results.reduce((sum, result) => sum + (result?.count ?? 0), 0) : 0;
1055
+ targetState.lastError = null;
1056
+ }
1057
+ function purgeSourcePageCaches(tenantRoot, options = {}) {
1058
+ const target = normalizeTarget(options.scope ?? options.target ?? "prod");
1059
+ const deployConfig = loadTenantDeployConfig(tenantRoot);
1060
+ const state = loadDeployState(tenantRoot, deployConfig, { target });
1061
+ const urls = resolveSourcePagePurgeUrls(deployConfig);
1062
+ if ((options.dryRun ?? false) || urls.length === 0 || !process.env.CLOUDFLARE_API_TOKEN) {
1063
+ recordCachePurgeResult(state.webCache.deployPurge, urls.map((url) => ({ count: url ? 1 : 0 })));
1064
+ writeDeployState(tenantRoot, state, { target });
1065
+ return { skipped: options.dryRun ?? false, urls, results: [] };
1066
+ }
1067
+ try {
1068
+ const results = purgeCloudflareCacheByUrls(urls, deployConfig, {
1069
+ env: { CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN }
1070
+ });
1071
+ recordCachePurgeResult(state.webCache.deployPurge, results);
1072
+ writeDeployState(tenantRoot, state, { target });
1073
+ return { urls, results };
1074
+ } catch (error) {
1075
+ recordCachePurgeResult(state.webCache.deployPurge, [], error);
1076
+ writeDeployState(tenantRoot, state, { target });
1077
+ throw error;
1078
+ }
1079
+ }
1080
+ function purgePublishedContentCaches(tenantRoot, urls, options = {}) {
1081
+ const target = normalizeTarget(options.scope ?? options.target ?? "prod");
1082
+ const deployConfig = loadTenantDeployConfig(tenantRoot);
1083
+ const state = loadDeployState(tenantRoot, deployConfig, { target });
1084
+ if ((options.dryRun ?? false) || !urls?.length || !process.env.CLOUDFLARE_API_TOKEN) {
1085
+ recordCachePurgeResult(state.webCache.contentPurge, (urls ?? []).map((url) => ({ count: url ? 1 : 0 })));
1086
+ writeDeployState(tenantRoot, state, { target });
1087
+ return { skipped: options.dryRun ?? false, urls: urls ?? [], results: [] };
1088
+ }
1089
+ try {
1090
+ const results = purgeCloudflareCacheByUrls(urls, deployConfig, {
1091
+ env: { CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN }
1092
+ });
1093
+ recordCachePurgeResult(state.webCache.contentPurge, results);
1094
+ writeDeployState(tenantRoot, state, { target });
1095
+ return { urls, results };
1096
+ } catch (error) {
1097
+ recordCachePurgeResult(state.webCache.contentPurge, [], error);
1098
+ writeDeployState(tenantRoot, state, { target });
1099
+ throw error;
1100
+ }
1101
+ }
622
1102
  function buildDestroySummary(deployConfig, state, target) {
623
1103
  return buildProvisioningSummary(deployConfig, state, target);
624
1104
  }
625
1105
  function isPlaceholderAccountId(value) {
626
1106
  return !value || value === "replace-with-cloudflare-account-id";
627
1107
  }
1108
+ function resolveConfiguredCloudflareAccountId(deployConfig) {
1109
+ return envOrNull("CLOUDFLARE_ACCOUNT_ID") ?? deployConfig.cloudflare.accountId;
1110
+ }
1111
+ function resolveConfiguredMarketBaseUrl(deployConfig) {
1112
+ return envOrNull("TREESEED_MARKET_API_BASE_URL") ?? deployConfig.runtime?.marketBaseUrl ?? deployConfig.hosting?.marketBaseUrl ?? "";
1113
+ }
1114
+ function resolveConfiguredHostedTeamId(deployConfig) {
1115
+ return envOrNull("TREESEED_HOSTING_TEAM_ID") ?? deployConfig.runtime?.teamId ?? deployConfig.hosting?.teamId ?? deployConfig.slug;
1116
+ }
1117
+ function resolveConfiguredProjectId(deployConfig) {
1118
+ return envOrNull("TREESEED_PROJECT_ID") ?? deployConfig.runtime?.projectId ?? deployConfig.hosting?.projectId ?? deployConfig.slug;
1119
+ }
1120
+ function resolveConfiguredPagesProjectName(deployConfig) {
1121
+ return envOrNull("TREESEED_CLOUDFLARE_PAGES_PROJECT_NAME") ?? deployConfig.cloudflare.pages?.projectName ?? deployConfig.slug;
1122
+ }
1123
+ function resolveConfiguredPagesPreviewProjectName(deployConfig) {
1124
+ return envOrNull("TREESEED_CLOUDFLARE_PAGES_PREVIEW_PROJECT_NAME") ?? deployConfig.cloudflare.pages?.previewProjectName ?? `${resolveConfiguredPagesProjectName(deployConfig)}-staging`;
1125
+ }
1126
+ function resolveConfiguredContentBucketBinding(deployConfig) {
1127
+ return envOrNull("TREESEED_CONTENT_BUCKET_BINDING") ?? deployConfig.cloudflare.r2?.binding ?? "TREESEED_CONTENT_BUCKET";
1128
+ }
1129
+ function resolveConfiguredContentBucketName(deployConfig) {
1130
+ return envOrNull("TREESEED_CONTENT_BUCKET_NAME") ?? deployConfig.cloudflare.r2?.bucketName ?? `${deployConfig.slug}-content`;
1131
+ }
1132
+ function resolveConfiguredContentPublicBaseUrl(deployConfig) {
1133
+ return envOrNull("TREESEED_CONTENT_PUBLIC_BASE_URL") ?? deployConfig.cloudflare.r2?.publicBaseUrl ?? "";
1134
+ }
628
1135
  function missingTurnstileRequirements() {
629
1136
  const issues = [];
630
1137
  if (!envOrNull("TREESEED_PUBLIC_TURNSTILE_SITE_KEY")) {
@@ -638,8 +1145,8 @@ function missingTurnstileRequirements() {
638
1145
  function missingContentRuntimeRequirements(deployConfig) {
639
1146
  const issues = [];
640
1147
  if (deployConfig.providers?.content?.runtime === "team_scoped_r2_overlay") {
641
- if (!deployConfig.cloudflare.r2?.bucketName) {
642
- issues.push("Set cloudflare.r2.bucketName before deploying team-scoped hosted content.");
1148
+ if (!resolveConfiguredContentBucketName(deployConfig)) {
1149
+ issues.push("Set TREESEED_CONTENT_BUCKET_NAME before deploying team-scoped hosted content.");
643
1150
  }
644
1151
  if (!envOrNull("TREESEED_EDITORIAL_PREVIEW_SECRET")) {
645
1152
  issues.push("Set TREESEED_EDITORIAL_PREVIEW_SECRET before deploying team-scoped hosted content.");
@@ -654,7 +1161,7 @@ function collectMissingDeployInputs(tenantRoot) {
654
1161
  missing.push({
655
1162
  key: "CLOUDFLARE_ACCOUNT_ID",
656
1163
  label: "Cloudflare account ID",
657
- message: `Cloudflare account ID is missing. Add it to ${relative(tenantRoot, deployConfig.__configPath ?? resolve(tenantRoot, "treeseed.site.yaml"))} or provide it now.`
1164
+ message: "Cloudflare account ID is missing. Set CLOUDFLARE_ACCOUNT_ID with treeseed config or provide it now."
658
1165
  });
659
1166
  }
660
1167
  if (!envOrNull("TREESEED_PUBLIC_TURNSTILE_SITE_KEY")) {
@@ -712,7 +1219,7 @@ function validateDeployPrerequisites(tenantRoot, { requireRemote = true } = {})
712
1219
  const issues = [];
713
1220
  if (isPlaceholderAccountId(deployConfig.cloudflare.accountId)) {
714
1221
  issues.push(
715
- `Set cloudflare.accountId in ${relative(tenantRoot, deployConfig.__configPath ?? resolve(tenantRoot, "treeseed.site.yaml"))} or export CLOUDFLARE_ACCOUNT_ID.`
1222
+ "Set CLOUDFLARE_ACCOUNT_ID with treeseed config or export it before deploying."
716
1223
  );
717
1224
  }
718
1225
  if (requireRemote) {
@@ -740,7 +1247,7 @@ function validateDestroyPrerequisites(tenantRoot, { requireRemote = true } = {})
740
1247
  const issues = [];
741
1248
  if (requireRemote && isPlaceholderAccountId(deployConfig.cloudflare.accountId)) {
742
1249
  issues.push(
743
- `Set cloudflare.accountId in ${relative(tenantRoot, deployConfig.__configPath ?? resolve(tenantRoot, "treeseed.site.yaml"))} or export CLOUDFLARE_ACCOUNT_ID.`
1250
+ "Set CLOUDFLARE_ACCOUNT_ID with treeseed config or export it before destroying infrastructure."
744
1251
  );
745
1252
  }
746
1253
  if (requireRemote) {
@@ -859,7 +1366,7 @@ function destroyCloudflareResources(tenantRoot, options = {}) {
859
1366
  const state = loadDeployState(tenantRoot, deployConfig, { target });
860
1367
  state.workerName = targetWorkerName(deployConfig, target);
861
1368
  const env = {
862
- CLOUDFLARE_ACCOUNT_ID: deployConfig.cloudflare.accountId
1369
+ CLOUDFLARE_ACCOUNT_ID: resolveConfiguredCloudflareAccountId(deployConfig)
863
1370
  };
864
1371
  const dryRun = options.dryRun ?? false;
865
1372
  const force = options.force ?? false;
@@ -919,7 +1426,7 @@ function provisionCloudflareResources(tenantRoot, options = {}) {
919
1426
  const state = loadDeployState(tenantRoot, deployConfig, { target });
920
1427
  state.workerName = targetWorkerName(deployConfig, target);
921
1428
  const env = {
922
- CLOUDFLARE_ACCOUNT_ID: deployConfig.cloudflare.accountId
1429
+ CLOUDFLARE_ACCOUNT_ID: resolveConfiguredCloudflareAccountId(deployConfig)
923
1430
  };
924
1431
  const dryRun = options.dryRun ?? false;
925
1432
  const kvNamespaces = dryRun ? [] : listKvNamespaces(tenantRoot, env);
@@ -987,10 +1494,11 @@ function provisionCloudflareResources(tenantRoot, options = {}) {
987
1494
  if (!current?.name) {
988
1495
  return;
989
1496
  }
990
- const exists = queues.find((entry) => queueName(entry) === current.name);
1497
+ let refreshedQueues = queues;
1498
+ const exists = refreshedQueues.find((entry) => queueName(entry) === current.name);
991
1499
  if (exists) {
992
1500
  current.queueId = queueId(exists);
993
- const currentDlq = current.dlqName ? queues.find((entry) => queueName(entry) === current.dlqName) : null;
1501
+ const currentDlq = current.dlqName ? refreshedQueues.find((entry) => queueName(entry) === current.dlqName) : null;
994
1502
  current.dlqId = queueId(currentDlq);
995
1503
  return;
996
1504
  }
@@ -999,22 +1507,41 @@ function provisionCloudflareResources(tenantRoot, options = {}) {
999
1507
  current.dlqId = current.dlqName ? `dryrun-${current.dlqName}` : null;
1000
1508
  return;
1001
1509
  }
1002
- runWrangler(["queues", "create", current.name], {
1003
- cwd: tenantRoot,
1004
- capture: true,
1005
- env
1006
- });
1007
- if (current.dlqName && !queues.find((entry) => queueName(entry) === current.dlqName)) {
1008
- runWrangler(["queues", "create", current.dlqName], {
1510
+ try {
1511
+ runWrangler(["queues", "create", current.name], {
1009
1512
  cwd: tenantRoot,
1010
1513
  capture: true,
1011
1514
  env
1012
1515
  });
1516
+ } catch (error) {
1517
+ if (!isWranglerAlreadyExistsError(error, [/Queue name .* is already taken/i, /\[code:\s*11009\]/i])) {
1518
+ throw error;
1519
+ }
1520
+ }
1521
+ refreshedQueues = listQueues(tenantRoot, env);
1522
+ if (current.dlqName && !refreshedQueues.find((entry) => queueName(entry) === current.dlqName)) {
1523
+ try {
1524
+ runWrangler(["queues", "create", current.dlqName], {
1525
+ cwd: tenantRoot,
1526
+ capture: true,
1527
+ env
1528
+ });
1529
+ } catch (error) {
1530
+ if (!isWranglerAlreadyExistsError(error, [/Queue name .* is already taken/i, /\[code:\s*11009\]/i])) {
1531
+ throw error;
1532
+ }
1533
+ }
1534
+ }
1535
+ refreshedQueues = listQueues(tenantRoot, env);
1536
+ const created = refreshedQueues.find((entry) => queueName(entry) === current.name);
1537
+ if (!created) {
1538
+ throw new Error(`Unable to resolve Cloudflare queue ${current.name} after reconciliation.`);
1013
1539
  }
1014
- const refreshed = listQueues(tenantRoot, env);
1015
- const created = refreshed.find((entry) => queueName(entry) === current.name);
1016
1540
  current.queueId = queueId(created);
1017
- const createdDlq = current.dlqName ? refreshed.find((entry) => queueName(entry) === current.dlqName) : null;
1541
+ const createdDlq = current.dlqName ? refreshedQueues.find((entry) => queueName(entry) === current.dlqName) : null;
1542
+ if (current.dlqName && !createdDlq) {
1543
+ throw new Error(`Unable to resolve Cloudflare dead-letter queue ${current.dlqName} after reconciliation.`);
1544
+ }
1018
1545
  current.dlqId = queueId(createdDlq);
1019
1546
  };
1020
1547
  const ensureR2Bucket = () => {
@@ -1022,18 +1549,29 @@ function provisionCloudflareResources(tenantRoot, options = {}) {
1022
1549
  if (!bucketName) {
1023
1550
  return;
1024
1551
  }
1025
- const exists = buckets.find((entry) => entry?.name === bucketName);
1552
+ let refreshedBuckets = buckets;
1553
+ const exists = refreshedBuckets.find((entry) => entry?.name === bucketName);
1026
1554
  if (exists) {
1027
1555
  return;
1028
1556
  }
1029
1557
  if (dryRun) {
1030
1558
  return;
1031
1559
  }
1032
- runWrangler(["r2", "bucket", "create", bucketName], {
1033
- cwd: tenantRoot,
1034
- capture: true,
1035
- env
1036
- });
1560
+ try {
1561
+ runWrangler(["r2", "bucket", "create", bucketName], {
1562
+ cwd: tenantRoot,
1563
+ capture: true,
1564
+ env
1565
+ });
1566
+ } catch (error) {
1567
+ if (!isWranglerAlreadyExistsError(error, [/bucket you tried to create already exists, and you own it/i, /\[code:\s*10004\]/i])) {
1568
+ throw error;
1569
+ }
1570
+ }
1571
+ refreshedBuckets = listR2Buckets(tenantRoot, env);
1572
+ if (!refreshedBuckets.find((entry) => entry?.name === bucketName)) {
1573
+ throw new Error(`Unable to resolve Cloudflare R2 bucket ${bucketName} after reconciliation.`);
1574
+ }
1037
1575
  };
1038
1576
  const ensurePagesProject = () => {
1039
1577
  const current = state.pages;
@@ -1069,12 +1607,16 @@ function provisionCloudflareResources(tenantRoot, options = {}) {
1069
1607
  ensureQueue();
1070
1608
  ensureR2Bucket();
1071
1609
  ensurePagesProject();
1610
+ reconcileCloudflareWebCacheRules(tenantRoot, deployConfig, state, target, { dryRun });
1072
1611
  state.readiness.configured = true;
1073
1612
  state.readiness.provisioned = hasProvisionedCloudflareResources(state);
1074
1613
  state.readiness.deployable = state.readiness.provisioned === true;
1614
+ state.readiness.phase = state.readiness.provisioned === true ? "provisioned" : "config_complete";
1075
1615
  state.readiness.initialized = true;
1076
1616
  state.readiness.initializedAt = (/* @__PURE__ */ new Date()).toISOString();
1077
1617
  state.readiness.lastValidatedAt = state.readiness.initializedAt;
1618
+ state.readiness.blockers = [];
1619
+ state.readiness.warnings = [];
1078
1620
  state.readiness.lastValidationSummary = {
1079
1621
  cloudflare: state.readiness.provisioned === true ? "ready" : "incomplete",
1080
1622
  railway: "configured"
@@ -1087,7 +1629,7 @@ function syncCloudflareSecrets(tenantRoot, options = {}) {
1087
1629
  const deployConfig = loadTenantDeployConfig(tenantRoot);
1088
1630
  const state = loadDeployState(tenantRoot, deployConfig, { target });
1089
1631
  const env = {
1090
- CLOUDFLARE_ACCOUNT_ID: deployConfig.cloudflare.accountId
1632
+ CLOUDFLARE_ACCOUNT_ID: resolveConfiguredCloudflareAccountId(deployConfig)
1091
1633
  };
1092
1634
  const secrets = buildSecretMap(deployConfig, state);
1093
1635
  const synced = [];
@@ -1100,7 +1642,8 @@ function syncCloudflareSecrets(tenantRoot, options = {}) {
1100
1642
  if (dryRun) {
1101
1643
  continue;
1102
1644
  }
1103
- const result = spawnSync(process.execPath, [resolveWranglerBin(), "secret", "put", key, "--config", resolveGeneratedWranglerPath(tenantRoot, { target })], {
1645
+ const command = state.pages?.projectName && target.kind === "persistent" ? [resolveWranglerBin(), "pages", "secret", "put", key, "--project-name", state.pages.projectName] : [resolveWranglerBin(), "secret", "put", key, "--config", resolveGeneratedWranglerPath(tenantRoot, { target })];
1646
+ const result = spawnSync(process.execPath, command, {
1104
1647
  cwd: tenantRoot,
1105
1648
  input: `${value}
1106
1649
  `,
@@ -1125,7 +1668,7 @@ function verifyProvisionedCloudflareResources(tenantRoot, options = {}) {
1125
1668
  const deployConfig = loadTenantDeployConfig(tenantRoot);
1126
1669
  const state = loadDeployState(tenantRoot, deployConfig, { target });
1127
1670
  const env = {
1128
- CLOUDFLARE_ACCOUNT_ID: deployConfig.cloudflare.accountId
1671
+ CLOUDFLARE_ACCOUNT_ID: resolveConfiguredCloudflareAccountId(deployConfig)
1129
1672
  };
1130
1673
  const dryRun = options.dryRun ?? false;
1131
1674
  const kvNamespaces = dryRun ? [] : listKvNamespaces(tenantRoot, env);
@@ -1140,13 +1683,17 @@ function verifyProvisionedCloudflareResources(tenantRoot, options = {}) {
1140
1683
  d1: Boolean(state.d1Databases?.SITE_DATA_DB?.databaseName && d1Databases.find((entry) => entry?.name === state.d1Databases.SITE_DATA_DB.databaseName)),
1141
1684
  queue: Boolean(state.queues?.agentWork?.name && queues.find((entry) => queueName(entry) === state.queues.agentWork.name)),
1142
1685
  dlq: !state.queues?.agentWork?.dlqName || Boolean(queues.find((entry) => queueName(entry) === state.queues.agentWork.dlqName)),
1143
- r2: Boolean(state.content?.bucketName && buckets.find((entry) => entry?.name === state.content.bucketName))
1686
+ r2: Boolean(state.content?.bucketName && buckets.find((entry) => entry?.name === state.content.bucketName)),
1687
+ webCache: !shouldManageCloudflareWebCacheRules(deployConfig, target) || state.webCache?.rulesManaged === true
1144
1688
  };
1145
1689
  const ok = dryRun ? true : Object.values(checks).every(Boolean);
1146
1690
  state.readiness.configured = true;
1147
1691
  state.readiness.provisioned = ok;
1148
1692
  state.readiness.deployable = ok;
1693
+ state.readiness.phase = ok ? "provisioned" : "config_complete";
1149
1694
  state.readiness.lastValidatedAt = (/* @__PURE__ */ new Date()).toISOString();
1695
+ state.readiness.blockers = [];
1696
+ state.readiness.warnings = [];
1150
1697
  state.readiness.lastValidationSummary = checks;
1151
1698
  const liveQueue = queues.find((entry) => queueName(entry) === state.queues?.agentWork?.name);
1152
1699
  if (state.queues?.agentWork) {
@@ -1158,6 +1705,15 @@ function verifyProvisionedCloudflareResources(tenantRoot, options = {}) {
1158
1705
  if (state.pages && livePages?.subdomain) {
1159
1706
  state.pages.url = `https://${livePages.subdomain}`;
1160
1707
  }
1708
+ if (!dryRun) {
1709
+ try {
1710
+ reconcileCloudflareWebCacheRules(tenantRoot, deployConfig, state, target, { dryRun: false });
1711
+ } catch (error) {
1712
+ state.webCache.rulesManaged = false;
1713
+ state.webCache.lastError = error instanceof Error ? error.message : String(error);
1714
+ }
1715
+ }
1716
+ state.webCache.lastVerifiedAt = (/* @__PURE__ */ new Date()).toISOString();
1161
1717
  writeDeployState(tenantRoot, state, { target });
1162
1718
  return {
1163
1719
  ok,
@@ -1176,7 +1732,7 @@ function runRemoteD1Migrations(tenantRoot, options = {}) {
1176
1732
  ["d1", "migrations", "apply", state.d1Databases.SITE_DATA_DB.databaseName, "--remote", "--config", wranglerPath],
1177
1733
  {
1178
1734
  cwd: tenantRoot,
1179
- env: { CLOUDFLARE_ACCOUNT_ID: deployConfig.cloudflare.accountId }
1735
+ env: { CLOUDFLARE_ACCOUNT_ID: resolveConfiguredCloudflareAccountId(deployConfig) }
1180
1736
  }
1181
1737
  );
1182
1738
  return { databaseName: state.d1Databases.SITE_DATA_DB.databaseName, dryRun: false };
@@ -1190,9 +1746,12 @@ function markDeploymentInitialized(tenantRoot, options = {}) {
1190
1746
  state.readiness.configured = true;
1191
1747
  state.readiness.provisioned = hasProvisionedCloudflareResources(state);
1192
1748
  state.readiness.deployable = state.readiness.provisioned === true;
1749
+ state.readiness.phase = state.readiness.provisioned === true ? "provisioned" : "config_complete";
1193
1750
  state.readiness.initializedAt = state.readiness.initializedAt ?? timestamp;
1194
1751
  state.readiness.lastValidatedAt = timestamp;
1195
1752
  state.readiness.lastConfigFingerprint = state.lastManifestFingerprint ?? state.readiness.lastConfigFingerprint;
1753
+ state.readiness.blockers = [];
1754
+ state.readiness.warnings = [];
1196
1755
  writeDeployState(tenantRoot, state, { target });
1197
1756
  return state;
1198
1757
  }
@@ -1252,7 +1811,10 @@ function finalizeDeploymentState(tenantRoot, options = {}) {
1252
1811
  state.readiness.configured = true;
1253
1812
  state.readiness.provisioned = hasProvisionedCloudflareResources(state);
1254
1813
  state.readiness.deployable = state.readiness.provisioned === true;
1814
+ state.readiness.phase = state.readiness.provisioned === true ? "provisioned" : "config_complete";
1255
1815
  state.readiness.lastValidatedAt = state.lastDeploymentTimestamp;
1816
+ state.readiness.blockers = [];
1817
+ state.readiness.warnings = [];
1256
1818
  for (const result of options.serviceResults ?? []) {
1257
1819
  if (!result?.service || !state.services?.[result.service]) {
1258
1820
  continue;
@@ -1263,6 +1825,13 @@ function finalizeDeploymentState(tenantRoot, options = {}) {
1263
1825
  state.services[result.service].lastDeploymentCommand = result.command ?? null;
1264
1826
  }
1265
1827
  writeDeployState(tenantRoot, state, { target });
1828
+ if (target.kind === "persistent") {
1829
+ try {
1830
+ purgeSourcePageCaches(tenantRoot, { target });
1831
+ } catch {
1832
+ }
1833
+ return loadDeployState(tenantRoot, deployConfig, { target });
1834
+ }
1266
1835
  return state;
1267
1836
  }
1268
1837
  function printDeploySummary(summary) {
@@ -1311,6 +1880,8 @@ export {
1311
1880
  printDestroySummary,
1312
1881
  promptForMissingDeployInputs,
1313
1882
  provisionCloudflareResources,
1883
+ purgePublishedContentCaches,
1884
+ purgeSourcePageCaches,
1314
1885
  resolveGeneratedWranglerPath,
1315
1886
  runRemoteD1Migrations,
1316
1887
  scopeFromTarget,