@smoothbricks/cli 0.11.18 → 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 (96) hide show
  1. package/README.md +91 -11
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +28 -4
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +6 -20
  6. package/dist/lib/deploy-tags.d.ts +17 -3
  7. package/dist/lib/deploy-tags.d.ts.map +1 -1
  8. package/dist/lib/deploy-tags.js +23 -4
  9. package/dist/lib/json.d.ts +22 -1
  10. package/dist/lib/json.d.ts.map +1 -1
  11. package/dist/lib/json.js +15 -3
  12. package/dist/monorepo/cargo-policy.d.ts +17 -0
  13. package/dist/monorepo/cargo-policy.d.ts.map +1 -1
  14. package/dist/monorepo/cargo-policy.js +73 -1
  15. package/dist/monorepo/index.d.ts.map +1 -1
  16. package/dist/monorepo/index.js +4 -2
  17. package/dist/monorepo/managed-files.d.ts +5 -2
  18. package/dist/monorepo/managed-files.d.ts.map +1 -1
  19. package/dist/monorepo/managed-files.js +35 -21
  20. package/dist/monorepo/packs/index.d.ts.map +1 -1
  21. package/dist/monorepo/packs/index.js +6 -1
  22. package/dist/nx/index.d.ts +6 -0
  23. package/dist/nx/index.d.ts.map +1 -1
  24. package/dist/nx/index.js +14 -0
  25. package/dist/secrets/commands.d.ts +9 -5
  26. package/dist/secrets/commands.d.ts.map +1 -1
  27. package/dist/secrets/commands.js +122 -49
  28. package/dist/secrets/index.d.ts +42 -56
  29. package/dist/secrets/index.d.ts.map +1 -1
  30. package/dist/secrets/index.js +52 -79
  31. package/dist/secrets/resolver.d.ts +59 -0
  32. package/dist/secrets/resolver.d.ts.map +1 -0
  33. package/dist/secrets/resolver.js +100 -0
  34. package/dist/secrets/run.d.ts +23 -0
  35. package/dist/secrets/run.d.ts.map +1 -0
  36. package/dist/secrets/run.js +130 -0
  37. package/dist/secrets/status.d.ts +177 -0
  38. package/dist/secrets/status.d.ts.map +1 -0
  39. package/dist/secrets/status.js +724 -0
  40. package/dist/wrangler/deploy-stage.d.ts +1 -1
  41. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  42. package/dist/wrangler/deploy-stage.js +22 -20
  43. package/dist/wrangler/deployed-version.d.ts.map +1 -1
  44. package/dist/wrangler/deployed-version.js +7 -12
  45. package/dist/wrangler/flat-config.d.ts +1 -4
  46. package/dist/wrangler/flat-config.d.ts.map +1 -1
  47. package/dist/wrangler/flat-config.js +98 -44
  48. package/dist/wrangler/prepare-env.d.ts +4 -3
  49. package/dist/wrangler/prepare-env.d.ts.map +1 -1
  50. package/dist/wrangler/prepare-env.js +7 -5
  51. package/dist/wrangler/source-config.d.ts +24 -0
  52. package/dist/wrangler/source-config.d.ts.map +1 -0
  53. package/dist/wrangler/source-config.js +110 -0
  54. package/dist/wrangler/stage.d.ts +55 -23
  55. package/dist/wrangler/stage.d.ts.map +1 -1
  56. package/dist/wrangler/stage.js +81 -162
  57. package/managed/raw/tooling/direnv/devenv.smoo.nix +19 -3
  58. package/managed/raw/tooling/direnv/secret-references.ts +280 -76
  59. package/managed/raw/tooling/direnv/setup-environment.ts +58 -2
  60. package/managed/raw/tsconfig.lib.json +28 -0
  61. package/package.json +7 -2
  62. package/src/cli.ts +34 -5
  63. package/src/github-ci/index.test.ts +6 -7
  64. package/src/github-ci/index.ts +11 -19
  65. package/src/lib/deploy-tags.ts +22 -4
  66. package/src/lib/json.ts +23 -1
  67. package/src/monorepo/cargo-policy.test.ts +91 -1
  68. package/src/monorepo/cargo-policy.ts +87 -1
  69. package/src/monorepo/index.ts +8 -2
  70. package/src/monorepo/managed-files.test.ts +69 -37
  71. package/src/monorepo/managed-files.ts +38 -21
  72. package/src/monorepo/packs/index.ts +10 -1
  73. package/src/monorepo/secret-references.test.ts +412 -6
  74. package/src/monorepo/setup-environment.test.ts +181 -0
  75. package/src/nx/index.test.ts +8 -0
  76. package/src/nx/index.ts +20 -0
  77. package/src/secrets/commands.test.ts +186 -4
  78. package/src/secrets/commands.ts +142 -51
  79. package/src/secrets/index.test.ts +4 -10
  80. package/src/secrets/index.ts +54 -115
  81. package/src/secrets/resolver.ts +130 -0
  82. package/src/secrets/run.test.ts +359 -0
  83. package/src/secrets/run.ts +148 -0
  84. package/src/secrets/status.test.ts +164 -0
  85. package/src/secrets/status.ts +297 -0
  86. package/src/wrangler/deploy-stage.test.ts +95 -2
  87. package/src/wrangler/deploy-stage.ts +26 -23
  88. package/src/wrangler/deployed-version.ts +7 -11
  89. package/src/wrangler/flat-config.test.ts +9 -4
  90. package/src/wrangler/flat-config.ts +1 -20
  91. package/src/wrangler/format-parity.test.ts +433 -0
  92. package/src/wrangler/prepare-env.ts +7 -5
  93. package/src/wrangler/source-config.test.ts +102 -0
  94. package/src/wrangler/source-config.ts +110 -0
  95. package/src/wrangler/stage.test.ts +61 -32
  96. package/src/wrangler/stage.ts +124 -173
@@ -0,0 +1,433 @@
1
+ // One worker, written twice: as `wrangler.toml` and as `wrangler.jsonc`. Everything a deploy
2
+ // derives from it — the parsed model, every stage plan, the derived pull-request document and
3
+ // every refusal — has to come out deep-equal, because past the parser there is one model and one
4
+ // implementation. A divergence here means a repo that renamed its config deploys something else.
5
+
6
+ import { describe, expect, it } from 'bun:test';
7
+ import { parseWranglerConfig } from './source-config.js';
8
+ import {
9
+ derivePullRequestDocument,
10
+ type LiveKvNamespace,
11
+ planConfiguredStageResources,
12
+ planPullRequestResources,
13
+ rateLimitNamespaceId,
14
+ type WranglerDocument,
15
+ } from './stage.js';
16
+
17
+ const TOML = `name = "acme-site"
18
+ main = "src/index.ts"
19
+ compatibility_date = "2026-05-06"
20
+ compatibility_flags = ["nodejs_compat"]
21
+
22
+ [observability]
23
+ enabled = true
24
+
25
+ [[migrations]]
26
+ tag = "v1"
27
+ new_sqlite_classes = ["QueueDO"]
28
+
29
+ [env.staging]
30
+ name = "acme-site-staging"
31
+ workers_dev = false
32
+
33
+ [env.staging.assets]
34
+ directory = "./dist"
35
+ not_found_handling = "single-page-application"
36
+
37
+ [[env.staging.routes]]
38
+ pattern = "*.staging.example.test/*"
39
+ zone_name = "example.test"
40
+
41
+ [[env.staging.routes]]
42
+ pattern = "staging.example.test"
43
+ custom_domain = true
44
+
45
+ [[env.staging.routes]]
46
+ pattern = "next.example.test"
47
+ custom_domain = true
48
+
49
+ [[env.staging.durable_objects.bindings]]
50
+ name = "QUEUE"
51
+ class_name = "QueueDO"
52
+
53
+ [[env.staging.kv_namespaces]]
54
+ binding = "SESSIONS"
55
+ id = "kv-sessions-staging"
56
+ preview_id = "kv-sessions-preview"
57
+
58
+ [[env.staging.r2_buckets]]
59
+ binding = "MEDIA"
60
+ bucket_name = "acme-media-staging"
61
+
62
+ [[env.staging.d1_databases]]
63
+ binding = "DB"
64
+ database_name = "acme-staging-db"
65
+ database_id = "d1-staging"
66
+ migrations_dir = "./migrations"
67
+
68
+ [[env.staging.services]]
69
+ binding = "BACKEND"
70
+ service = "acme-backend-staging"
71
+
72
+ [[env.staging.services]]
73
+ binding = "SHARED"
74
+ service = "auth-service"
75
+
76
+ [[env.staging.send_email]]
77
+ name = "EMAIL"
78
+ allowed_sender_addresses = ["login@mail.staging.example.test"]
79
+
80
+ [[env.staging.ratelimits]]
81
+ name = "SIGNUP"
82
+ namespace_id = "2026071701"
83
+ simple = { limit = 5, period = 60 }
84
+
85
+ [env.staging.vars]
86
+ ENVIRONMENT = "staging"
87
+ APP_ORIGIN = "https://app.staging.example.test"
88
+ BUILD_CHANNEL = "staging-weekly"
89
+
90
+ [env.production]
91
+ name = "acme-site"
92
+ workers_dev = false
93
+
94
+ [[env.production.routes]]
95
+ pattern = "example.test"
96
+ custom_domain = true
97
+
98
+ [[env.production.kv_namespaces]]
99
+ binding = "SESSIONS"
100
+ id = "kv-sessions-production"
101
+
102
+ [[env.production.r2_buckets]]
103
+ binding = "MEDIA"
104
+ bucket_name = "acme-media"
105
+
106
+ [env.production.vars]
107
+ ENVIRONMENT = "production"
108
+ `;
109
+
110
+ // The same worker in the format Cloudflare now recommends, with the comments and trailing commas
111
+ // that are the whole reason for the `.jsonc` spelling.
112
+ const JSONC = `{
113
+ // The worker every stage of this repo deploys.
114
+ "name": "acme-site",
115
+ "main": "src/index.ts",
116
+ "compatibility_date": "2026-05-06",
117
+ "compatibility_flags": ["nodejs_compat"],
118
+ "observability": { "enabled": true },
119
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["QueueDO"] }],
120
+ "env": {
121
+ "staging": {
122
+ "name": "acme-site-staging",
123
+ "workers_dev": false,
124
+ "assets": { "directory": "./dist", "not_found_handling": "single-page-application" },
125
+ "routes": [
126
+ { "pattern": "*.staging.example.test/*", "zone_name": "example.test" },
127
+ { "pattern": "staging.example.test", "custom_domain": true },
128
+ /* pinned: this one has no stage label and is dropped from a pull-request stage */
129
+ { "pattern": "next.example.test", "custom_domain": true },
130
+ ],
131
+ "durable_objects": { "bindings": [{ "name": "QUEUE", "class_name": "QueueDO" }] },
132
+ "kv_namespaces": [
133
+ { "binding": "SESSIONS", "id": "kv-sessions-staging", "preview_id": "kv-sessions-preview" },
134
+ ],
135
+ "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "acme-media-staging" }],
136
+ "d1_databases": [
137
+ {
138
+ "binding": "DB",
139
+ "database_name": "acme-staging-db",
140
+ "database_id": "d1-staging",
141
+ "migrations_dir": "./migrations",
142
+ },
143
+ ],
144
+ "services": [
145
+ { "binding": "BACKEND", "service": "acme-backend-staging" },
146
+ { "binding": "SHARED", "service": "auth-service" }, // unlabelled: stays shared
147
+ ],
148
+ "send_email": [{ "name": "EMAIL", "allowed_sender_addresses": ["login@mail.staging.example.test"] }],
149
+ "ratelimits": [{ "name": "SIGNUP", "namespace_id": "2026071701", "simple": { "limit": 5, "period": 60 } }],
150
+ "vars": {
151
+ "ENVIRONMENT": "staging",
152
+ "APP_ORIGIN": "https://app.staging.example.test",
153
+ "BUILD_CHANNEL": "staging-weekly",
154
+ },
155
+ },
156
+ "production": {
157
+ "name": "acme-site",
158
+ "workers_dev": false,
159
+ "routes": [{ "pattern": "example.test", "custom_domain": true }],
160
+ "kv_namespaces": [{ "binding": "SESSIONS", "id": "kv-sessions-production" }],
161
+ "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "acme-media" }],
162
+ "vars": { "ENVIRONMENT": "production" },
163
+ },
164
+ },
165
+ }
166
+ `;
167
+
168
+ const LIVE_NAMESPACES: LiveKvNamespace[] = [{ id: 'kv-sessions-staging', title: 'acme-sessions-staging' }];
169
+
170
+ const PR42 = {
171
+ stage: 'pr42',
172
+ accountId: 'account-1',
173
+ kvNamespaceIds: new Map([['kv-sessions-staging', 'kv-sessions-pr42']]),
174
+ d1DatabaseIds: new Map([['d1-staging', 'd1-pr42']]),
175
+ } as const;
176
+
177
+ const fromToml = parseWranglerConfig('wrangler.toml', TOML, 'toml');
178
+ const fromJsonc = parseWranglerConfig('wrangler.jsonc', JSONC, 'jsonc');
179
+
180
+ /**
181
+ * What `act` refused with, for each spelling. Returning the message rather than asserting inside
182
+ * keeps the two halves comparable: a refusal only reaches parity when both sides produce the same
183
+ * sentence, not merely when both throw something.
184
+ */
185
+ function refusals(
186
+ toml: string,
187
+ jsonc: string,
188
+ act: (document: WranglerDocument) => unknown,
189
+ ): { toml: string; jsonc: string } {
190
+ const message = (text: string, file: 'wrangler.toml' | 'wrangler.jsonc'): string => {
191
+ try {
192
+ act(parseWranglerConfig(file, text, file === 'wrangler.toml' ? 'toml' : 'jsonc'));
193
+ } catch (error) {
194
+ return error instanceof Error ? error.message : String(error);
195
+ }
196
+ return 'did not refuse';
197
+ };
198
+ return { toml: message(toml, 'wrangler.toml'), jsonc: message(jsonc, 'wrangler.jsonc') };
199
+ }
200
+
201
+ describe('a worker written as TOML and as JSONC', () => {
202
+ it('parses to one model, comments and trailing commas included', () => {
203
+ expect(fromJsonc).toEqual(fromToml);
204
+ });
205
+
206
+ it('plans staging identically', () => {
207
+ const plan = planConfiguredStageResources(fromToml, 'staging');
208
+ expect(planConfiguredStageResources(fromJsonc, 'staging')).toEqual(plan);
209
+ expect(plan).toEqual({
210
+ stage: 'staging',
211
+ workerName: 'acme-site-staging',
212
+ kvNamespaces: [{ binding: 'SESSIONS', id: 'kv-sessions-staging' }],
213
+ r2Buckets: [{ binding: 'MEDIA', bucketName: 'acme-media-staging' }],
214
+ routes: [
215
+ { pattern: '*.staging.example.test/*', zoneName: 'example.test', customDomain: false },
216
+ { pattern: 'staging.example.test', customDomain: true },
217
+ { pattern: 'next.example.test', customDomain: true },
218
+ ],
219
+ });
220
+ });
221
+
222
+ it('plans production identically', () => {
223
+ const plan = planConfiguredStageResources(fromToml, 'production');
224
+ expect(planConfiguredStageResources(fromJsonc, 'production')).toEqual(plan);
225
+ expect(plan).toEqual({
226
+ stage: 'production',
227
+ workerName: 'acme-site',
228
+ kvNamespaces: [{ binding: 'SESSIONS', id: 'kv-sessions-production' }],
229
+ r2Buckets: [{ binding: 'MEDIA', bucketName: 'acme-media' }],
230
+ routes: [{ pattern: 'example.test', customDomain: true }],
231
+ });
232
+ });
233
+
234
+ it('plans a pull-request stage identically, down to the resources it would create', () => {
235
+ const plan = planPullRequestResources(fromToml, 'pr42', LIVE_NAMESPACES);
236
+ expect(planPullRequestResources(fromJsonc, 'pr42', LIVE_NAMESPACES)).toEqual(plan);
237
+ expect(plan).toEqual({
238
+ stage: 'pr42',
239
+ workerName: 'acme-site-pr42',
240
+ workerBaseName: 'acme-site',
241
+ kvNamespaces: [
242
+ {
243
+ binding: 'SESSIONS',
244
+ stagingId: 'kv-sessions-staging',
245
+ stagingTitle: 'acme-sessions-staging',
246
+ title: 'acme-sessions-pr42',
247
+ },
248
+ ],
249
+ d1Databases: [{ binding: 'DB', stagingId: 'd1-staging', name: 'acme-pr42-db' }],
250
+ r2Buckets: [{ binding: 'MEDIA', bucketName: 'acme-media-pr42' }],
251
+ // `staging.example.test` carries the label too, so the stage gets its own custom domain;
252
+ // only the pinned `next.example.test` is dropped.
253
+ routes: [
254
+ { pattern: '*.pr42.example.test/*', zoneName: 'example.test', customDomain: false },
255
+ { pattern: 'pr42.example.test', customDomain: true },
256
+ ],
257
+ });
258
+ });
259
+
260
+ it('derives the same pull-request document, carrying keys it does not model', () => {
261
+ const derived = derivePullRequestDocument(fromToml, PR42);
262
+ expect(derivePullRequestDocument(fromJsonc, PR42)).toEqual(derived);
263
+ expect(derived.env?.pr42).toEqual({
264
+ name: 'acme-site-pr42',
265
+ workers_dev: false,
266
+ assets: { directory: './dist', not_found_handling: 'single-page-application' },
267
+ routes: [
268
+ { pattern: '*.pr42.example.test/*', zone_name: 'example.test' },
269
+ { pattern: 'pr42.example.test', custom_domain: true },
270
+ ],
271
+ durable_objects: { bindings: [{ name: 'QUEUE', class_name: 'QueueDO' }] },
272
+ // `preview_id` is not a field this derivation models, and it survives anyway.
273
+ kv_namespaces: [{ binding: 'SESSIONS', id: 'kv-sessions-pr42', preview_id: 'kv-sessions-preview' }],
274
+ r2_buckets: [{ binding: 'MEDIA', bucket_name: 'acme-media-pr42' }],
275
+ d1_databases: [
276
+ {
277
+ binding: 'DB',
278
+ database_name: 'acme-pr42-db',
279
+ database_id: 'd1-pr42',
280
+ migrations_dir: './migrations',
281
+ },
282
+ ],
283
+ services: [
284
+ { binding: 'BACKEND', service: 'acme-backend-pr42' },
285
+ { binding: 'SHARED', service: 'auth-service' },
286
+ ],
287
+ send_email: [{ name: 'EMAIL', allowed_sender_addresses: ['login@mail.pr42.example.test'] }],
288
+ ratelimits: [
289
+ {
290
+ name: 'SIGNUP',
291
+ namespace_id: rateLimitNamespaceId('account-1', 'acme-site', 'pr42', 'SIGNUP'),
292
+ simple: { limit: 5, period: 60 },
293
+ },
294
+ ],
295
+ vars: {
296
+ ENVIRONMENT: 'pr42',
297
+ APP_ORIGIN: 'https://app.pr42.example.test',
298
+ BUILD_CHANNEL: 'staging-weekly',
299
+ },
300
+ });
301
+ // Top-level declarations and the block the stage was derived from are left exactly as read.
302
+ expect(derived.migrations).toEqual([{ tag: 'v1', new_sqlite_classes: ['QueueDO'] }]);
303
+ expect(derived.observability).toEqual({ enabled: true });
304
+ expect(derived.env?.staging).toEqual(fromToml.env?.staging);
305
+ expect(derived.env?.production).toEqual(fromToml.env?.production);
306
+ });
307
+
308
+ it('leaves the parsed source document untouched by a derivation', () => {
309
+ const before = structuredClone(fromToml);
310
+ derivePullRequestDocument(fromToml, PR42);
311
+ expect(fromToml).toEqual(before);
312
+ });
313
+ });
314
+
315
+ describe('a refusal reads the same whichever format declared it', () => {
316
+ it('refuses a template whose every route is pinned', () => {
317
+ const { toml, jsonc } = refusals(
318
+ `[env.staging]
319
+ name = "acme-site-staging"
320
+ [[env.staging.routes]]
321
+ pattern = "next.example.test"
322
+ custom_domain = true
323
+ `,
324
+ `{
325
+ // no stage-derivable route anywhere
326
+ "env": {
327
+ "staging": {
328
+ "name": "acme-site-staging",
329
+ "routes": [{ "pattern": "next.example.test", "custom_domain": true }],
330
+ },
331
+ },
332
+ }`,
333
+ (document) => planPullRequestResources(document, 'pr42', []),
334
+ );
335
+ expect(jsonc).toBe(toml);
336
+ expect(toml).toBe(
337
+ 'Every route of acme-site-staging is pinned (no staging label): next.example.test. A pull-request stage would deploy unrouted and Wrangler would expose it on workers.dev; add a stage-derivable route such as site.staging.<zone>/*.',
338
+ );
339
+ });
340
+
341
+ it('refuses an R2 bucket the pull-request stage would share with staging', () => {
342
+ const { toml, jsonc } = refusals(
343
+ `[env.staging]
344
+ name = "acme-site-staging"
345
+ [[env.staging.routes]]
346
+ pattern = "site.staging.example.test/*"
347
+ [[env.staging.r2_buckets]]
348
+ binding = "MEDIA"
349
+ bucket_name = "acme-shared-media"
350
+ `,
351
+ `{
352
+ "env": {
353
+ "staging": {
354
+ "name": "acme-site-staging",
355
+ "routes": [{ "pattern": "site.staging.example.test/*" }],
356
+ "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "acme-shared-media" }], // no staging segment
357
+ },
358
+ },
359
+ }`,
360
+ (document) => planPullRequestResources(document, 'pr42', []),
361
+ );
362
+ expect(jsonc).toBe(toml);
363
+ expect(toml).toBe('Staging R2 bucket acme-shared-media has no exact staging segment.');
364
+ });
365
+
366
+ it('refuses a D1 database the pull-request stage would share with staging', () => {
367
+ const { toml, jsonc } = refusals(
368
+ `[env.staging]
369
+ name = "acme-site-staging"
370
+ [[env.staging.routes]]
371
+ pattern = "site.staging.example.test/*"
372
+ [[env.staging.d1_databases]]
373
+ binding = "DB"
374
+ database_name = "acme-shared-db"
375
+ database_id = "d1-shared"
376
+ `,
377
+ `{
378
+ "env": {
379
+ "staging": {
380
+ "name": "acme-site-staging",
381
+ "routes": [{ "pattern": "site.staging.example.test/*" }],
382
+ "d1_databases": [{ "binding": "DB", "database_name": "acme-shared-db", "database_id": "d1-shared" }],
383
+ },
384
+ },
385
+ }`,
386
+ (document) => planPullRequestResources(document, 'pr42', []),
387
+ );
388
+ expect(jsonc).toBe(toml);
389
+ expect(toml).toBe('Staging D1 database acme-shared-db has no exact staging segment.');
390
+ });
391
+
392
+ it('refuses a stage the configuration does not declare', () => {
393
+ const { toml, jsonc } = refusals(
394
+ `name = "acme-site"
395
+ [env.staging]
396
+ name = "acme-site-staging"
397
+ `,
398
+ `{
399
+ "name": "acme-site",
400
+ "env": { "staging": { "name": "acme-site-staging" } },
401
+ }`,
402
+ (document) => planConfiguredStageResources(document, 'production'),
403
+ );
404
+ expect(jsonc).toBe(toml);
405
+ expect(toml).toBe('Wrangler configuration must declare [env.production].');
406
+ });
407
+
408
+ it('refuses a pull-request stage the repo committed itself, rather than replacing it', () => {
409
+ const { toml, jsonc } = refusals(
410
+ `[env.staging]
411
+ name = "acme-site-staging"
412
+ [[env.staging.routes]]
413
+ pattern = "site.staging.example.test/*"
414
+ [env.pr42]
415
+ name = "hand-written"
416
+ `,
417
+ `{
418
+ "env": {
419
+ "staging": {
420
+ "name": "acme-site-staging",
421
+ "routes": [{ "pattern": "site.staging.example.test/*" }],
422
+ },
423
+ "pr42": { "name": "hand-written" },
424
+ },
425
+ }`,
426
+ (document) => derivePullRequestDocument(document, PR42),
427
+ );
428
+ expect(jsonc).toBe(toml);
429
+ expect(toml).toBe(
430
+ 'Wrangler configuration already declares [env.pr42]; a pull-request stage is derived from [env.staging] and must not be committed.',
431
+ );
432
+ });
433
+ });
@@ -22,6 +22,7 @@ import { $ } from 'bun';
22
22
  import { type AST, getStaticTOMLValue, parseTOML } from 'toml-eslint-parser';
23
23
  import typia from 'typia';
24
24
  import { decode, printCommandOutput, runText } from '../lib/run.js';
25
+ import { readWranglerSourceConfig } from './source-config.js';
25
26
 
26
27
  // ── runtime narrowing (wrangler TOML/JSON is external data — no casts) ───────
27
28
 
@@ -280,13 +281,14 @@ export function parseDevVarsExample(text: string): string[] {
280
281
  }
281
282
 
282
283
  /**
283
- * Read the vars/secrets split for `env` from `<root>/wrangler.toml` (public var
284
- * keys) + `<root>/.dev.vars.example` (secret names). This is the "what to prompt
285
- * for" manifest a prepare-env script derives its work from.
284
+ * Read the vars/secrets split for `env` from the project's own Wrangler configuration (public
285
+ * var keys) + `<root>/.dev.vars.example` (secret names). This is the "what to prompt for"
286
+ * manifest a prepare-env script derives its work from. The config may be JSONC, JSON or TOML —
287
+ * reading it is format-blind; the editors below are the TOML half of the flow.
286
288
  */
287
289
  export function readManifest(root: string, env: string): Manifest {
288
- const toml = readFileSync(join(root, 'wrangler.toml'), 'utf8');
289
- const block = envRecord(toml, env);
290
+ const declared: unknown = readWranglerSourceConfig(root).document.env?.[env];
291
+ const block = isWranglerEnvBlock(declared) ? declared : null;
290
292
  const vars = Object.keys(block?.vars ?? {});
291
293
  const kvBindings = (block?.kv_namespaces ?? []).flatMap((row) =>
292
294
  typeof row.binding === 'string' ? [row.binding] : [],
@@ -0,0 +1,102 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ discoverWranglerSourceConfig,
7
+ parseWranglerConfig,
8
+ readWranglerSourceConfig,
9
+ WRANGLER_SOURCE_CONFIG_FILES,
10
+ } from './source-config.js';
11
+
12
+ const roots: string[] = [];
13
+
14
+ afterEach(async () => {
15
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
16
+ });
17
+
18
+ /** A project directory holding exactly the named config files. */
19
+ async function project(files: Record<string, string>): Promise<string> {
20
+ const root = await mkdtemp(join(tmpdir(), 'smoo-wrangler-source-'));
21
+ roots.push(root);
22
+ for (const [name, content] of Object.entries(files)) {
23
+ await writeFile(join(root, name), content);
24
+ }
25
+ return root;
26
+ }
27
+
28
+ describe('discovering the config wrangler itself would read', () => {
29
+ it('follows wrangler precedence for each spelling on its own', async () => {
30
+ for (const file of WRANGLER_SOURCE_CONFIG_FILES) {
31
+ const root = await project({ [file]: file.endsWith('.toml') ? 'name = "acme"\n' : '{ "name": "acme" }' });
32
+ expect(discoverWranglerSourceConfig(root).path).toBe(join(root, file));
33
+ }
34
+ });
35
+
36
+ it('refuses two configs, naming every one and the one wrangler would have silently taken', async () => {
37
+ const root = await project({
38
+ 'wrangler.jsonc': '{ "name": "acme-from-jsonc" }',
39
+ 'wrangler.toml': 'name = "acme-from-toml"\n',
40
+ });
41
+
42
+ // Verified against wrangler 4.131.0: with both present it reads the .jsonc and says nothing.
43
+ expect(() => discoverWranglerSourceConfig(root)).toThrow(
44
+ `${root} declares more than one Wrangler configuration: wrangler.jsonc, wrangler.toml. Wrangler would read wrangler.jsonc and ignore the rest without saying so; keep exactly one.`,
45
+ );
46
+ });
47
+
48
+ it('refuses a project with no config, naming the spellings it looked for', async () => {
49
+ const root = await project({});
50
+
51
+ expect(() => discoverWranglerSourceConfig(root)).toThrow(
52
+ `${root} declares no Wrangler configuration (wrangler.jsonc, wrangler.json, wrangler.toml); pass --config for a build-generated flat configuration.`,
53
+ );
54
+ });
55
+
56
+ it('reads and parses the discovered file in one step', async () => {
57
+ const root = await project({ 'wrangler.jsonc': '{\n // the worker\n "name": "acme",\n}\n' });
58
+
59
+ expect(readWranglerSourceConfig(root)).toEqual({ path: join(root, 'wrangler.jsonc'), document: { name: 'acme' } });
60
+ });
61
+ });
62
+
63
+ describe('parsing a source config', () => {
64
+ it('tolerates comments and trailing commas in wrangler.json too, exactly as wrangler does', () => {
65
+ const document = parseWranglerConfig(
66
+ 'wrangler.json',
67
+ '{\n /* recommended spelling is .jsonc, but wrangler parses both the same way */\n "name": "acme",\n "compatibility_flags": ["nodejs_compat"],\n}\n',
68
+ 'jsonc',
69
+ );
70
+
71
+ expect(document).toEqual({ name: 'acme', compatibility_flags: ['nodejs_compat'] });
72
+ });
73
+
74
+ it('names the file when the text is not the format it is spelled as', () => {
75
+ expect(() => parseWranglerConfig('wrangler.jsonc', '{ "name": ', 'jsonc')).toThrow(
76
+ /^wrangler\.jsonc is not valid JSONC: /,
77
+ );
78
+ expect(() => parseWranglerConfig('wrangler.toml', '[env.\nbad', 'toml')).toThrow(
79
+ /^wrangler\.toml is not valid TOML: /,
80
+ );
81
+ });
82
+
83
+ it('refuses anything that is not a configuration object', () => {
84
+ expect(() => parseWranglerConfig('wrangler.jsonc', '[1, 2]', 'jsonc')).toThrow(
85
+ 'wrangler.jsonc is not a Wrangler configuration document: expected an object, optionally with env blocks.',
86
+ );
87
+ });
88
+
89
+ it('refuses a bare TOML date rather than shifting it through JSON', () => {
90
+ // The temporary per-stage config is JSON whatever the source was, and JSON.stringify would
91
+ // turn this into an ISO timestamp in the deploying machine's timezone.
92
+ expect(() => parseWranglerConfig('wrangler.toml', 'compatibility_date = 2026-05-06\n', 'toml')).toThrow(
93
+ 'wrangler.toml declares a bare TOML date or time at compatibility_date, which JSON cannot carry; quote it as a string.',
94
+ );
95
+ });
96
+
97
+ it('refuses a non-finite TOML number rather than writing null for it', () => {
98
+ expect(() => parseWranglerConfig('wrangler.toml', '[env.staging.vars]\nBUDGET = inf\n', 'toml')).toThrow(
99
+ 'wrangler.toml declares Infinity at env.staging.vars.BUDGET, which JSON cannot carry; write a finite number.',
100
+ );
101
+ });
102
+ });
@@ -0,0 +1,110 @@
1
+ // Where a project's Wrangler configuration lives, and how its file formats become one model.
2
+ //
3
+ // Wrangler reads whichever of `wrangler.jsonc`, `wrangler.json` and `wrangler.toml` it finds
4
+ // first and says nothing about the rest: with a `.jsonc` and a `.toml` side by side, wrangler
5
+ // 4.131.0 deploys the `.jsonc` silently, whichever of the two the repo is actually editing.
6
+ // Discovery here follows the same precedence but refuses the pick, naming every file it found.
7
+ //
8
+ // Both JSON spellings go through the JSONC parser because wrangler does — comments and trailing
9
+ // commas parse in `wrangler.json` too (verified against wrangler 4.131.0) — so the format only
10
+ // ever decides which parser runs. Past this module there is one model and no config text.
11
+
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { getStaticTOMLValue, parseTOML } from 'toml-eslint-parser';
15
+ import typia from 'typia';
16
+ import type { WranglerDocument } from './stage.js';
17
+
18
+ /** Which parser a source config needs; `wrangler.json` and `wrangler.jsonc` share one. */
19
+ export type WranglerConfigFormat = 'jsonc' | 'toml';
20
+
21
+ /** Wrangler's own precedence: the first of these that exists is the one it reads. */
22
+ export const WRANGLER_SOURCE_CONFIG_FILES = ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml'] as const;
23
+
24
+ export interface WranglerSourceConfig {
25
+ /** The file wrangler itself would read, ready to hand back to it as `--config`. */
26
+ path: string;
27
+ document: WranglerDocument;
28
+ }
29
+
30
+ const isWranglerDocument = typia.createIs<WranglerDocument>();
31
+
32
+ /**
33
+ * The one source config this project declares. Two configs is a refusal, not a precedence
34
+ * exercise: the repo would deploy whichever file wrangler prefers, which is not necessarily the
35
+ * one anybody is editing.
36
+ */
37
+ export function discoverWranglerSourceConfig(cwd: string): { path: string; format: WranglerConfigFormat } {
38
+ const found = WRANGLER_SOURCE_CONFIG_FILES.filter((file) => existsSync(join(cwd, file)));
39
+ const [first] = found;
40
+ if (first === undefined) {
41
+ throw new Error(
42
+ `${cwd} declares no Wrangler configuration (${WRANGLER_SOURCE_CONFIG_FILES.join(', ')}); pass --config for a build-generated flat configuration.`,
43
+ );
44
+ }
45
+ if (found.length > 1) {
46
+ throw new Error(
47
+ `${cwd} declares more than one Wrangler configuration: ${found.join(', ')}. Wrangler would read ${first} and ignore the rest without saying so; keep exactly one.`,
48
+ );
49
+ }
50
+ return { path: join(cwd, first), format: first === 'wrangler.toml' ? 'toml' : 'jsonc' };
51
+ }
52
+
53
+ /** The discovered source config, parsed into the model every stage derivation reads. */
54
+ export function readWranglerSourceConfig(cwd: string): WranglerSourceConfig {
55
+ const { path, format } = discoverWranglerSourceConfig(cwd);
56
+ return { path, document: parseWranglerConfig(path, readFileSync(path, 'utf8'), format) };
57
+ }
58
+
59
+ /** One document out of either format. The text is never handed on: everything downstream reads data. */
60
+ export function parseWranglerConfig(path: string, text: string, format: WranglerConfigFormat): WranglerDocument {
61
+ const value = parseConfigText(path, text, format);
62
+ // TOML carries values JSON does not, and the temporary per-stage config is JSON whatever the
63
+ // source format was. Name them here rather than let JSON.stringify shift a date by its timezone
64
+ // or write `null` for an infinity halfway through a deploy.
65
+ if (format === 'toml') assertJsonRepresentable(value, '', path);
66
+ if (!isWranglerDocument(value)) {
67
+ throw new Error(
68
+ `${path} is not a Wrangler configuration document: expected an object, optionally with env blocks.`,
69
+ );
70
+ }
71
+ return value;
72
+ }
73
+
74
+ function parseConfigText(path: string, text: string, format: WranglerConfigFormat): unknown {
75
+ try {
76
+ return format === 'toml' ? getStaticTOMLValue(parseTOML(text)) : Bun.JSONC.parse(text);
77
+ } catch (error) {
78
+ const detail = error instanceof Error ? error.message : String(error);
79
+ throw new Error(`${path} is not valid ${format === 'toml' ? 'TOML' : 'JSONC'}: ${detail}`);
80
+ }
81
+ }
82
+
83
+ function assertJsonRepresentable(value: unknown, path: string, file: string): void {
84
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
85
+ if (typeof value === 'number') {
86
+ if (Number.isFinite(value)) return;
87
+ throw new Error(`${file} declares ${value} at ${path}, which JSON cannot carry; write a finite number.`);
88
+ }
89
+ if (Array.isArray(value)) {
90
+ for (const [index, item] of value.entries()) {
91
+ assertJsonRepresentable(item, `${path}[${index}]`, file);
92
+ }
93
+ return;
94
+ }
95
+ if (isPlainObject(value)) {
96
+ for (const [key, item] of Object.entries(value)) {
97
+ assertJsonRepresentable(item, path ? `${path}.${key}` : key, file);
98
+ }
99
+ return;
100
+ }
101
+ throw new Error(
102
+ `${file} declares a bare TOML ${value instanceof Date ? 'date or time' : typeof value} at ${path}, which JSON cannot carry; quote it as a string.`,
103
+ );
104
+ }
105
+
106
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
107
+ if (typeof value !== 'object' || value === null) return false;
108
+ const prototype: unknown = Object.getPrototypeOf(value);
109
+ return prototype === Object.prototype || prototype === null;
110
+ }