@smoothbricks/cli 0.11.19 → 0.11.21

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 (84) 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 -2
  14. package/dist/nx/index.d.ts +6 -1
  15. package/dist/nx/index.d.ts.map +1 -1
  16. package/dist/nx/index.js +14 -1
  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/package-policy.test.ts +50 -19
  60. package/src/monorepo/packs/index.ts +10 -2
  61. package/src/monorepo/secret-references.test.ts +412 -6
  62. package/src/monorepo/setup-environment.test.ts +181 -0
  63. package/src/nx/index.test.ts +8 -2
  64. package/src/nx/index.ts +20 -2
  65. package/src/secrets/commands.test.ts +186 -4
  66. package/src/secrets/commands.ts +142 -51
  67. package/src/secrets/index.test.ts +4 -10
  68. package/src/secrets/index.ts +54 -115
  69. package/src/secrets/resolver.ts +130 -0
  70. package/src/secrets/run.test.ts +359 -0
  71. package/src/secrets/run.ts +148 -0
  72. package/src/secrets/status.test.ts +164 -0
  73. package/src/secrets/status.ts +297 -0
  74. package/src/wrangler/deploy-stage.test.ts +95 -2
  75. package/src/wrangler/deploy-stage.ts +26 -23
  76. package/src/wrangler/deployed-version.ts +7 -11
  77. package/src/wrangler/flat-config.test.ts +9 -4
  78. package/src/wrangler/flat-config.ts +1 -20
  79. package/src/wrangler/format-parity.test.ts +433 -0
  80. package/src/wrangler/prepare-env.ts +7 -5
  81. package/src/wrangler/source-config.test.ts +102 -0
  82. package/src/wrangler/source-config.ts +110 -0
  83. package/src/wrangler/stage.test.ts +61 -32
  84. package/src/wrangler/stage.ts +124 -173
@@ -0,0 +1,297 @@
1
+ /**
2
+ * The value model behind `smoo secrets`: one row per secret name, reconciled
3
+ * from every source that has an opinion about it, plus the machine-readable
4
+ * document `smoo secrets status --json` emits.
5
+ *
6
+ * Everything here is pure and depends on nothing but typia, which is what lets
7
+ * `@smoothbricks/cli/secrets` be imported for the types alone — a consumer
8
+ * driving a UI or a repository's own operations CLI gets the document type and
9
+ * a validator without pulling in `gh`, the filesystem readers, or commander.
10
+ * The readers that produce these facts live in ./index.ts; the operator
11
+ * commands that print them live in ./commands.ts.
12
+ */
13
+
14
+ import typia from 'typia';
15
+ import type { SecretStageMap, SecretStageScope } from '../wrangler/stage-secrets.js';
16
+
17
+ export type { SecretStageScope };
18
+
19
+ /** Where an env name is described, which repository secret carries it, and whether that exists. */
20
+ export interface SecretRow {
21
+ name: string;
22
+ /** The repository secret this env name reads from, by convention or declaration. */
23
+ repositorySecret: string;
24
+ /** Wrangler projects whose `.dev.vars.example` declares it. */
25
+ declaredByWorkers: string[];
26
+ /** True when a managed workflow renders `secrets.<name>` into a job. */
27
+ suppliedByWorkflow: boolean;
28
+ /** True when `smoo.secrets` can fetch it for a developer shell; equivalently, `localGroup` is set. */
29
+ fetchableLocally: boolean;
30
+ /**
31
+ * How `smoo.secrets` resolves it locally: the group that resolves it, and
32
+ * the group this repository's own declarations derive. They differ exactly
33
+ * when the entry overrides the derivation, which is the one thing a reader
34
+ * must never have to infer. Absent when nothing fetches the name locally.
35
+ */
36
+ localGroup?: { resolves: string; derived: string };
37
+ /** True when the repository holds a secret of this name. */
38
+ onRepository: boolean;
39
+ /**
40
+ * GitHub Environments holding their own value for this name. A job bound to
41
+ * an environment reads that value in preference to the repository's, which
42
+ * is how one name carries test credentials on a preview stage and live ones
43
+ * in production.
44
+ */
45
+ heldByEnvironment: string[];
46
+ }
47
+
48
+ /**
49
+ * One `smoo.secrets` entry as the reconciliation needs it: the name, the
50
+ * group that resolves it, and the group this repository's declarations
51
+ * derive. ../secrets/resolver.ts produces these from the same file shell
52
+ * entry routes with.
53
+ */
54
+ export interface LocalSecret {
55
+ name: string;
56
+ group: string;
57
+ derivedGroup: string;
58
+ }
59
+
60
+ export interface SecretSources {
61
+ /** Worker label -> env names it declares. */
62
+ workerSecrets: Record<string, readonly string[]>;
63
+ /** Env names a managed workflow passes into a job. */
64
+ workflowSecrets: readonly string[];
65
+ /** Env name -> repository secret, by convention with declared exceptions. */
66
+ secretNames: Readonly<Record<string, string>>;
67
+ /** Every `smoo.secrets` entry with the group that resolves it. */
68
+ localSecrets: readonly LocalSecret[];
69
+ /** Repository secret names GitHub currently holds. */
70
+ repositorySecrets: readonly string[];
71
+ /** Environment name -> secret names that environment holds. */
72
+ environmentSecrets?: Readonly<Record<string, readonly string[]>>;
73
+ }
74
+
75
+ /**
76
+ * One row per name known to any source, sorted, so a reader sees the whole
77
+ * picture rather than one source's view of it.
78
+ */
79
+ export function reconcileSecrets(sources: SecretSources): SecretRow[] {
80
+ const declaredByAnyWorker: string[] = Object.values(sources.workerSecrets).flatMap((names) => [...names]);
81
+ const localSecrets = new Map(sources.localSecrets.map((secret) => [secret.name, secret]));
82
+ const names = new Set<string>([...declaredByAnyWorker, ...sources.workflowSecrets, ...localSecrets.keys()]);
83
+ // A repository secret that already carries a known env name is that name's
84
+ // row, not a row of its own: listing ACME_GITHUB_CLIENT_SECRET beside
85
+ // GITHUB_CLIENT_SECRET would report one value as two secrets, one of them
86
+ // permanently "declared by nothing".
87
+ const carriesKnownEnvName = new Set(
88
+ names.size > 0 ? [...names].map((name) => sources.secretNames[name] ?? name) : [],
89
+ );
90
+ // A value only an environment holds is still a value: leaving it out of the
91
+ // rows is how an operator ends up hunting for a secret that is already set.
92
+ const addUndeclared = (secret: string): void => {
93
+ if (!carriesKnownEnvName.has(secret)) names.add(secret);
94
+ };
95
+ for (const secret of sources.repositorySecrets) addUndeclared(secret);
96
+ for (const held of Object.values(sources.environmentSecrets ?? {})) {
97
+ for (const secret of held) addUndeclared(secret);
98
+ }
99
+ return [...names]
100
+ .sort((left, right) => left.localeCompare(right))
101
+ .map((name) => {
102
+ const local = localSecrets.get(name);
103
+ return {
104
+ name,
105
+ repositorySecret: sources.secretNames[name] ?? name,
106
+ declaredByWorkers: Object.entries(sources.workerSecrets)
107
+ .filter(([, declared]) => declared.includes(name))
108
+ .map(([label]) => label)
109
+ .sort((left, right) => left.localeCompare(right)),
110
+ suppliedByWorkflow: sources.workflowSecrets.includes(name),
111
+ fetchableLocally: local !== undefined,
112
+ ...(local === undefined ? {} : { localGroup: { resolves: local.group, derived: local.derivedGroup } }),
113
+ onRepository: sources.repositorySecrets.includes(sources.secretNames[name] ?? name),
114
+ heldByEnvironment: Object.entries(sources.environmentSecrets ?? {})
115
+ .filter(([, held]) => held.includes(sources.secretNames[name] ?? name))
116
+ .map(([environment]) => environment)
117
+ .sort((left, right) => left.localeCompare(right)),
118
+ };
119
+ });
120
+ }
121
+
122
+ /**
123
+ * The rows a CI deploy cannot satisfy: a Worker declares the name, a workflow
124
+ * promises to pass it, and the repository has no value to pass. Reported
125
+ * separately from "declared but not wired into any workflow", because the
126
+ * remedies differ — set a secret, versus declare it in `smoo.github`.
127
+ */
128
+ export function unsatisfiedSecrets(rows: readonly SecretRow[], boundEnvironments: readonly string[] = []): SecretRow[] {
129
+ return rows.filter((row) => {
130
+ if (!row.suppliedByWorkflow) return false;
131
+ if (row.onRepository) return false;
132
+ // With no environment bound, the repository is the only scope a job reads.
133
+ // With environments bound, each one can carry the value instead - so the
134
+ // name is satisfied only when every bound environment holds it.
135
+ if (boundEnvironments.length === 0) return true;
136
+ return !boundEnvironments.every((environment) => row.heldByEnvironment.includes(environment));
137
+ });
138
+ }
139
+
140
+ /** Bound environments that lack their own value and cannot fall back to the repository. */
141
+ export function environmentsMissing(row: SecretRow, boundEnvironments: readonly string[]): string[] {
142
+ if (row.onRepository) return [];
143
+ return boundEnvironments.filter((environment) => !row.heldByEnvironment.includes(environment));
144
+ }
145
+
146
+ /** Worker-declared names no managed workflow passes: a CI deploy will run without them. */
147
+ export function unwiredSecrets(rows: readonly SecretRow[]): SecretRow[] {
148
+ return rows.filter((row) => row.declaredByWorkers.length > 0 && !row.suppliedByWorkflow);
149
+ }
150
+
151
+ /**
152
+ * Every stage a scope can name, in the order the document lists them. A
153
+ * declared secret absent from `smoo.wrangler.secretStages` belongs to all of
154
+ * them, which is the rule this constant spells out exactly once.
155
+ */
156
+ const EVERY_STAGE: readonly SecretStageScope[] = ['preview', 'production', 'staging'];
157
+
158
+ /** The repository the status was read from, and how that repository was chosen. */
159
+ export interface SecretsStatusRepository {
160
+ /** `owner/name`, the form `gh --repo` takes. */
161
+ repo: string;
162
+ /** Why this repository — the checkout fact that decided it, verbatim from the resolver. */
163
+ source: string;
164
+ /** How many secrets it holds at repository scope. */
165
+ secretCount: number;
166
+ }
167
+
168
+ /**
169
+ * One GitHub Environment the status looked at. Discriminated on `readable`
170
+ * because an environment `gh` refused to list is not an empty one: reporting
171
+ * it as empty would call values missing that may well be set, and the reason
172
+ * is the only thing that lets an operator tell the two apart.
173
+ */
174
+ export type SecretsStatusEnvironment =
175
+ | {
176
+ name: string;
177
+ /** True when a managed workflow binds a job to it, so a job reads it before the repository. */
178
+ bound: boolean;
179
+ readable: true;
180
+ secretCount: number;
181
+ }
182
+ | {
183
+ name: string;
184
+ bound: boolean;
185
+ readable: false;
186
+ /** What `gh` said, so nothing below claims what this environment holds. */
187
+ reason: string;
188
+ };
189
+
190
+ /** A reconciled row, plus which deployment stages require the name to have a value. */
191
+ export interface SecretsStatusRow extends SecretRow {
192
+ /**
193
+ * Stages that refuse to deploy without a value, from `smoo.wrangler.secretStages`
194
+ * unioned over every wrangler project declaring the name: absent from the map
195
+ * means every stage, mapped to `[]` means none. A name no wrangler project
196
+ * declares is `[]` too — `declaredByWorkers` tells those two apart.
197
+ */
198
+ requiredByStages: SecretStageScope[];
199
+ }
200
+
201
+ /** A name a workflow passes that no scope the job reads can supply: this is what fails a deploy. */
202
+ export interface UnsatisfiedSecret {
203
+ name: string;
204
+ repositorySecret: string;
205
+ /**
206
+ * Bound environments holding no value for it. Empty means no environment is
207
+ * bound and the repository itself is the scope to set it in.
208
+ */
209
+ missingIn: string[];
210
+ }
211
+
212
+ /**
213
+ * Everything `smoo secrets status` learned, in one document.
214
+ *
215
+ * `unsatisfied` is the exit code's reason and not a convenience: the rule that
216
+ * decides whether a value exists in a scope a bound job reads is smoo's, and a
217
+ * consumer re-deriving it from `secrets` would be maintaining a second copy of
218
+ * it.
219
+ */
220
+ export interface SecretsStatusDocument {
221
+ /** Document shape, so a consumer refuses a document it does not understand instead of misreading it. */
222
+ version: 1;
223
+ repository: SecretsStatusRepository;
224
+ /** Every environment looked at: those the workflows bind, then any asked for with `--env`. */
225
+ environments: SecretsStatusEnvironment[];
226
+ /** One row per name any source knows, sorted by name. */
227
+ secrets: SecretsStatusRow[];
228
+ /** Empty exactly when the command exits 0. */
229
+ unsatisfied: UnsatisfiedSecret[];
230
+ }
231
+
232
+ /** The facts a document is projected from, all gathered before anything is printed. */
233
+ export interface SecretsStatusFacts {
234
+ repository: SecretsStatusRepository;
235
+ environments: readonly SecretsStatusEnvironment[];
236
+ rows: readonly SecretRow[];
237
+ /** Worker label -> its `smoo.wrangler.secretStages` declaration; a worker with no block is absent. */
238
+ stageScopes: Readonly<Record<string, SecretStageMap>>;
239
+ }
240
+
241
+ /**
242
+ * The stages that require a value for one name: the union over every worker
243
+ * declaring it, since a name two projects share must exist wherever either of
244
+ * them deploys.
245
+ */
246
+ function requiredByStages(
247
+ name: string,
248
+ workers: readonly string[],
249
+ stageScopes: Readonly<Record<string, SecretStageMap>>,
250
+ ): SecretStageScope[] {
251
+ const required = new Set<SecretStageScope>();
252
+ for (const worker of workers) {
253
+ const scoped = stageScopes[worker]?.[name];
254
+ // No scope declared for a name the worker declares is "every stage": the
255
+ // map is an exception list, not a registry.
256
+ for (const stage of scoped ?? EVERY_STAGE) required.add(stage);
257
+ }
258
+ return EVERY_STAGE.filter((stage) => required.has(stage));
259
+ }
260
+
261
+ /** The document, from facts. Pure: every read happened before this was called. */
262
+ export function projectSecretsStatus(facts: SecretsStatusFacts): SecretsStatusDocument {
263
+ const secrets: SecretsStatusRow[] = facts.rows.map((row) => ({
264
+ ...row,
265
+ requiredByStages: requiredByStages(row.name, row.declaredByWorkers, facts.stageScopes),
266
+ }));
267
+ // Only an environment a workflow binds *and* `gh` could list is a scope a job
268
+ // is known to read: an unreadable one is unknown, not empty.
269
+ const bound = facts.environments
270
+ .filter((environment) => environment.bound && environment.readable)
271
+ .map((environment) => environment.name);
272
+ return {
273
+ version: 1,
274
+ repository: facts.repository,
275
+ environments: [...facts.environments],
276
+ secrets,
277
+ unsatisfied: unsatisfiedSecrets(secrets, bound).map((row) => ({
278
+ name: row.name,
279
+ repositorySecret: row.repositorySecret,
280
+ missingIn: environmentsMissing(row, bound),
281
+ })),
282
+ };
283
+ }
284
+
285
+ /**
286
+ * The consumer's boundary: text off a `smoo secrets status --json` run into a
287
+ * validated document, or typia's own account of every field that disagreed.
288
+ */
289
+ export const parseSecretsStatusDocument = typia.json.createValidateParse<SecretsStatusDocument>();
290
+
291
+ /**
292
+ * The emitter's boundary: the projection is checked against its declared type
293
+ * before it becomes text. A shape bug then fails here, naming the field, rather
294
+ * than reaching a consumer as a document its validator rejects with no idea who
295
+ * produced it.
296
+ */
297
+ export const stringifySecretsStatusDocument = typia.json.createAssertStringify<SecretsStatusDocument>();
@@ -1120,6 +1120,99 @@ describe('writeTemporaryConfig', () => {
1120
1120
  });
1121
1121
  });
1122
1122
 
1123
+ // The same project as FIXTURE/ROUTED_FIXTURE, in the format Cloudflare now recommends. The
1124
+ // deploy has to reach the same worker, the same routes and the same derived stage from it.
1125
+ const JSONC_FIXTURE = `{
1126
+ // A worker whose stages are derived, written as JSONC.
1127
+ "env": {
1128
+ "staging": {
1129
+ "name": "fixture-worker-staging",
1130
+ "workers_dev": false,
1131
+ "routes": [{ "pattern": "*.staging.example.test/*", "zone_name": "example.test" }],
1132
+ "vars": { "ENVIRONMENT": "staging" },
1133
+ },
1134
+ },
1135
+ }
1136
+ `;
1137
+
1138
+ describe('deployStage with a JSONC source config', () => {
1139
+ it('deploys staging from wrangler.jsonc itself, with --env and no temporary file', async () => {
1140
+ const root = await fixtureRoot(JSONC_FIXTURE, 'wrangler.jsonc');
1141
+ const runner = new FakeRunner();
1142
+ const cloudflare = new FakeCloudflare();
1143
+ cloudflare.scripts = [{ id: 'fixture-worker-staging' }];
1144
+ cloudflare.zones = [{ id: 'zone-1', name: 'example.test' }];
1145
+
1146
+ const result = await deployStage(root, { stage: 'staging' }, dependencies(runner, cloudflare));
1147
+
1148
+ expect(result).toMatchObject({ workerName: 'fixture-worker-staging', action: 'deployed' });
1149
+ const deploy = requiredTestValue(
1150
+ runner.calls.find((call) => call.args[0] === 'deploy'),
1151
+ 'deploy call',
1152
+ );
1153
+ expect(deploy.args.slice(0, 5)).toEqual(['deploy', '--config', join(root, 'wrangler.jsonc'), '--env', 'staging']);
1154
+ expect(cloudflare.mutations).toEqual([
1155
+ 'create-dns:zone-1:*.staging.example.test:staging.example.test',
1156
+ 'create-route:zone-1:*.staging.example.test/*:fixture-worker-staging',
1157
+ ]);
1158
+ expect((await readdir(root)).filter((name) => name.startsWith('.wrangler.smoo-'))).toEqual([]);
1159
+ });
1160
+
1161
+ it('derives a pull-request stage into a temporary JSON config beside the source', async () => {
1162
+ const root = await fixtureRoot(JSONC_FIXTURE, 'wrangler.jsonc');
1163
+ const runner = new FakeRunner();
1164
+ const cloudflare = new FakeCloudflare();
1165
+ cloudflare.zones = [{ id: 'zone-1', name: 'example.test' }];
1166
+ let derivedConfig: Record<string, unknown> | undefined;
1167
+ let derivedPath: string | undefined;
1168
+ runner.onCall = async (args) => {
1169
+ if (args[0] !== 'deploy') return;
1170
+ derivedPath = args[args.indexOf('--config') + 1];
1171
+ derivedConfig = JSON.parse(await readFile(derivedPath ?? '', 'utf8'));
1172
+ };
1173
+
1174
+ const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, cloudflare));
1175
+
1176
+ expect(result).toMatchObject({ workerName: 'fixture-worker-pr123', action: 'deployed' });
1177
+ // Wrangler picks its parser by extension, so the derived config is JSON whatever the source was.
1178
+ expect(derivedPath?.startsWith(join(root, '.wrangler.smoo-'))).toBe(true);
1179
+ expect(derivedPath?.endsWith('.json')).toBe(true);
1180
+ expect(derivedConfig?.env).toEqual({
1181
+ staging: {
1182
+ name: 'fixture-worker-staging',
1183
+ workers_dev: false,
1184
+ routes: [{ pattern: '*.staging.example.test/*', zone_name: 'example.test' }],
1185
+ vars: { ENVIRONMENT: 'staging' },
1186
+ },
1187
+ pr123: {
1188
+ name: 'fixture-worker-pr123',
1189
+ workers_dev: false,
1190
+ routes: [{ pattern: '*.pr123.example.test/*', zone_name: 'example.test' }],
1191
+ vars: { ENVIRONMENT: 'pr123' },
1192
+ },
1193
+ });
1194
+ const deploy = requiredTestValue(
1195
+ runner.calls.find((call) => call.args[0] === 'deploy'),
1196
+ 'deploy call',
1197
+ );
1198
+ expect(deploy.args.slice(3, 5)).toEqual(['--env', 'pr123']);
1199
+ // The committed source is never rewritten, and the derived copy does not outlive the deploy.
1200
+ expect(await readFile(join(root, 'wrangler.jsonc'), 'utf8')).toBe(JSONC_FIXTURE);
1201
+ expect((await readdir(root)).filter((name) => name.startsWith('.wrangler.smoo-'))).toEqual([]);
1202
+ });
1203
+
1204
+ it('refuses a project carrying two source configs before it reads either', async () => {
1205
+ const root = await fixtureRoot(JSONC_FIXTURE, 'wrangler.jsonc');
1206
+ await writeFile(join(root, 'wrangler.toml'), FIXTURE);
1207
+ const cloudflare = new FakeCloudflare();
1208
+
1209
+ await expect(deployStage(root, { stage: 'staging' }, dependencies(new FakeRunner(), cloudflare))).rejects.toThrow(
1210
+ `${root} declares more than one Wrangler configuration: wrangler.jsonc, wrangler.toml.`,
1211
+ );
1212
+ expect(cloudflare.mutations).toEqual([]);
1213
+ });
1214
+ });
1215
+
1123
1216
  /** Runs `action` with the variables set on this process, restoring the previous values afterwards. */
1124
1217
  async function withProcessEnv<T>(variables: Record<string, string>, action: () => Promise<T>): Promise<T> {
1125
1218
  const previous = Object.fromEntries(Object.keys(variables).map((name) => [name, process.env[name]]));
@@ -1134,10 +1227,10 @@ async function withProcessEnv<T>(variables: Record<string, string>, action: () =
1134
1227
  }
1135
1228
  }
1136
1229
 
1137
- async function fixtureRoot(toml = FIXTURE): Promise<string> {
1230
+ async function fixtureRoot(config = FIXTURE, file = 'wrangler.toml'): Promise<string> {
1138
1231
  const root = await mkdtemp(join(tmpdir(), 'smoo-wrangler-test-'));
1139
1232
  roots.push(root);
1140
- await writeFile(join(root, 'wrangler.toml'), toml);
1233
+ await writeFile(join(root, file), config);
1141
1234
  return root;
1142
1235
  }
1143
1236
 
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  import { parseJsonFileText } from '../lib/json.js';
5
5
  import { mergeEnv, printCommandOutput } from '../lib/run.js';
6
6
  import { type CloudflareClient, CloudflareRestClient, type D1DatabaseRecord } from './cloudflare.js';
7
- import { type FlatWranglerConfig, parseFlatWranglerConfig, planFlatStageResources } from './flat-config.js';
7
+ import { type FlatWranglerConfig, parseFlatWranglerConfig } from './flat-config.js';
8
8
  import {
9
9
  awaitLiveVersion,
10
10
  awaitVersionEndpoint,
@@ -15,11 +15,12 @@ import {
15
15
  type VersionEndpointWaitOptions,
16
16
  writeCachedLiveVersion,
17
17
  } from './live-version.js';
18
+ import { readWranglerSourceConfig } from './source-config.js';
18
19
  import {
19
20
  type ConfiguredStageResourcePlan,
20
21
  type DeploymentStage,
22
+ derivePullRequestDocument,
21
23
  derivePullRequestStageConfig,
22
- derivePullRequestWranglerConfig,
23
24
  hasExactStageSegment,
24
25
  isPullRequestStage,
25
26
  type LiveKvNamespace,
@@ -28,6 +29,7 @@ import {
28
29
  planConfiguredStageResources,
29
30
  planPullRequestBindings,
30
31
  planPullRequestResources,
32
+ planStageResources,
31
33
  pullRequestStage,
32
34
  } from './stage.js';
33
35
  import {
@@ -99,7 +101,7 @@ export interface DeployStageResult {
99
101
  export interface DeployStageOptions {
100
102
  /** `staging`, `production`, or `prN`. */
101
103
  stage: string;
102
- /** A build-generated flat wrangler.json to deploy instead of `./wrangler.toml` (see `prepareFlatConfig`). */
104
+ /** A build-generated flat wrangler.json to deploy instead of the project's own config (see `prepareFlatConfig`). */
103
105
  config?: string;
104
106
  /**
105
107
  * A URL, served by this worker, whose trimmed body is the running version tag. When given, the
@@ -137,7 +139,7 @@ export async function deployStage(
137
139
  try {
138
140
  const prepared = options.config
139
141
  ? await prepareFlatConfig(options.config, stage, accountId, gate, cloudflare)
140
- : await prepareTomlConfig(cwd, stage, accountId, gate, cloudflare);
142
+ : await prepareSourceConfig(cwd, stage, accountId, gate, cloudflare);
141
143
  temporaryConfigPath = prepared.temporaryConfigPath;
142
144
  // The pull-request path already ran this before provisioning; the gate answers once per Worker.
143
145
  // Everything below here writes to Cloudflare.
@@ -234,44 +236,45 @@ interface PreparedConfig {
234
236
  deployConfigPath: string;
235
237
  temporaryConfigPath?: string;
236
238
  plan: ConfiguredStageResourcePlan;
237
- /** D1 bindings whose migrations run before the deploy (flat configs only; TOML deploys keep migrations out of scope). */
239
+ /** D1 bindings whose migrations run before the deploy (flat configs only; source configs keep migrations out of scope). */
238
240
  d1MigrationBindings: string[];
239
- /** `--env <stage>` for TOML configs with env blocks; a flat config is already resolved, so no flag. */
241
+ /** `--env <stage>` for a source config with env blocks; a flat config is already resolved, so no flag. */
240
242
  envFlag?: DeploymentStage;
241
243
  }
242
244
 
243
- async function prepareTomlConfig(
245
+ /**
246
+ * The project's own `wrangler.jsonc`/`wrangler.json`/`wrangler.toml`, whichever it declares.
247
+ * Format stops mattering at `readWranglerSourceConfig`: everything below derives from the model.
248
+ */
249
+ async function prepareSourceConfig(
244
250
  cwd: string,
245
251
  stage: DeploymentStage,
246
252
  accountId: string,
247
253
  gate: SecretGate,
248
254
  cloudflare: CloudflareClient,
249
255
  ): Promise<PreparedConfig> {
250
- const committedConfigPath = join(cwd, 'wrangler.toml');
251
- const committedToml = await readFile(committedConfigPath, 'utf8');
256
+ const source = readWranglerSourceConfig(cwd);
252
257
  if (!isPullRequestStage(stage)) {
253
258
  return {
254
- deployConfigPath: committedConfigPath,
255
- plan: planConfiguredStageResources(committedToml, stage),
259
+ deployConfigPath: source.path,
260
+ plan: planConfiguredStageResources(source.document, stage),
256
261
  d1MigrationBindings: [],
257
262
  envFlag: stage,
258
263
  };
259
264
  }
260
265
  const liveNamespaces = await cloudflare.listKvNamespaces();
261
- const plan = planPullRequestResources(committedToml, stage, liveNamespaces);
266
+ const plan = planPullRequestResources(source.document, stage, liveNamespaces);
262
267
  const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(plan, gate, cloudflare, liveNamespaces);
263
- const derivedToml = derivePullRequestWranglerConfig(committedToml, {
264
- stage,
265
- accountId,
266
- kvNamespaceIds,
267
- d1DatabaseIds,
268
- });
269
- const temporaryConfigPath = join(cwd, `.wrangler.smoo-${process.pid}-${randomUUID()}.toml`);
270
- await writeTemporaryConfig(temporaryConfigPath, derivedToml);
268
+ const derived = derivePullRequestDocument(source.document, { stage, accountId, kvNamespaceIds, d1DatabaseIds });
269
+ // JSON whatever the source format was: wrangler picks its parser by extension, and a file
270
+ // written to be read once and deleted has no reader for the comments a TOML rewrite preserved.
271
+ // Beside the original, because its main/assets/migrations paths are relative to the file.
272
+ const temporaryConfigPath = join(dirname(source.path), `.wrangler.smoo-${process.pid}-${randomUUID()}.json`);
273
+ await writeTemporaryConfig(temporaryConfigPath, `${JSON.stringify(derived, null, 2)}\n`);
271
274
  return {
272
275
  deployConfigPath: temporaryConfigPath,
273
276
  temporaryConfigPath,
274
- plan: planConfiguredStageResources(derivedToml, stage),
277
+ plan: planConfiguredStageResources(derived, stage),
275
278
  d1MigrationBindings: [],
276
279
  envFlag: stage,
277
280
  };
@@ -288,7 +291,7 @@ async function prepareFlatConfig(
288
291
  if (!isPullRequestStage(stage)) {
289
292
  return {
290
293
  deployConfigPath: configPath,
291
- plan: planFlatStageResources(flat, stage),
294
+ plan: planStageResources(flat, stage),
292
295
  d1MigrationBindings: migrationBindings(flat),
293
296
  };
294
297
  }
@@ -302,7 +305,7 @@ async function prepareFlatConfig(
302
305
  return {
303
306
  deployConfigPath: temporaryConfigPath,
304
307
  temporaryConfigPath,
305
- plan: planFlatStageResources(derived, stage),
308
+ plan: planStageResources(derived, stage),
306
309
  d1MigrationBindings: migrationBindings(derived),
307
310
  };
308
311
  }
@@ -4,12 +4,10 @@
4
4
  // Cloudflare. It never provisions, never derives a pull-request stage's resources, and never
5
5
  // writes anything except its own cache entry.
6
6
 
7
- import { existsSync } from 'node:fs';
8
7
  import { readFile } from 'node:fs/promises';
9
- import { join } from 'node:path';
10
8
  import { parseJsonFileText } from '../lib/json.js';
11
9
  import { BunProcessRunner, type ProcessRunner, type ProcessRunOptions, wranglerJson } from './deploy-stage.js';
12
- import { parseFlatWranglerConfig, planFlatStageResources } from './flat-config.js';
10
+ import { parseFlatWranglerConfig } from './flat-config.js';
13
11
  import {
14
12
  LIVE_VERSION_CACHE_TTL_MS,
15
13
  type LiveVersionProbe,
@@ -18,11 +16,13 @@ import {
18
16
  readLiveVersion,
19
17
  writeCachedLiveVersion,
20
18
  } from './live-version.js';
19
+ import { readWranglerSourceConfig } from './source-config.js';
21
20
  import {
22
21
  type DeploymentStage,
23
22
  isPullRequestStage,
24
23
  parseDeploymentStage,
25
24
  planConfiguredStageResources,
25
+ planStageResources,
26
26
  stageResourceName,
27
27
  stagingWorkerBaseName,
28
28
  } from './stage.js';
@@ -64,16 +64,12 @@ export async function stageWorkerName(cwd: string, stage: DeploymentStage, confi
64
64
  const flat = parseJsonFileText(configPath, await readFile(configPath, 'utf8'), parseFlatWranglerConfig);
65
65
  return isPullRequestStage(stage)
66
66
  ? stageResourceName(stagingWorkerBaseName(flat.name), stage)
67
- : planFlatStageResources(flat, stage).workerName;
67
+ : planStageResources(flat, stage).workerName;
68
68
  }
69
- const tomlPath = join(cwd, 'wrangler.toml');
70
- if (!existsSync(tomlPath)) {
71
- throw new Error(`${tomlPath} does not exist; pass --config for a build-generated flat configuration.`);
72
- }
73
- const toml = await readFile(tomlPath, 'utf8');
69
+ const { document } = readWranglerSourceConfig(cwd);
74
70
  return isPullRequestStage(stage)
75
- ? stageResourceName(stagingWorkerBaseName(planConfiguredStageResources(toml, 'staging').workerName), stage)
76
- : planConfiguredStageResources(toml, stage).workerName;
71
+ ? stageResourceName(stagingWorkerBaseName(planConfiguredStageResources(document, 'staging').workerName), stage)
72
+ : planConfiguredStageResources(document, stage).workerName;
77
73
  }
78
74
 
79
75
  /**
@@ -1,6 +1,11 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
- import { type FlatWranglerConfig, parseFlatWranglerConfig, planFlatStageResources } from './flat-config.js';
3
- import { type DerivePullRequestConfigOptions, derivePullRequestStageConfig, planPullRequestBindings } from './stage.js';
2
+ import { type FlatWranglerConfig, parseFlatWranglerConfig } from './flat-config.js';
3
+ import {
4
+ type DerivePullRequestConfigOptions,
5
+ derivePullRequestStageConfig,
6
+ planPullRequestBindings,
7
+ planStageResources,
8
+ } from './stage.js';
4
9
 
5
10
  // The shape the Cloudflare Vite adapter emits for a site built with
6
11
  // CLOUDFLARE_ENV=staging: one flat document, ids already resolved.
@@ -181,9 +186,9 @@ describe('derivePullRequestStageConfig for a flat config', () => {
181
186
  });
182
187
  });
183
188
 
184
- describe('planFlatStageResources', () => {
189
+ describe('planStageResources', () => {
185
190
  it('reads the bindings a reconcile needs from a flat config', () => {
186
- const plan = planFlatStageResources(CONFIG, 'staging');
191
+ const plan = planStageResources(CONFIG, 'staging');
187
192
  expect(plan.workerName).toBe('site-preview-staging');
188
193
  expect(plan.kvNamespaces).toEqual([{ binding: 'SESSION', id: 'kv-staging' }]);
189
194
  expect(plan.r2Buckets).toEqual([{ binding: 'MEDIA', bucketName: 'site-media-staging' }]);
@@ -1,6 +1,6 @@
1
1
  import typia from 'typia';
2
2
  import { formatValidationErrors } from '../lib/json.js';
3
- import type { ConfiguredStageResourcePlan, DeploymentStage, StageConfigFields } from './stage.js';
3
+ import type { StageConfigFields } from './stage.js';
4
4
 
5
5
  /**
6
6
  * A Wrangler configuration with no `env` blocks: the shape build tools emit
@@ -11,7 +11,6 @@ import type { ConfiguredStageResourcePlan, DeploymentStage, StageConfigFields }
11
11
  */
12
12
  export interface FlatWranglerConfig extends StageConfigFields {
13
13
  env?: unknown;
14
- [key: string]: unknown;
15
14
  }
16
15
 
17
16
  const parseFlatWranglerConfigText = typia.json.createValidateParse<FlatWranglerConfig>();
@@ -27,21 +26,3 @@ export function parseFlatWranglerConfig(json: string): FlatWranglerConfig {
27
26
  }
28
27
  return result.data;
29
28
  }
30
-
31
- /** The reconcile plan (`reconcileStageResources`) read off a flat config for any stage. */
32
- export function planFlatStageResources(
33
- config: FlatWranglerConfig,
34
- stage: DeploymentStage,
35
- ): ConfiguredStageResourcePlan {
36
- return {
37
- stage,
38
- workerName: config.name,
39
- kvNamespaces: (config.kv_namespaces ?? []).map(({ binding, id }) => ({ binding, id })),
40
- r2Buckets: (config.r2_buckets ?? []).map((bucket) => ({ binding: bucket.binding, bucketName: bucket.bucket_name })),
41
- routes: (config.routes ?? []).map((route) => ({
42
- pattern: route.pattern,
43
- ...(typeof route.zone_name === 'string' ? { zoneName: route.zone_name } : {}),
44
- customDomain: route.custom_domain === true,
45
- })),
46
- };
47
- }