@smoothbricks/cli 0.11.19 → 0.11.20

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 (83) hide show
  1. package/README.md +68 -5
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +28 -4
  4. package/dist/lib/json.d.ts +22 -1
  5. package/dist/lib/json.d.ts.map +1 -1
  6. package/dist/lib/json.js +15 -3
  7. package/dist/monorepo/cargo-policy.d.ts +17 -0
  8. package/dist/monorepo/cargo-policy.d.ts.map +1 -1
  9. package/dist/monorepo/cargo-policy.js +73 -1
  10. package/dist/monorepo/index.d.ts.map +1 -1
  11. package/dist/monorepo/index.js +4 -2
  12. package/dist/monorepo/packs/index.d.ts.map +1 -1
  13. package/dist/monorepo/packs/index.js +6 -1
  14. package/dist/nx/index.d.ts +6 -0
  15. package/dist/nx/index.d.ts.map +1 -1
  16. package/dist/nx/index.js +14 -0
  17. package/dist/secrets/commands.d.ts +9 -5
  18. package/dist/secrets/commands.d.ts.map +1 -1
  19. package/dist/secrets/commands.js +122 -49
  20. package/dist/secrets/index.d.ts +42 -56
  21. package/dist/secrets/index.d.ts.map +1 -1
  22. package/dist/secrets/index.js +52 -79
  23. package/dist/secrets/resolver.d.ts +59 -0
  24. package/dist/secrets/resolver.d.ts.map +1 -0
  25. package/dist/secrets/resolver.js +100 -0
  26. package/dist/secrets/run.d.ts +23 -0
  27. package/dist/secrets/run.d.ts.map +1 -0
  28. package/dist/secrets/run.js +130 -0
  29. package/dist/secrets/status.d.ts +177 -0
  30. package/dist/secrets/status.d.ts.map +1 -0
  31. package/dist/secrets/status.js +724 -0
  32. package/dist/wrangler/deploy-stage.d.ts +1 -1
  33. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  34. package/dist/wrangler/deploy-stage.js +22 -20
  35. package/dist/wrangler/deployed-version.d.ts.map +1 -1
  36. package/dist/wrangler/deployed-version.js +7 -12
  37. package/dist/wrangler/flat-config.d.ts +1 -4
  38. package/dist/wrangler/flat-config.d.ts.map +1 -1
  39. package/dist/wrangler/flat-config.js +98 -44
  40. package/dist/wrangler/prepare-env.d.ts +4 -3
  41. package/dist/wrangler/prepare-env.d.ts.map +1 -1
  42. package/dist/wrangler/prepare-env.js +7 -5
  43. package/dist/wrangler/source-config.d.ts +24 -0
  44. package/dist/wrangler/source-config.d.ts.map +1 -0
  45. package/dist/wrangler/source-config.js +110 -0
  46. package/dist/wrangler/stage.d.ts +55 -23
  47. package/dist/wrangler/stage.d.ts.map +1 -1
  48. package/dist/wrangler/stage.js +81 -162
  49. package/managed/raw/tooling/direnv/devenv.smoo.nix +19 -3
  50. package/managed/raw/tooling/direnv/secret-references.ts +280 -76
  51. package/managed/raw/tooling/direnv/setup-environment.ts +58 -2
  52. package/managed/raw/tsconfig.lib.json +28 -0
  53. package/package.json +7 -2
  54. package/src/cli.ts +34 -5
  55. package/src/lib/json.ts +23 -1
  56. package/src/monorepo/cargo-policy.test.ts +91 -1
  57. package/src/monorepo/cargo-policy.ts +87 -1
  58. package/src/monorepo/index.ts +8 -2
  59. package/src/monorepo/packs/index.ts +10 -1
  60. package/src/monorepo/secret-references.test.ts +412 -6
  61. package/src/monorepo/setup-environment.test.ts +181 -0
  62. package/src/nx/index.test.ts +8 -0
  63. package/src/nx/index.ts +20 -0
  64. package/src/secrets/commands.test.ts +186 -4
  65. package/src/secrets/commands.ts +142 -51
  66. package/src/secrets/index.test.ts +4 -10
  67. package/src/secrets/index.ts +54 -115
  68. package/src/secrets/resolver.ts +130 -0
  69. package/src/secrets/run.test.ts +359 -0
  70. package/src/secrets/run.ts +148 -0
  71. package/src/secrets/status.test.ts +164 -0
  72. package/src/secrets/status.ts +297 -0
  73. package/src/wrangler/deploy-stage.test.ts +95 -2
  74. package/src/wrangler/deploy-stage.ts +26 -23
  75. package/src/wrangler/deployed-version.ts +7 -11
  76. package/src/wrangler/flat-config.test.ts +9 -4
  77. package/src/wrangler/flat-config.ts +1 -20
  78. package/src/wrangler/format-parity.test.ts +433 -0
  79. package/src/wrangler/prepare-env.ts +7 -5
  80. package/src/wrangler/source-config.test.ts +102 -0
  81. package/src/wrangler/source-config.ts +110 -0
  82. package/src/wrangler/stage.test.ts +61 -32
  83. package/src/wrangler/stage.ts +124 -173
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
+ import { parseWranglerConfig } from './source-config.js';
2
3
  import {
4
+ derivePullRequestDocument,
3
5
  derivePullRequestStageConfig,
4
- derivePullRequestWranglerConfig,
5
6
  isPullRequestStage,
6
7
  planPullRequestBindings,
7
8
  planPullRequestResources,
@@ -9,8 +10,14 @@ import {
9
10
  rateLimitNamespaceId,
10
11
  stageDomain,
11
12
  stageResourceName,
13
+ type WranglerDocument,
12
14
  } from './stage.js';
13
15
 
16
+ /** The fixtures below are TOML; the derivation only ever sees the model a parser produces. */
17
+ function document(toml: string): WranglerDocument {
18
+ return parseWranglerConfig('wrangler.toml', toml, 'toml');
19
+ }
20
+
14
21
  const APP_FIXTURE = `name = "app"
15
22
  compatibility_date = "2026-05-06"
16
23
 
@@ -133,42 +140,50 @@ describe('Wrangler deployment stage convention', () => {
133
140
  });
134
141
 
135
142
  it('derives the app staging block without changing inherited/static semantics', () => {
136
- const derived = derivePullRequestWranglerConfig(APP_FIXTURE, {
143
+ const source = document(APP_FIXTURE);
144
+ const derived = derivePullRequestDocument(source, {
137
145
  stage: 'pr123',
138
146
  accountId: 'account-1',
139
147
  kvNamespaceIds: new Map<string, string>(),
140
148
  d1DatabaseIds: new Map<string, string>(),
141
149
  });
142
150
 
143
- expect(derived).toContain('[env.pr123]\nname = "app-pr123"');
144
- expect(derived).toContain('pattern = "*.pr123.example.test/*"');
145
- expect(derived).toContain('pattern = "pr123.example.test"');
146
- expect(derived.slice(derived.indexOf('[env.pr123]'))).not.toContain('next.example.test');
147
- expect(derived).toContain('[env.pr123.assets]\ndirectory = "./dist"');
148
- expect(derived).toContain('compatibility_date = "2026-05-06"');
149
- expect(derived).not.toContain('pr456');
151
+ expect(derived.env?.pr123).toEqual({
152
+ name: 'app-pr123',
153
+ workers_dev: false,
154
+ assets: { directory: './dist', not_found_handling: 'single-page-application' },
155
+ routes: [
156
+ { pattern: '*.pr123.example.test/*', zone_name: 'example.test' },
157
+ { pattern: 'pr123.example.test', custom_domain: true },
158
+ ],
159
+ });
160
+ // Inherited top-level keys and the staging block the stage was derived from are untouched.
161
+ expect(derived.compatibility_date).toBe('2026-05-06');
162
+ expect(derived.env?.staging).toEqual(source.env?.staging);
163
+ expect(JSON.stringify(derived)).not.toContain('pr456');
150
164
  });
151
165
 
152
- it('refuses an all-pinned TOML template before any resource is named', () => {
153
- const pinned = `name = "app"
166
+ it('refuses an all-pinned template before any resource is named', () => {
167
+ const pinned = document(`name = "app"
154
168
  [env.staging]
155
169
  name = "app-staging"
156
170
  [[env.staging.routes]]
157
171
  pattern = "next.example.test"
158
172
  custom_domain = true
159
- `;
173
+ `);
160
174
  expect(() => planPullRequestResources(pinned, 'pr123', [])).toThrow(/pinned/);
161
175
  expect(() => planPullRequestResources(pinned, 'pr123', [])).toThrow(/next\.example\.test/);
162
176
  });
163
177
 
164
178
  it('derives backend resources from staging while preserving provider and DO identities', () => {
165
- const plan = planPullRequestResources(BACKEND_FIXTURE, 'pr123', LIVE_NAMESPACES);
179
+ const source = document(BACKEND_FIXTURE);
180
+ const plan = planPullRequestResources(source, 'pr123', LIVE_NAMESPACES);
166
181
  expect(plan.workerName).toBe('app-backend-pr123');
167
182
  expect(plan.kvNamespaces.map(({ title }) => title)).toEqual(['alias-index-pr123', 'org-profiles-pr123']);
168
183
  expect(plan.d1Databases).toEqual([{ binding: 'DB', stagingId: 'd1-staging-id', name: 'app-pr123-db' }]);
169
184
  expect(plan.r2Buckets).toEqual([{ binding: 'MEDIA', bucketName: 'app-media-pr123' }]);
170
185
 
171
- const derived = derivePullRequestWranglerConfig(BACKEND_FIXTURE, {
186
+ const derived = derivePullRequestDocument(source, {
172
187
  stage: 'pr123',
173
188
  accountId: 'account-1',
174
189
  kvNamespaceIds: DERIVED_IDS,
@@ -180,24 +195,38 @@ custom_domain = true
180
195
  expect(Number(signupId)).toBeGreaterThan(0);
181
196
  expect(Number(signupId)).toBeLessThanOrEqual(0x7fff_ffff);
182
197
  expect(loginId).not.toBe(signupId);
183
- expect(derived).toContain(`namespace_id = "${signupId}"`);
184
- expect(derived).toContain(`namespace_id = "${loginId}"`);
185
- expect(derived).toContain('id = "kv-alias-pr123-id"');
186
- expect(derived).toContain('id = "kv-org-pr123-id"');
187
- expect(derived).toContain('database_name = "app-pr123-db"');
188
- expect(derived).toContain('database_id = "d1-pr123-id"');
189
- expect(derived).toContain('bucket_name = "app-media-pr123"');
190
- expect(derived).toContain('service = "app-backend-pr123"');
191
- expect(derived).toContain('service = "auth-service"');
192
- expect(derived).toContain('ENVIRONMENT = "pr123"');
193
- expect(derived).toContain('TLD_DOMAIN = "pr123.example.test"');
194
- expect(derived).toContain('BUILD_ID = "staging-20260716-2"');
195
- expect(derived).toContain('allowed_sender_addresses = ["login@mail.pr123.example.test"]');
196
- expect(derived).toContain('MAIL_RECIPIENTS = "login-test@pr123.example.test,invite-test@pr123.example.test"');
197
- expect(derived).toContain('APP_ORIGIN = "https://app.pr123.example.test"');
198
- expect(derived).toContain('class_name = "QueueDO"');
199
- expect(derived).toContain('new_sqlite_classes = ["QueueDO", "StoreDO"]');
200
- expect(derived).not.toContain('pr456');
198
+ expect(derived.env?.pr123).toEqual({
199
+ name: 'app-backend-pr123',
200
+ workers_dev: false,
201
+ routes: [{ pattern: '*.pr123.example.test/auth/*', zone_name: 'example.test' }],
202
+ durable_objects: { bindings: [{ name: 'QUEUE', class_name: 'QueueDO' }] },
203
+ kv_namespaces: [
204
+ { binding: 'ALIAS_INDEX', id: 'kv-alias-pr123-id' },
205
+ { binding: 'ORG_PROFILES', id: 'kv-org-pr123-id' },
206
+ ],
207
+ d1_databases: [{ binding: 'DB', database_name: 'app-pr123-db', database_id: 'd1-pr123-id' }],
208
+ services: [
209
+ { binding: 'BACKEND', service: 'app-backend-pr123' },
210
+ { binding: 'SHARED', service: 'auth-service' },
211
+ ],
212
+ send_email: [{ name: 'EMAIL', allowed_sender_addresses: ['login@mail.pr123.example.test'] }],
213
+ ratelimits: [
214
+ { name: 'SIGNUP', namespace_id: signupId, simple: { limit: 5, period: 60 } },
215
+ { name: 'LOGIN', namespace_id: loginId, simple: { limit: 20, period: 60 } },
216
+ ],
217
+ r2_buckets: [{ binding: 'MEDIA', bucket_name: 'app-media-pr123' }],
218
+ vars: {
219
+ ENVIRONMENT: 'pr123',
220
+ TLD_DOMAIN: 'pr123.example.test',
221
+ BUILD_ID: 'staging-20260716-2',
222
+ MAIL_RECIPIENTS: 'login-test@pr123.example.test,invite-test@pr123.example.test',
223
+ EMAIL_FROM_ADDRESS: 'login@mail.pr123.example.test',
224
+ APP_ORIGIN: 'https://app.pr123.example.test',
225
+ },
226
+ });
227
+ // The Durable Object migrations are a top-level, stage-independent declaration.
228
+ expect(derived.migrations).toEqual([{ tag: 'v1', new_sqlite_classes: ['QueueDO', 'StoreDO'] }]);
229
+ expect(JSON.stringify(derived)).not.toContain('pr456');
201
230
  });
202
231
 
203
232
  it('refuses unlabelled mutable KV, R2 and D1 before a pull-request stage can share them', () => {
@@ -1,7 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { getStaticTOMLValue, parseTOML } from 'toml-eslint-parser';
3
2
  import typia from 'typia';
4
- import { cloneEnvBlock } from './prepare-env.js';
5
3
  import { derivedStagingName, hasStageLabel, replaceExactToken, replaceHostnameLabel } from './stage-labels.js';
6
4
 
7
5
  export type DeploymentStage = 'staging' | 'production' | `pr${number}`;
@@ -53,23 +51,25 @@ export function hasExactStageSegment(value: string, stage: `pr${number}`): boole
53
51
  return new RegExp(`(?:^|[-.])${escaped}(?=$|[-.])`).test(value);
54
52
  }
55
53
 
56
- interface WranglerRoot {
54
+ /**
55
+ * A whole Wrangler configuration as data — the one model both source formats parse into
56
+ * (`source-config.ts` owns the parsers). Nothing in this module reads configuration text, which
57
+ * is what makes a stage derive identically from `wrangler.toml` and from `wrangler.jsonc`.
58
+ */
59
+ export interface WranglerDocument {
57
60
  env?: Record<string, WranglerEnvironment | undefined>;
61
+ [key: string]: unknown;
58
62
  }
59
63
 
60
- interface WranglerEnvironment {
61
- name?: unknown;
62
- routes?: unknown;
63
- kv_namespaces?: unknown;
64
- r2_buckets?: unknown;
65
- d1_databases?: unknown;
66
- services?: unknown;
67
- ratelimits?: unknown;
68
- vars?: unknown;
69
- send_email?: unknown;
64
+ /**
65
+ * One `env` block, still unparsed. `StageConfigFields` is the typed statement of what this
66
+ * module reads out of it; every other key is carried into a derived stage, never interpreted.
67
+ */
68
+ export interface WranglerEnvironment {
70
69
  [key: string]: unknown;
71
70
  }
72
71
 
72
+ /** One reconciled binding of the plan: exactly what Cloudflare is asked about, and nothing else. */
73
73
  export interface KvBinding {
74
74
  binding: string;
75
75
  id: string;
@@ -79,23 +79,27 @@ export interface R2Binding {
79
79
  bucketName: string;
80
80
  }
81
81
 
82
- /** One-stage bindings that a TOML env block and a flat JSON config both present. */
83
- export interface StageConfigFields {
84
- name: string;
85
- routes?: StageRoute[];
86
- vars?: Record<string, unknown>;
87
- kv_namespaces?: KvBinding[];
88
- r2_buckets?: { binding: string; bucket_name: string }[];
89
- d1_databases?: StageD1Binding[];
90
- services?: StageServiceBinding[];
91
- ratelimits?: StageRateLimit[];
92
- send_email?: Array<{ allowed_sender_addresses?: unknown; [key: string]: unknown }>;
93
- }
82
+ // Configuration rows, as opposed to plan rows: the fields the derivation proves and rewrites,
83
+ // plus every other key of the row carried verbatim. A route's `zone_id`, a KV binding's
84
+ // `preview_id` and anything Cloudflare adds next survive into the derived config untouched.
94
85
 
95
86
  export interface StageRoute {
96
87
  pattern: string;
97
88
  zone_name?: string;
98
89
  custom_domain?: boolean;
90
+ [key: string]: unknown;
91
+ }
92
+
93
+ export interface StageKvBinding {
94
+ binding: string;
95
+ id: string;
96
+ [key: string]: unknown;
97
+ }
98
+
99
+ export interface StageR2Binding {
100
+ binding: string;
101
+ bucket_name: string;
102
+ [key: string]: unknown;
99
103
  }
100
104
 
101
105
  export interface StageD1Binding {
@@ -103,24 +107,40 @@ export interface StageD1Binding {
103
107
  database_name: string;
104
108
  database_id: string;
105
109
  migrations_dir?: string;
110
+ [key: string]: unknown;
106
111
  }
107
112
 
108
113
  export interface StageServiceBinding {
109
114
  binding: string;
110
115
  service: string;
111
116
  environment?: string;
117
+ [key: string]: unknown;
112
118
  }
113
119
 
114
120
  export interface StageRateLimit {
115
121
  name: string;
116
122
  namespace_id: string;
117
- simple?: unknown;
123
+ [key: string]: unknown;
124
+ }
125
+
126
+ /** One-stage bindings that an env block and a flat JSON config both present. */
127
+ export interface StageConfigFields {
128
+ name: string;
129
+ routes?: StageRoute[];
130
+ vars?: Record<string, unknown>;
131
+ kv_namespaces?: StageKvBinding[];
132
+ r2_buckets?: StageR2Binding[];
133
+ d1_databases?: StageD1Binding[];
134
+ services?: StageServiceBinding[];
135
+ ratelimits?: StageRateLimit[];
136
+ send_email?: Record<string, unknown>[];
137
+ [key: string]: unknown;
118
138
  }
119
139
 
120
- const isWranglerRoot = typia.createIs<WranglerRoot>();
121
140
  const isWranglerEnvironment = typia.createIs<WranglerEnvironment>();
122
141
  const isUnknownRecord = typia.createIs<Record<string, unknown>>();
123
142
  const isUnknownRows = typia.createIs<Record<string, unknown>[]>();
143
+ const isRouteRows = typia.createIs<Array<string | Record<string, unknown>>>();
124
144
 
125
145
  export interface LiveKvNamespace {
126
146
  id: string;
@@ -157,35 +177,53 @@ export interface ConfiguredStageResourcePlan {
157
177
  routes: Array<{ pattern: string; zoneName?: string; customDomain: boolean }>;
158
178
  }
159
179
 
160
- export function planConfiguredStageResources(toml: string, stage: DeploymentStage): ConfiguredStageResourcePlan {
161
- const block = parseRoot(toml).env?.[stage];
162
- if (!isWranglerEnvironment(block)) {
163
- throw new Error(`Wrangler configuration must declare [env.${stage}].`);
164
- }
165
- const workerName = requiredString(block, 'name', `[env.${stage}]`);
180
+ /** The reconcile plan (`reconcileStageResources`) read off one resolved stage's bindings. */
181
+ export function planStageResources(config: StageConfigFields, stage: DeploymentStage): ConfiguredStageResourcePlan {
166
182
  return {
167
183
  stage,
168
- workerName,
169
- kvNamespaces: readKvBindings(block.kv_namespaces),
170
- r2Buckets: readR2Bindings(block.r2_buckets),
171
- routes: readRoutes(block.routes),
184
+ workerName: config.name,
185
+ kvNamespaces: (config.kv_namespaces ?? []).map(({ binding, id }) => ({ binding, id })),
186
+ r2Buckets: (config.r2_buckets ?? []).map((bucket) => ({ binding: bucket.binding, bucketName: bucket.bucket_name })),
187
+ routes: (config.routes ?? []).map((route) => ({
188
+ pattern: route.pattern,
189
+ ...(typeof route.zone_name === 'string' ? { zoneName: route.zone_name } : {}),
190
+ customDomain: route.custom_domain === true,
191
+ })),
172
192
  };
173
193
  }
174
194
 
175
- function parseRoot(toml: string): WranglerRoot {
176
- const value: unknown = getStaticTOMLValue(parseTOML(toml));
177
- if (!isWranglerRoot(value)) {
178
- throw new Error('Wrangler configuration is not a valid TOML environment document.');
179
- }
180
- return value;
195
+ export function planConfiguredStageResources(
196
+ document: WranglerDocument,
197
+ stage: DeploymentStage,
198
+ ): ConfiguredStageResourcePlan {
199
+ return planStageResources(stageEnvironmentConfig(document, stage), stage);
181
200
  }
182
201
 
183
- function stagingEnvironment(toml: string): WranglerEnvironment {
184
- const staging = parseRoot(toml).env?.staging;
185
- if (!isWranglerEnvironment(staging)) {
186
- throw new Error('Wrangler configuration must declare [env.staging].');
202
+ /** The `env.<stage>` block as the stage model, or the refusal that it is not declared. */
203
+ function stageEnvironmentConfig(document: WranglerDocument, stage: DeploymentStage): StageConfigFields {
204
+ const block = document.env?.[stage];
205
+ if (!isWranglerEnvironment(block)) {
206
+ throw new Error(`Wrangler configuration must declare [env.${stage}].`);
187
207
  }
188
- return staging;
208
+ return environmentStageConfig(block, `[env.${stage}]`);
209
+ }
210
+
211
+ /**
212
+ * One env block as the stage model: every field the derivation reads proven present and typed,
213
+ * every other key carried verbatim. A field absent from the source stays absent, so the same
214
+ * worker written as TOML and as JSONC produces the same model down to the key set.
215
+ */
216
+ function environmentStageConfig(environment: WranglerEnvironment, label: string): StageConfigFields {
217
+ const config: StageConfigFields = { ...environment, name: requiredString(environment, 'name', label) };
218
+ if (environment.routes !== undefined) config.routes = readStageRoutes(environment.routes);
219
+ if (isUnknownRecord(environment.vars)) config.vars = environment.vars;
220
+ if (environment.kv_namespaces !== undefined) config.kv_namespaces = readKvBindings(environment.kv_namespaces);
221
+ if (environment.r2_buckets !== undefined) config.r2_buckets = readR2Buckets(environment.r2_buckets);
222
+ if (environment.d1_databases !== undefined) config.d1_databases = readD1Bindings(environment.d1_databases);
223
+ if (environment.services !== undefined) config.services = readServices(environment.services);
224
+ if (environment.ratelimits !== undefined) config.ratelimits = readRateLimits(environment.ratelimits);
225
+ if (environment.send_email !== undefined) config.send_email = readRows(environment.send_email);
226
+ return config;
189
227
  }
190
228
 
191
229
  /** The staging worker name without its required `-staging` suffix. */
@@ -196,13 +234,6 @@ export function stagingWorkerBaseName(name: string): string {
196
234
  return name.slice(0, -'-staging'.length);
197
235
  }
198
236
 
199
- function stagingWorkerName(staging: WranglerEnvironment): { workerName: string; workerBaseName: string } {
200
- if (typeof staging.name !== 'string' || !staging.name) {
201
- throw new Error('[env.staging] must declare a non-empty name.');
202
- }
203
- return { workerName: staging.name, workerBaseName: stagingWorkerBaseName(staging.name) };
204
- }
205
-
206
237
  /**
207
238
  * A template whose routes are all pinned (no derivable stage label) cannot serve a
208
239
  * pull-request stage: it would deploy unrouted and Wrangler would expose it on workers.dev.
@@ -216,34 +247,15 @@ export function assertPullRequestRoutable(name: string, routes: StageRoute[] | u
216
247
  );
217
248
  }
218
249
 
219
- function stagingStageConfig(toml: string): StageConfigFields {
220
- const staging = stagingEnvironment(toml);
221
- const { workerName } = stagingWorkerName(staging);
222
- return {
223
- name: workerName,
224
- routes: readStageRoutes(staging.routes),
225
- ...(isUnknownRecord(staging.vars) ? { vars: staging.vars } : {}),
226
- kv_namespaces: readKvBindings(staging.kv_namespaces),
227
- r2_buckets: readRows(staging.r2_buckets).map((row) => {
228
- const binding = requiredString(row, 'binding', 'R2 binding');
229
- return { binding, bucket_name: requiredString(row, 'bucket_name', `R2 binding ${binding}`) };
230
- }),
231
- d1_databases: readD1Bindings(staging.d1_databases),
232
- services: readServices(staging.services),
233
- ratelimits: readRateLimits(staging.ratelimits),
234
- send_email: readRows(staging.send_email),
235
- };
236
- }
237
-
238
250
  export function planPullRequestResources(
239
- toml: string,
251
+ document: WranglerDocument,
240
252
  stage: `pr${number}`,
241
253
  liveNamespaces: LiveKvNamespace[],
242
254
  ): PullRequestResourcePlan {
243
- return planPullRequestBindings(stagingStageConfig(toml), stage, liveNamespaces);
255
+ return planPullRequestBindings(stageEnvironmentConfig(document, 'staging'), stage, liveNamespaces);
244
256
  }
245
257
 
246
- /** KV/R2/D1 isolation and route checks for a pull-request stage, shared by TOML and JSON. */
258
+ /** KV/R2/D1 isolation and route checks for a pull-request stage, shared by every source format. */
247
259
  export function planPullRequestBindings(
248
260
  config: StageConfigFields,
249
261
  stage: `pr${number}`,
@@ -454,50 +466,22 @@ function requiredDerivedString(value: unknown): string {
454
466
  return value;
455
467
  }
456
468
 
457
- export function derivePullRequestWranglerConfig(toml: string, options: DerivePullRequestConfigOptions): string {
458
- const ctx = deriveContext(stagingWorkerName(stagingEnvironment(toml)).workerName, options);
459
- const cloned = cloneEnvBlock(toml, 'staging', ctx.stage);
460
- const program = parseTOML(cloned);
461
- const rootValue: unknown = getStaticTOMLValue(program);
462
- if (!isWranglerRoot(rootValue)) {
463
- throw new Error('Derived Wrangler configuration is not a valid environment document.');
464
- }
465
- const root = rootValue;
466
- const edits: Array<{ start: number; end: number; value: string }> = [];
467
-
468
- for (const table of program.body[0].body) {
469
- if (table.type !== 'TOMLTable' || table.resolvedKey[0] !== 'env' || table.resolvedKey[1] !== ctx.stage) {
470
- continue;
471
- }
472
- const tableValue = valueAtPath(root, table.resolvedKey);
473
- if (!isUnknownRecord(tableValue)) {
474
- continue;
475
- }
476
- const section = typeof table.resolvedKey[2] === 'string' ? table.resolvedKey[2] : undefined;
477
- if (section === 'routes') {
478
- const pattern = tableValue.pattern;
479
- if (typeof pattern === 'string' && !hasStageLabel(pattern)) {
480
- edits.push({ start: table.range[0], end: table.range[1], value: '' });
481
- continue;
482
- }
483
- }
484
- const nearbyName = typeof tableValue.name === 'string' ? tableValue.name : undefined;
485
- for (const keyValue of table.body) {
486
- const key = cloned.slice(keyValue.key.range[0], keyValue.key.range[1]).trim();
487
- const current = tableValue[key];
488
- const next = deriveStageField(section, key, current, ctx, nearbyName);
489
- if (next !== current) {
490
- edits.push({ start: keyValue.value.range[0], end: keyValue.value.range[1], value: tomlLiteral(next) });
491
- }
492
- }
493
- }
494
-
495
- let derived = cloned;
496
- for (const edit of edits.sort((left, right) => right.start - left.start)) {
497
- derived = derived.slice(0, edit.start) + edit.value + derived.slice(edit.end);
469
+ /**
470
+ * The whole document with `env.<prN>` added, derived from `env.staging`. Nothing read from disk
471
+ * is mutated every level the derivation touches is rebuilt — so the committed source config is
472
+ * still exactly what the repo wrote once the deploy is done with it.
473
+ */
474
+ export function derivePullRequestDocument(
475
+ document: WranglerDocument,
476
+ options: DerivePullRequestConfigOptions,
477
+ ): WranglerDocument {
478
+ if (document.env?.[options.stage] !== undefined) {
479
+ throw new Error(
480
+ `Wrangler configuration already declares [env.${options.stage}]; a pull-request stage is derived from [env.staging] and must not be committed.`,
481
+ );
498
482
  }
499
- parseTOML(derived);
500
- return derived;
483
+ const derived = derivePullRequestStageConfig(stageEnvironmentConfig(document, 'staging'), options);
484
+ return { ...document, env: { ...document.env, [options.stage]: derived } };
501
485
  }
502
486
 
503
487
  export function rateLimitNamespaceId(
@@ -512,70 +496,59 @@ export function rateLimitNamespaceId(
512
496
  return String(value === 0 ? 1 : value);
513
497
  }
514
498
 
515
- function readKvBindings(value: unknown): KvBinding[] {
499
+ /**
500
+ * Wrangler accepts a route as an object or as a bare pattern string. Both become the object form
501
+ * here, so one derivation rewrites the hostname label either way — a string route used to be
502
+ * carried into the pull-request stage verbatim, pointing it at staging's own hostname.
503
+ */
504
+ function readStageRoutes(value: unknown): StageRoute[] {
505
+ if (!isRouteRows(value)) return [];
506
+ return value.map((row) =>
507
+ typeof row === 'string' ? { pattern: row } : { ...row, pattern: requiredString(row, 'pattern', 'route') },
508
+ );
509
+ }
510
+
511
+ function readKvBindings(value: unknown): StageKvBinding[] {
516
512
  return readRows(value).map((row) => ({
513
+ ...row,
517
514
  binding: requiredString(row, 'binding', 'KV namespace'),
518
515
  id: requiredString(row, 'id', 'KV namespace'),
519
516
  }));
520
517
  }
521
518
 
522
- function readR2Bindings(value: unknown): R2Binding[] {
519
+ function readR2Buckets(value: unknown): StageR2Binding[] {
523
520
  return readRows(value).map((row) => {
524
521
  const binding = requiredString(row, 'binding', 'R2 binding');
525
- return { binding, bucketName: requiredString(row, 'bucket_name', `R2 binding ${binding}`) };
522
+ return { ...row, binding, bucket_name: requiredString(row, 'bucket_name', `R2 binding ${binding}`) };
526
523
  });
527
524
  }
528
525
 
529
526
  function readD1Bindings(value: unknown): StageD1Binding[] {
530
527
  return readRows(value).map((row) => {
531
528
  const binding = requiredString(row, 'binding', 'D1 database');
532
- const database: StageD1Binding = {
529
+ return {
530
+ ...row,
533
531
  binding,
534
532
  database_name: requiredString(row, 'database_name', `D1 binding ${binding}`),
535
533
  database_id: requiredString(row, 'database_id', `D1 binding ${binding}`),
536
534
  };
537
- return typeof row.migrations_dir === 'string' ? { ...database, migrations_dir: row.migrations_dir } : database;
538
535
  });
539
536
  }
540
537
 
541
538
  function readServices(value: unknown): StageServiceBinding[] {
542
539
  return readRows(value).map((row) => {
543
540
  const binding = requiredString(row, 'binding', 'Service binding');
544
- const service: StageServiceBinding = {
545
- binding,
546
- service: requiredString(row, 'service', `Service binding ${binding}`),
547
- };
548
- return typeof row.environment === 'string' ? { ...service, environment: row.environment } : service;
541
+ return { ...row, binding, service: requiredString(row, 'service', `Service binding ${binding}`) };
549
542
  });
550
543
  }
551
544
 
552
545
  function readRateLimits(value: unknown): StageRateLimit[] {
553
546
  return readRows(value).map((row) => {
554
547
  const name = requiredString(row, 'name', 'Rate-limit binding');
555
- const limit: StageRateLimit = {
556
- name,
557
- namespace_id: requiredString(row, 'namespace_id', `Rate-limit binding ${name}`),
558
- };
559
- return row.simple === undefined ? limit : { ...limit, simple: row.simple };
548
+ return { ...row, name, namespace_id: requiredString(row, 'namespace_id', `Rate-limit binding ${name}`) };
560
549
  });
561
550
  }
562
551
 
563
- function readStageRoutes(value: unknown): StageRoute[] {
564
- return readRows(value).map((row) => ({
565
- pattern: requiredString(row, 'pattern', 'route'),
566
- ...(typeof row.zone_name === 'string' ? { zone_name: row.zone_name } : {}),
567
- ...(row.custom_domain === true ? { custom_domain: true } : {}),
568
- }));
569
- }
570
-
571
- function readRoutes(value: unknown): Array<{ pattern: string; zoneName?: string; customDomain: boolean }> {
572
- return readRows(value).map((row) => ({
573
- pattern: requiredString(row, 'pattern', 'route'),
574
- ...(typeof row.zone_name === 'string' ? { zoneName: row.zone_name } : {}),
575
- customDomain: row.custom_domain === true,
576
- }));
577
- }
578
-
579
552
  function readRows(value: unknown): Record<string, unknown>[] {
580
553
  return isUnknownRows(value) ? value : [];
581
554
  }
@@ -587,25 +560,3 @@ function requiredString(row: Record<string, unknown>, key: string, context: stri
587
560
  }
588
561
  return value;
589
562
  }
590
-
591
- function valueAtPath(root: unknown, path: Array<string | number>): unknown {
592
- let value = root;
593
- for (const segment of path) {
594
- if (typeof segment === 'number') {
595
- if (!Array.isArray(value)) return undefined;
596
- value = value[segment];
597
- } else {
598
- if (!isUnknownRecord(value)) return undefined;
599
- value = value[segment];
600
- }
601
- }
602
- return value;
603
- }
604
-
605
- function tomlLiteral(value: unknown): string {
606
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || Array.isArray(value)) {
607
- const literal = JSON.stringify(value);
608
- if (literal !== undefined) return literal;
609
- }
610
- throw new Error(`Cannot materialize Wrangler TOML value of type ${typeof value}.`);
611
- }