@smoothbricks/cli 0.11.17 → 0.11.18

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 (52) hide show
  1. package/README.md +22 -4
  2. package/dist/cli.js +10 -7
  3. package/dist/monorepo/ci-workflow.d.ts +56 -0
  4. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  5. package/dist/monorepo/ci-workflow.js +199 -2
  6. package/dist/monorepo/managed-files.d.ts +23 -0
  7. package/dist/monorepo/managed-files.d.ts.map +1 -1
  8. package/dist/monorepo/managed-files.js +39 -1
  9. package/dist/release/github-release.d.ts +10 -0
  10. package/dist/release/github-release.d.ts.map +1 -1
  11. package/dist/release/github-release.js +11 -5
  12. package/dist/release/index.d.ts.map +1 -1
  13. package/dist/release/index.js +4 -5
  14. package/dist/secrets/commands.d.ts +52 -13
  15. package/dist/secrets/commands.d.ts.map +1 -1
  16. package/dist/secrets/commands.js +220 -34
  17. package/dist/secrets/index.d.ts +21 -1
  18. package/dist/secrets/index.d.ts.map +1 -1
  19. package/dist/secrets/index.js +110 -4
  20. package/dist/secrets/repository.d.ts +60 -0
  21. package/dist/secrets/repository.d.ts.map +1 -0
  22. package/dist/secrets/repository.js +145 -0
  23. package/dist/wrangler/cloudflare.d.ts +7 -0
  24. package/dist/wrangler/cloudflare.d.ts.map +1 -1
  25. package/dist/wrangler/cloudflare.js +10 -0
  26. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  27. package/dist/wrangler/deploy-stage.js +50 -24
  28. package/dist/wrangler/stage-secrets.d.ts +57 -0
  29. package/dist/wrangler/stage-secrets.d.ts.map +1 -0
  30. package/dist/wrangler/stage-secrets.js +178 -0
  31. package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
  32. package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
  33. package/package.json +2 -2
  34. package/src/cli.ts +33 -10
  35. package/src/monorepo/__tests__/ci-workflow.test.ts +161 -0
  36. package/src/monorepo/ci-workflow.ts +277 -2
  37. package/src/monorepo/managed-files.test.ts +27 -0
  38. package/src/monorepo/managed-files.ts +56 -2
  39. package/src/monorepo/package-policy.test.ts +1 -1
  40. package/src/release/__tests__/github-release.test.ts +8 -4
  41. package/src/release/github-release.ts +13 -5
  42. package/src/release/index.ts +4 -4
  43. package/src/secrets/commands.test.ts +28 -0
  44. package/src/secrets/commands.ts +237 -35
  45. package/src/secrets/index.test.ts +118 -1
  46. package/src/secrets/index.ts +108 -4
  47. package/src/secrets/repository.test.ts +98 -0
  48. package/src/secrets/repository.ts +164 -0
  49. package/src/wrangler/cloudflare.ts +18 -0
  50. package/src/wrangler/deploy-stage.test.ts +258 -11
  51. package/src/wrangler/deploy-stage.ts +70 -38
  52. package/src/wrangler/stage-secrets.ts +146 -0
@@ -3,15 +3,16 @@ import { existsSync, mkdtempSync, readFileSync, statSync } from 'node:fs';
3
3
  import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import type {
7
- CloudflareClient,
8
- CloudflareZone,
9
- D1DatabaseRecord,
10
- DnsRecord,
11
- R2Bucket,
12
- WorkerDomain,
13
- WorkerRoute,
14
- WorkerScript,
6
+ import {
7
+ CloudflareApiError,
8
+ type CloudflareClient,
9
+ type CloudflareZone,
10
+ type D1DatabaseRecord,
11
+ type DnsRecord,
12
+ type R2Bucket,
13
+ type WorkerDomain,
14
+ type WorkerRoute,
15
+ type WorkerScript,
15
16
  } from './cloudflare.js';
16
17
  import {
17
18
  childEnvironment,
@@ -144,6 +145,10 @@ class FakeCloudflare implements CloudflareClient {
144
145
  namespaces: LiveKvNamespace[] = [];
145
146
  buckets: R2Bucket[] = [];
146
147
  scripts: WorkerScript[] = [{ id: 'fixture-worker-pr123' }];
148
+ /** Secret names each Worker already holds, by Worker name. */
149
+ secrets: Record<string, string[]> = {};
150
+ /** Every Worker whose secrets were queried, in order, so a test can prove one was never asked. */
151
+ secretQueries: string[] = [];
147
152
  domains: WorkerDomain[] = [];
148
153
  zones: CloudflareZone[] = [];
149
154
  routes: Record<string, WorkerRoute[]> = {};
@@ -183,6 +188,20 @@ class FakeCloudflare implements CloudflareClient {
183
188
  async listWorkerScripts(): Promise<WorkerScript[]> {
184
189
  return this.scripts;
185
190
  }
191
+ async listWorkerSecrets(workerName: string): Promise<string[]> {
192
+ this.secretQueries.push(workerName);
193
+ // Cloudflare answers 404 for a Worker that is not there. Wrangler used to hand that back as an
194
+ // empty list; it no longer does, and code that reads the failure as "no secrets" inverts the
195
+ // one check a first deployment depends on.
196
+ if (!this.scripts.some((script) => script.id === workerName)) {
197
+ throw new CloudflareApiError(
198
+ `Cloudflare API /workers/scripts/${workerName}/secrets failed: not found`,
199
+ 404,
200
+ [10007],
201
+ );
202
+ }
203
+ return this.secrets[workerName] ?? [];
204
+ }
186
205
  async deleteWorkerScript(name: string): Promise<void> {
187
206
  this.mutations.push(`delete-worker:${name}`);
188
207
  }
@@ -397,11 +416,13 @@ describe('deploy-stage against live state', () => {
397
416
  expect(existsSync(requiredTestValue(runner.secretsPathSeen, 'secrets path'))).toBe(false);
398
417
  });
399
418
 
400
- it('passes only present manifest values for fixed stages and preserves absent remote secrets', async () => {
419
+ it('sends only the values it has and leaves a secret the Worker already holds untouched', async () => {
401
420
  const root = await fixtureRoot();
402
421
  await writeFile(join(root, '.dev.vars.example'), 'FIXTURE_SECRET=""\nFIXTURE_TOKEN=""\n');
403
422
  const runner = new FakeRunner([], {});
404
423
  const cloudflare = new FakeCloudflare();
424
+ cloudflare.scripts = [{ id: 'fixture-worker-staging' }];
425
+ cloudflare.secrets['fixture-worker-staging'] = ['FIXTURE_TOKEN'];
405
426
 
406
427
  const result = await deployStage(
407
428
  root,
@@ -417,6 +438,8 @@ describe('deploy-stage against live state', () => {
417
438
  },
418
439
  );
419
440
 
441
+ // `--secrets-file` applies additively, so naming only what this shell has leaves FIXTURE_TOKEN
442
+ // exactly as the Worker holds it. That silence is only safe because the gate proved it is held.
420
443
  expect(result.action).toBe('deployed');
421
444
  expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({
422
445
  FIXTURE_SECRET: 'shared-secret',
@@ -467,6 +490,230 @@ describe('deploy-stage against live state', () => {
467
490
  });
468
491
  });
469
492
 
493
+ /**
494
+ * `smoo.wrangler.secretStages` answers two questions from one declaration, and both are load
495
+ * bearing: which secrets a stage must have before it may deploy, and which secrets it is allowed
496
+ * to receive at all. The second direction is the security-relevant one — a capability CI exports
497
+ * for preview stages must not ride a production deploy just because the variable is set.
498
+ */
499
+ describe('stage-scoped deployment secrets', () => {
500
+ const STAGED_FIXTURE = `${FIXTURE}
501
+ [env.production]
502
+ name = "fixture-worker-production"
503
+ workers_dev = false
504
+
505
+ [env.production.vars]
506
+ ENVIRONMENT = "production"
507
+ `;
508
+
509
+ async function scopedRoot(secrets: string[], secretStages?: Record<string, string[]>): Promise<string> {
510
+ const root = await fixtureRoot(STAGED_FIXTURE);
511
+ await writeFile(join(root, '.dev.vars.example'), secrets.map((name) => `${name}=""\n`).join(''));
512
+ if (secretStages) {
513
+ await writeFile(
514
+ join(root, 'package.json'),
515
+ `${JSON.stringify({ name: '@acme/api', smoo: { wrangler: { secretStages } } }, null, 2)}\n`,
516
+ );
517
+ }
518
+ return root;
519
+ }
520
+
521
+ function environment(values: Record<string, string> = {}): NodeJS.ProcessEnv {
522
+ return { CLOUDFLARE_ACCOUNT_ID: 'account-1', CLOUDFLARE_API_TOKEN: 'token', ...values };
523
+ }
524
+
525
+ it('refuses a first deploy whose stage-required secret is exported nowhere, naming every one', async () => {
526
+ const root = await scopedRoot(['SESSION_SECRET', 'OAUTH_STATE_KEY'], {
527
+ OAUTH_STATE_KEY: ['staging', 'production'],
528
+ });
529
+ const runner = new FakeRunner([], {});
530
+ const cloudflare = new FakeCloudflare();
531
+
532
+ await expect(
533
+ deployStage(root, { stage: 'production' }, { runner, cloudflare, processEnv: environment() }),
534
+ ).rejects.toThrow(
535
+ /Refusing to deploy fixture-worker-production to production[\s\S]*SESSION_SECRET[\s\S]*OAUTH_STATE_KEY/,
536
+ );
537
+ expect(cloudflare.mutations).toEqual([]);
538
+ expect(runner.calls).toEqual([]);
539
+ });
540
+
541
+ it('treats a Worker that is not there as holding nothing, never as needing nothing', async () => {
542
+ const root = await scopedRoot(['OAUTH_STATE_KEY']);
543
+ const cloudflare = new FakeCloudflare();
544
+
545
+ // The double answers 404 for an absent Worker, exactly as Cloudflare does. Reading that error
546
+ // as "no secrets" would let this deploy through; asking at all would surface the 404 instead
547
+ // of the refusal. Neither happens: the script listing already said the Worker holds nothing.
548
+ await expect(
549
+ deployStage(
550
+ root,
551
+ { stage: 'production' },
552
+ { runner: new FakeRunner([], {}), cloudflare, processEnv: environment() },
553
+ ),
554
+ ).rejects.toThrow(/Refusing to deploy fixture-worker-production to production[\s\S]*OAUTH_STATE_KEY/);
555
+ expect(cloudflare.secretQueries).toEqual([]);
556
+ expect(cloudflare.mutations).toEqual([]);
557
+ });
558
+
559
+ it('requires an unscoped secret of every stage, including a pull-request stage', async () => {
560
+ const root = await scopedRoot(['SESSION_SECRET'], { OTHER_SECRET: ['staging'] });
561
+ await writeFile(join(root, '.dev.vars.example'), 'SESSION_SECRET=""\nOTHER_SECRET=""\n');
562
+ const cloudflare = new FakeCloudflare();
563
+
564
+ await expect(
565
+ deployStage(root, { stage: 'pr77' }, dependencies(new FakeRunner([], {}), cloudflare)),
566
+ ).rejects.toThrow(/fixture-worker-pr77[\s\S]*SESSION_SECRET — unscoped, so every stage requires it/);
567
+ expect(cloudflare.mutations).toEqual([]);
568
+ });
569
+
570
+ it('does not require a preview-scoped secret of production', async () => {
571
+ const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['staging', 'preview'] });
572
+ const runner = new FakeRunner([], {});
573
+ const cloudflare = new FakeCloudflare();
574
+
575
+ const result = await deployStage(root, { stage: 'production' }, { runner, cloudflare, processEnv: environment() });
576
+
577
+ expect(result).toMatchObject({ action: 'deployed', workerName: 'fixture-worker-production' });
578
+ expect(runner.secretsPathSeen).toBeUndefined();
579
+ });
580
+
581
+ it('withholds a preview-scoped secret from production even when the deploying shell exports it', async () => {
582
+ const root = await scopedRoot(['API_TOKEN', 'E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['staging', 'preview'] });
583
+ const runner = new FakeRunner([], {});
584
+ const cloudflare = new FakeCloudflare();
585
+ // The real environment, because that is what a child process inherits and what the shell of a
586
+ // laptop deploy actually looks like when CI has exported a preview capability.
587
+ process.env.E2E_CONTROL_TOKEN = 'teardown-value';
588
+
589
+ try {
590
+ const result = await deployStage(
591
+ root,
592
+ { stage: 'production' },
593
+ {
594
+ runner,
595
+ cloudflare,
596
+ processEnv: environment({ API_TOKEN: 'api-value', E2E_CONTROL_TOKEN: 'teardown-value' }),
597
+ },
598
+ );
599
+
600
+ expect(result.action).toBe('deployed');
601
+ expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({ API_TOKEN: 'api-value' });
602
+ // Not in the payload, and not readable by the deploy either: the capability is absent from
603
+ // this stage at the process boundary, not merely left out of a file.
604
+ for (const call of runner.calls) {
605
+ expect(call.env.E2E_CONTROL_TOKEN).toBeUndefined();
606
+ }
607
+ expect(runner.calls.length).toBeGreaterThan(0);
608
+ } finally {
609
+ delete process.env.E2E_CONTROL_TOKEN;
610
+ }
611
+ });
612
+
613
+ it('sends a preview-scoped secret to the stages it is scoped to', async () => {
614
+ const scopes = { E2E_CONTROL_TOKEN: ['staging', 'preview'] };
615
+ const values = { API_TOKEN: 'api-value', E2E_CONTROL_TOKEN: 'teardown-value' };
616
+
617
+ for (const stage of ['staging', 'pr77'] as const) {
618
+ const root = await scopedRoot(['API_TOKEN', 'E2E_CONTROL_TOKEN'], scopes);
619
+ const runner = new FakeRunner([], {});
620
+
621
+ await deployStage(root, { stage }, { runner, cloudflare: new FakeCloudflare(), processEnv: environment(values) });
622
+
623
+ expect(JSON.parse(requiredTestValue(runner.secretsJson, `secrets JSON for ${stage}`))).toEqual(values);
624
+ }
625
+ });
626
+
627
+ it('treats an empty scope as no stage at all, not as every stage', async () => {
628
+ const root = await scopedRoot(['API_TOKEN', 'LOCAL_ONLY_KEY'], { LOCAL_ONLY_KEY: [] });
629
+ const runner = new FakeRunner([], {});
630
+
631
+ // `[]` and "absent from the map" are opposite declarations. A check written as
632
+ // `!scope?.length` would collapse them and ship a local-development value to every stage.
633
+ const result = await deployStage(
634
+ root,
635
+ { stage: 'production' },
636
+ {
637
+ runner,
638
+ cloudflare: new FakeCloudflare(),
639
+ processEnv: environment({ API_TOKEN: 'api-value', LOCAL_ONLY_KEY: 'laptop-value' }),
640
+ },
641
+ );
642
+
643
+ expect(result.action).toBe('deployed');
644
+ expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({ API_TOKEN: 'api-value' });
645
+ });
646
+
647
+ it('refuses when the Worker holds a secret this stage is scoped out of, since a deploy cannot remove it', async () => {
648
+ const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['staging', 'preview'] });
649
+ const cloudflare = new FakeCloudflare();
650
+ cloudflare.scripts = [{ id: 'fixture-worker-production' }];
651
+ cloudflare.secrets['fixture-worker-production'] = ['E2E_CONTROL_TOKEN'];
652
+
653
+ await expect(
654
+ deployStage(
655
+ root,
656
+ { stage: 'production' },
657
+ { runner: new FakeRunner([], {}), cloudflare, processEnv: environment() },
658
+ ),
659
+ ).rejects.toThrow(/keeps out of production[\s\S]*E2E_CONTROL_TOKEN — scoped to staging, preview/);
660
+ expect(cloudflare.mutations).toEqual([]);
661
+ });
662
+
663
+ it('refuses a scope written against a name no secret has, rather than scoping nothing', async () => {
664
+ const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOEKN: ['staging'] });
665
+
666
+ await expect(
667
+ deployStage(
668
+ root,
669
+ { stage: 'production' },
670
+ { runner: new FakeRunner([], {}), cloudflare: new FakeCloudflare(), processEnv: environment() },
671
+ ),
672
+ ).rejects.toThrow(/secretStages scopes E2E_CONTROL_TOEKN, which .dev.vars.example does not declare/);
673
+ });
674
+
675
+ it('names a malformed scope declaration by its path in the manifest', async () => {
676
+ const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['pr7'] });
677
+
678
+ await expect(
679
+ deployStage(
680
+ root,
681
+ { stage: 'production' },
682
+ { runner: new FakeRunner([], {}), cloudflare: new FakeCloudflare(), processEnv: environment() },
683
+ ),
684
+ ).rejects.toThrow(
685
+ /package\.json declares an invalid smoo\.wrangler block: smoo\.wrangler\.secretStages\.E2E_CONTROL_TOKEN\[0\]/,
686
+ );
687
+ });
688
+
689
+ it('keeps every secret value out of the refusal and off every command line', async () => {
690
+ const root = await scopedRoot(['API_TOKEN', 'SESSION_SECRET']);
691
+ const runner = new FakeRunner([], {});
692
+ const cloudflare = new FakeCloudflare();
693
+
694
+ const refused = await deployStage(
695
+ root,
696
+ { stage: 'production' },
697
+ { runner, cloudflare, processEnv: environment({ API_TOKEN: 'api-value' }) },
698
+ ).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
699
+
700
+ expect(refused).toContain('SESSION_SECRET');
701
+ expect(refused).not.toContain('api-value');
702
+
703
+ // And the same on the path that does deploy: values reach wrangler through a 0600 file only.
704
+ cloudflare.scripts = [{ id: 'fixture-worker-production' }];
705
+ cloudflare.secrets['fixture-worker-production'] = ['SESSION_SECRET'];
706
+ await deployStage(
707
+ root,
708
+ { stage: 'production' },
709
+ { runner, cloudflare, processEnv: environment({ API_TOKEN: 'api-value' }) },
710
+ );
711
+
712
+ expect(runner.calls.flatMap((call) => call.args).join('\u0000')).not.toContain('api-value');
713
+ expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({ API_TOKEN: 'api-value' });
714
+ });
715
+ });
716
+
470
717
  describe('cleanup-pr exact stage matching', () => {
471
718
  it('rejects an invalid PR before touching the client', async () => {
472
719
  const cloudflare = new FakeCloudflare();
@@ -837,7 +1084,7 @@ describe('deployStage with a flat JSON config', () => {
837
1084
 
838
1085
  await expect(
839
1086
  deployStage(root, { stage: 'pr7', config: configPath }, dependencies(new FakeRunner(), cloudflare)),
840
- ).rejects.toThrow(/requires process environment values/);
1087
+ ).rejects.toThrow(/Refusing to deploy fixture-website-preview-pr7 to pr7[\s\S]*FIXTURE_SECRET/);
841
1088
  expect(cloudflare.mutations).toEqual([]);
842
1089
  });
843
1090
 
@@ -1,5 +1,4 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { existsSync, readFileSync } from 'node:fs';
3
2
  import { readFile, rm, writeFile } from 'node:fs/promises';
4
3
  import { dirname, join } from 'node:path';
5
4
  import { parseJsonFileText } from '../lib/json.js';
@@ -16,7 +15,6 @@ import {
16
15
  type VersionEndpointWaitOptions,
17
16
  writeCachedLiveVersion,
18
17
  } from './live-version.js';
19
- import { parseDevVarsExample } from './prepare-env.js';
20
18
  import {
21
19
  type ConfiguredStageResourcePlan,
22
20
  type DeploymentStage,
@@ -32,6 +30,13 @@ import {
32
30
  planPullRequestResources,
33
31
  pullRequestStage,
34
32
  } from './stage.js';
33
+ import {
34
+ planStageSecrets,
35
+ readDeclaredSecretNames,
36
+ readSecretStageMap,
37
+ type StageSecretPlan,
38
+ stageSecretRefusal,
39
+ } from './stage-secrets.js';
35
40
 
36
41
  export interface ProcessResult {
37
42
  exitCode: number;
@@ -118,20 +123,25 @@ export async function deployStage(
118
123
  dependencies.cloudflare ??
119
124
  new CloudflareRestClient(accountId, requiredEnvironmentValue(apiToken, 'CLOUDFLARE_API_TOKEN'));
120
125
  const runner = dependencies.runner ?? new BunProcessRunner();
121
- const secretNames = readSecretNames(cwd);
126
+ const secretPlan = planStageSecrets(readDeclaredSecretNames(cwd), stage, readSecretStageMap(cwd));
127
+ // Only the stage's own secrets, and only the ones a value exists for. A secret scoped to other
128
+ // stages is dropped here even when this shell exports it: that is the whole permission rule.
122
129
  const secretValues: Record<string, string> = {};
123
- for (const name of secretNames) {
130
+ for (const name of secretPlan.required) {
124
131
  const value = processEnv[name];
125
132
  if (value) secretValues[name] = value;
126
133
  }
127
- const missingSecrets = secretNames.filter((name) => !processEnv[name]);
134
+ const gate = stageSecretGate(secretPlan, new Set(Object.keys(secretValues)), cloudflare);
128
135
  let temporaryConfigPath: string | undefined;
129
136
  let temporarySecretsPath: string | undefined;
130
137
  try {
131
138
  const prepared = options.config
132
- ? await prepareFlatConfig(options.config, stage, accountId, missingSecrets, cloudflare)
133
- : await prepareTomlConfig(cwd, stage, accountId, missingSecrets, cloudflare);
139
+ ? await prepareFlatConfig(options.config, stage, accountId, gate, cloudflare)
140
+ : await prepareTomlConfig(cwd, stage, accountId, gate, cloudflare);
134
141
  temporaryConfigPath = prepared.temporaryConfigPath;
142
+ // The pull-request path already ran this before provisioning; the gate answers once per Worker.
143
+ // Everything below here writes to Cloudflare.
144
+ await gate(prepared.plan.workerName);
135
145
  const workerExists = await reconcileStageResources(prepared.plan, cloudflare);
136
146
  const versionTag = nxTaskVersionTag(processEnv);
137
147
  const envArgs = prepared.envFlag ? ['--env', prepared.envFlag] : [];
@@ -139,7 +149,13 @@ export async function deployStage(
139
149
  // renames the worker `<name>-<CLOUDFLARE_ENV>`. The build that produced the flat config is the
140
150
  // caller that sets the variable, so the deploy would silently succeed under a name neither
141
151
  // `reconcileStageResources` nor `versions list` looks at.
142
- const run: ProcessRunOptions = prepared.envFlag ? { cwd } : { cwd, unsetEnv: ['CLOUDFLARE_ENV'] };
152
+ //
153
+ // A secret this stage is scoped out of goes with it. Leaving it out of the secrets payload is
154
+ // what stops it being installed; withholding it from the child as well means the deploy cannot
155
+ // read it at all, so "this stage never sees that capability" holds at the process boundary and
156
+ // not merely in the file we happen to write.
157
+ const unsetEnv = [...(prepared.envFlag ? [] : ['CLOUDFLARE_ENV']), ...secretPlan.withheld];
158
+ const run: ProcessRunOptions = unsetEnv.length > 0 ? { cwd, unsetEnv } : { cwd };
143
159
  const workerName = prepared.plan.workerName;
144
160
  const probe: LiveVersionProbe = {
145
161
  deployments: () => wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run),
@@ -228,7 +244,7 @@ async function prepareTomlConfig(
228
244
  cwd: string,
229
245
  stage: DeploymentStage,
230
246
  accountId: string,
231
- missingSecrets: string[],
247
+ gate: SecretGate,
232
248
  cloudflare: CloudflareClient,
233
249
  ): Promise<PreparedConfig> {
234
250
  const committedConfigPath = join(cwd, 'wrangler.toml');
@@ -243,12 +259,7 @@ async function prepareTomlConfig(
243
259
  }
244
260
  const liveNamespaces = await cloudflare.listKvNamespaces();
245
261
  const plan = planPullRequestResources(committedToml, stage, liveNamespaces);
246
- const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(
247
- plan,
248
- missingSecrets,
249
- cloudflare,
250
- liveNamespaces,
251
- );
262
+ const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(plan, gate, cloudflare, liveNamespaces);
252
263
  const derivedToml = derivePullRequestWranglerConfig(committedToml, {
253
264
  stage,
254
265
  accountId,
@@ -270,7 +281,7 @@ async function prepareFlatConfig(
270
281
  configPath: string,
271
282
  stage: DeploymentStage,
272
283
  accountId: string,
273
- missingSecrets: string[],
284
+ gate: SecretGate,
274
285
  cloudflare: CloudflareClient,
275
286
  ): Promise<PreparedConfig> {
276
287
  const flat = parseJsonFileText(configPath, await readFile(configPath, 'utf8'), parseFlatWranglerConfig);
@@ -283,12 +294,7 @@ async function prepareFlatConfig(
283
294
  }
284
295
  const liveNamespaces = await cloudflare.listKvNamespaces();
285
296
  const plan = planPullRequestBindings(flat, stage, liveNamespaces);
286
- const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(
287
- plan,
288
- missingSecrets,
289
- cloudflare,
290
- liveNamespaces,
291
- );
297
+ const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(plan, gate, cloudflare, liveNamespaces);
292
298
  const derived = derivePullRequestStageConfig(flat, { stage, accountId, kvNamespaceIds, d1DatabaseIds });
293
299
  // Beside the original: its main/assets/migrations paths are relative to the file.
294
300
  const temporaryConfigPath = join(dirname(configPath), `.wrangler.smoo-${process.pid}-${randomUUID()}.json`);
@@ -322,27 +328,58 @@ function migrationBindings(config: FlatWranglerConfig): string[] {
322
328
  .map((database) => database.binding);
323
329
  }
324
330
 
325
- async function refuseFirstDeploymentWithoutSecrets(
331
+ /**
332
+ * Asks, of one Worker, whether this stage may deploy at all. Called at each path's last read
333
+ * before its first write, so a refusal costs nothing: the pull-request path provisions KV and D1
334
+ * inside config preparation, and every path reconciles buckets, routes and DNS after it. A
335
+ * refusal arriving later would strand resources created for a deploy that was never allowed.
336
+ */
337
+ type SecretGate = (workerName: string) => Promise<void>;
338
+
339
+ /**
340
+ * One answer per Worker, reused by both call sites so the read costs one round trip.
341
+ *
342
+ * What the Worker holds comes from the account's script listing first. A Worker absent from it
343
+ * holds nothing — exactly the first-deployment case — and Cloudflare answers 404 rather than an
344
+ * empty list when asked for a missing Worker's secrets. Reading that failure as "then nothing is
345
+ * needed" would disarm the check at the one moment it matters most, so it is never asked.
346
+ */
347
+ function stageSecretGate(
348
+ plan: StageSecretPlan,
349
+ exported: ReadonlySet<string>,
350
+ cloudflare: CloudflareClient,
351
+ ): SecretGate {
352
+ const answered = new Map<string, Promise<void>>();
353
+ return (workerName) => {
354
+ let pending = answered.get(workerName);
355
+ if (!pending) {
356
+ pending = assertStageSecrets(plan, exported, cloudflare, workerName);
357
+ answered.set(workerName, pending);
358
+ }
359
+ return pending;
360
+ };
361
+ }
362
+
363
+ async function assertStageSecrets(
364
+ plan: StageSecretPlan,
365
+ exported: ReadonlySet<string>,
326
366
  cloudflare: CloudflareClient,
327
367
  workerName: string,
328
- missingSecrets: string[],
329
368
  ): Promise<void> {
330
- const firstDeployment = !(await cloudflare.listWorkerScripts()).some((script) => script.id === workerName);
331
- if (firstDeployment && missingSecrets.length > 0) {
332
- throw new Error(
333
- `First deployment of ${workerName} requires process environment values for: ${missingSecrets.join(', ')}.`,
334
- );
335
- }
369
+ const live = (await cloudflare.listWorkerScripts()).some((script) => script.id === workerName);
370
+ const held = new Set(live ? await cloudflare.listWorkerSecrets(workerName) : []);
371
+ const refusal = stageSecretRefusal(plan, exported, held, workerName);
372
+ if (refusal) throw new Error(refusal);
336
373
  }
337
374
 
338
- /** Isolation refusals already ran in the plan; this is the first Cloudflare write. */
375
+ /** Isolation refusals already ran in the plan; the gate below is the last one before the first write. */
339
376
  async function provisionPullRequestResources(
340
377
  plan: PullRequestResourcePlan,
341
- missingSecrets: string[],
378
+ gate: SecretGate,
342
379
  cloudflare: CloudflareClient,
343
380
  liveNamespaces: LiveKvNamespace[],
344
381
  ): Promise<{ kvNamespaceIds: Map<string, string>; d1DatabaseIds: Map<string, string> }> {
345
- await refuseFirstDeploymentWithoutSecrets(cloudflare, plan.workerName, missingSecrets);
382
+ await gate(plan.workerName);
346
383
  const kvNamespaceIds = await ensureKvNamespaces(plan.kvNamespaces, liveNamespaces, cloudflare);
347
384
  const d1DatabaseIds =
348
385
  plan.d1Databases.length === 0
@@ -669,8 +706,3 @@ function requiredEnvironmentValue(value: string | undefined, name: string): stri
669
706
  if (!value) throw new Error(`${name} is required.`);
670
707
  return value;
671
708
  }
672
-
673
- function readSecretNames(cwd: string): string[] {
674
- const path = join(cwd, '.dev.vars.example');
675
- return existsSync(path) ? parseDevVarsExample(readFileSync(path, 'utf8')) : [];
676
- }
@@ -0,0 +1,146 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import typia from 'typia';
4
+ import { formatValidationErrors, parseJsonFileText } from '../lib/json.js';
5
+ import { parseDevVarsExample } from './prepare-env.js';
6
+ import { type DeploymentStage, isPullRequestStage } from './stage.js';
7
+
8
+ /**
9
+ * How a declaration names a stage. The fixed stages go by their own name; every `prN` stage is
10
+ * `preview`, because a scope is written once and pull-request numbers are not knowable in advance.
11
+ */
12
+ export type SecretStageScope = 'staging' | 'production' | 'preview';
13
+
14
+ /**
15
+ * `smoo.wrangler.secretStages`: a declared secret name mapped to the stages it belongs to. The map
16
+ * answers two questions with one declaration, and both directions matter:
17
+ *
18
+ * - requirement — a stage the secret belongs to refuses to deploy without a value for it;
19
+ * - permission — a stage the secret does *not* belong to never receives it, however loudly the
20
+ * deploying shell exports it. A test-only capability exported by CI for preview stages must not
21
+ * ride along into production just because the variable happens to be set.
22
+ *
23
+ * A declared name absent from the map belongs to every stage. A name mapped to `[]` belongs to no
24
+ * stage, which is how a value that exists only for local development is declared.
25
+ */
26
+ export type SecretStageMap = Record<string, SecretStageScope[]>;
27
+
28
+ /** The one block of a project's package.json this reader needs; every other field is ignored. */
29
+ interface WranglerPackageManifest {
30
+ smoo?: {
31
+ wrangler?: {
32
+ secretStages?: SecretStageMap;
33
+ };
34
+ };
35
+ }
36
+
37
+ const validateWranglerManifest = typia.json.createValidateParse<WranglerPackageManifest>();
38
+
39
+ /** Secret NAMES the project declares. Values live on the Worker; the repo only ever holds the keys. */
40
+ export function readDeclaredSecretNames(cwd: string): string[] {
41
+ const path = join(cwd, '.dev.vars.example');
42
+ return existsSync(path) ? parseDevVarsExample(readFileSync(path, 'utf8')) : [];
43
+ }
44
+
45
+ /** `smoo.wrangler.secretStages` from the project's package.json; no file and no block scope nothing. */
46
+ export function readSecretStageMap(cwd: string): SecretStageMap {
47
+ const path = join(cwd, 'package.json');
48
+ if (!existsSync(path)) return {};
49
+ const result = parseJsonFileText(path, readFileSync(path, 'utf8'), validateWranglerManifest);
50
+ if (!result.success) {
51
+ throw new Error(`${path} declares an invalid smoo.wrangler block: ${formatValidationErrors(result.errors)}`);
52
+ }
53
+ return result.data.smoo?.wrangler?.secretStages ?? {};
54
+ }
55
+
56
+ /** Which of a project's declared secrets one stage may see, and which belong to other stages. */
57
+ export interface StageSecretPlan {
58
+ stage: DeploymentStage;
59
+ /** Declared names this stage requires — and the only ones a deploy of it may carry. */
60
+ required: string[];
61
+ /** Declared names scoped to other stages: withheld from this deploy even when a value is exported. */
62
+ withheld: string[];
63
+ /** The declaration itself, so a refusal can say why each name is where it is. */
64
+ scopes: SecretStageMap;
65
+ }
66
+
67
+ /**
68
+ * Splits the declared secrets by whether `stage` is in scope for each.
69
+ *
70
+ * A scope on an undeclared name is refused rather than ignored: it is almost always a typo of a
71
+ * real secret's name, and its effect is the dangerous direction — the misspelt entry scopes
72
+ * nothing while the real secret, still absent from the map, reaches every stage.
73
+ */
74
+ export function planStageSecrets(declared: string[], stage: DeploymentStage, scopes: SecretStageMap): StageSecretPlan {
75
+ const declaredNames = new Set(declared);
76
+ const undeclared = Object.keys(scopes).filter((name) => !declaredNames.has(name));
77
+ if (undeclared.length > 0) {
78
+ throw new Error(
79
+ `smoo.wrangler.secretStages scopes ${undeclared.join(', ')}, which .dev.vars.example does not declare. ` +
80
+ 'A scope on a name no secret has leaves the secret it was meant for unscoped, so that secret reaches every stage.',
81
+ );
82
+ }
83
+ // Every `prN` stage answers to one written token: a scope cannot name pull requests in advance.
84
+ const scope: SecretStageScope = isPullRequestStage(stage) ? 'preview' : stage;
85
+ const required: string[] = [];
86
+ const withheld: string[] = [];
87
+ for (const name of declared) {
88
+ const declaredScope = scopes[name];
89
+ if (declaredScope === undefined || declaredScope.includes(scope)) {
90
+ required.push(name);
91
+ } else {
92
+ withheld.push(name);
93
+ }
94
+ }
95
+ return { stage, required, withheld, scopes };
96
+ }
97
+
98
+ /**
99
+ * Why this deploy must not proceed, or nothing when it may.
100
+ *
101
+ * Two refusals, reported together so one run names every problem:
102
+ *
103
+ * - a required secret with no value anywhere. `--secrets-file` applies additively, so a deploy
104
+ * that never mentions a secret leaves whatever the Worker already holds — silence that reads as
105
+ * success while a secret introduced after the first deploy never arrives, and the code that
106
+ * needs it fails at runtime instead of here.
107
+ * - a withheld secret the Worker already holds. Filtering it out of this deploy's payload cannot
108
+ * remove it, so the scope would be nominal rather than enforced until someone deletes it.
109
+ *
110
+ * A value is never read, never formatted, and never named beyond its key.
111
+ */
112
+ export function stageSecretRefusal(
113
+ plan: StageSecretPlan,
114
+ exported: ReadonlySet<string>,
115
+ held: ReadonlySet<string>,
116
+ workerName: string,
117
+ ): string | undefined {
118
+ const unavailable = plan.required.filter((name) => !exported.has(name) && !held.has(name));
119
+ const installed = plan.withheld.filter((name) => held.has(name));
120
+ if (unavailable.length === 0 && installed.length === 0) return undefined;
121
+ const lines = [`Refusing to deploy ${workerName} to ${plan.stage}.`];
122
+ if (unavailable.length > 0) {
123
+ lines.push(
124
+ `Stage ${plan.stage} requires these secrets and no value exists for them, neither in this environment nor on the Worker:`,
125
+ ...unavailable.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`),
126
+ 'Export each one in the deploying shell before the deploy. Once the Worker exists,',
127
+ `\`wrangler secret put <NAME> --name ${workerName}\` supplies it too.`,
128
+ );
129
+ }
130
+ if (installed.length > 0) {
131
+ lines.push(
132
+ `The Worker holds these secrets, which smoo.wrangler.secretStages keeps out of ${plan.stage}:`,
133
+ ...installed.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`),
134
+ `Delete each one with \`wrangler secret delete <NAME> --name ${workerName}\`. A deploy of this stage`,
135
+ 'never sends them, so leaving them installed would keep the scope nominal.',
136
+ );
137
+ }
138
+ return lines.join('\n');
139
+ }
140
+
141
+ function scopeDescription(name: string, scopes: SecretStageMap): string {
142
+ const scope = scopes[name];
143
+ if (scope === undefined) return 'unscoped, so every stage requires it';
144
+ if (scope.length === 0) return 'scoped to no stage (local development only)';
145
+ return `scoped to ${scope.join(', ')}`;
146
+ }