@smoothbricks/cli 0.11.16 → 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 (79) hide show
  1. package/README.md +65 -4
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +47 -0
  4. package/dist/github-ci/index.d.ts +1 -1
  5. package/dist/github-ci/index.d.ts.map +1 -1
  6. package/dist/github-ci/index.js +7 -10
  7. package/dist/lib/secret-names.d.ts +35 -0
  8. package/dist/lib/secret-names.d.ts.map +1 -0
  9. package/dist/lib/secret-names.js +61 -0
  10. package/dist/monorepo/ci-workflow.d.ts +56 -0
  11. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  12. package/dist/monorepo/ci-workflow.js +214 -8
  13. package/dist/monorepo/managed-files.d.ts +23 -0
  14. package/dist/monorepo/managed-files.d.ts.map +1 -1
  15. package/dist/monorepo/managed-files.js +39 -1
  16. package/dist/monorepo/publish-workflow.d.ts +10 -1
  17. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  18. package/dist/monorepo/publish-workflow.js +58 -20
  19. package/dist/release/github-release.d.ts +10 -0
  20. package/dist/release/github-release.d.ts.map +1 -1
  21. package/dist/release/github-release.js +11 -5
  22. package/dist/release/index.d.ts.map +1 -1
  23. package/dist/release/index.js +4 -5
  24. package/dist/secrets/commands.d.ts +84 -0
  25. package/dist/secrets/commands.d.ts.map +1 -0
  26. package/dist/secrets/commands.js +375 -0
  27. package/dist/secrets/index.d.ts +103 -0
  28. package/dist/secrets/index.d.ts.map +1 -0
  29. package/dist/secrets/index.js +255 -0
  30. package/dist/secrets/repository.d.ts +60 -0
  31. package/dist/secrets/repository.d.ts.map +1 -0
  32. package/dist/secrets/repository.js +145 -0
  33. package/dist/wrangler/cloudflare.d.ts +7 -0
  34. package/dist/wrangler/cloudflare.d.ts.map +1 -1
  35. package/dist/wrangler/cloudflare.js +10 -0
  36. package/dist/wrangler/deploy-stage.d.ts +13 -2
  37. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  38. package/dist/wrangler/deploy-stage.js +89 -78
  39. package/dist/wrangler/deployed-version.d.ts +44 -0
  40. package/dist/wrangler/deployed-version.d.ts.map +1 -0
  41. package/dist/wrangler/deployed-version.js +87 -0
  42. package/dist/wrangler/live-version.d.ts +146 -0
  43. package/dist/wrangler/live-version.d.ts.map +1 -0
  44. package/dist/wrangler/live-version.js +326 -0
  45. package/dist/wrangler/stage-secrets.d.ts +57 -0
  46. package/dist/wrangler/stage-secrets.d.ts.map +1 -0
  47. package/dist/wrangler/stage-secrets.js +178 -0
  48. package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
  49. package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
  50. package/package.json +2 -2
  51. package/src/cli.ts +69 -1
  52. package/src/github-ci/index.test.ts +92 -8
  53. package/src/github-ci/index.ts +7 -10
  54. package/src/lib/secret-names.ts +70 -0
  55. package/src/monorepo/__tests__/ci-workflow.test.ts +188 -2
  56. package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
  57. package/src/monorepo/ci-workflow.ts +294 -8
  58. package/src/monorepo/managed-files.test.ts +28 -1
  59. package/src/monorepo/managed-files.ts +56 -2
  60. package/src/monorepo/package-policy.test.ts +1 -1
  61. package/src/monorepo/publish-workflow.ts +65 -19
  62. package/src/monorepo/secret-references.test.ts +1 -1
  63. package/src/release/__tests__/github-release.test.ts +8 -4
  64. package/src/release/__tests__/private-npm-status.test.ts +2 -2
  65. package/src/release/github-release.ts +13 -5
  66. package/src/release/index.ts +4 -4
  67. package/src/secrets/commands.test.ts +28 -0
  68. package/src/secrets/commands.ts +412 -0
  69. package/src/secrets/index.test.ts +209 -0
  70. package/src/secrets/index.ts +287 -0
  71. package/src/secrets/repository.test.ts +98 -0
  72. package/src/secrets/repository.ts +164 -0
  73. package/src/wrangler/cloudflare.ts +18 -0
  74. package/src/wrangler/deploy-stage.test.ts +448 -23
  75. package/src/wrangler/deploy-stage.ts +142 -83
  76. package/src/wrangler/deployed-version.test.ts +150 -0
  77. package/src/wrangler/deployed-version.ts +130 -0
  78. package/src/wrangler/live-version.ts +363 -0
  79. package/src/wrangler/stage-secrets.ts +146 -0
@@ -1,13 +1,20 @@
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
- import typia from 'typia';
6
4
  import { parseJsonFileText } from '../lib/json.js';
7
5
  import { mergeEnv, printCommandOutput } from '../lib/run.js';
8
6
  import { type CloudflareClient, CloudflareRestClient, type D1DatabaseRecord } from './cloudflare.js';
9
7
  import { type FlatWranglerConfig, parseFlatWranglerConfig, planFlatStageResources } from './flat-config.js';
10
- import { parseDevVarsExample } from './prepare-env.js';
8
+ import {
9
+ awaitLiveVersion,
10
+ awaitVersionEndpoint,
11
+ currentDeploymentVersionId,
12
+ findVersionIdByTag,
13
+ type LiveVersionProbe,
14
+ liveVersionCacheDirectory,
15
+ type VersionEndpointWaitOptions,
16
+ writeCachedLiveVersion,
17
+ } from './live-version.js';
11
18
  import {
12
19
  type ConfiguredStageResourcePlan,
13
20
  type DeploymentStage,
@@ -23,6 +30,13 @@ import {
23
30
  planPullRequestResources,
24
31
  pullRequestStage,
25
32
  } from './stage.js';
33
+ import {
34
+ planStageSecrets,
35
+ readDeclaredSecretNames,
36
+ readSecretStageMap,
37
+ type StageSecretPlan,
38
+ stageSecretRefusal,
39
+ } from './stage-secrets.js';
26
40
 
27
41
  export interface ProcessResult {
28
42
  exitCode: number;
@@ -69,6 +83,10 @@ export interface WranglerCommandDependencies {
69
83
  runner?: ProcessRunner;
70
84
  cloudflare?: CloudflareClient;
71
85
  processEnv?: NodeJS.ProcessEnv;
86
+ /** Bounds and clock for the post-deploy wait; tests drive it without sleeping. */
87
+ wait?: VersionEndpointWaitOptions;
88
+ /** Where a successful deploy records the tag it made live, for `smoo wrangler deployed-version`. */
89
+ liveVersionCacheDirectory?: string;
72
90
  }
73
91
 
74
92
  export interface DeployStageResult {
@@ -78,13 +96,17 @@ export interface DeployStageResult {
78
96
  versionTag?: string;
79
97
  }
80
98
 
81
- const isUnknownRecord = typia.createIs<Record<string, unknown>>();
82
-
83
99
  export interface DeployStageOptions {
84
100
  /** `staging`, `production`, or `prN`. */
85
101
  stage: string;
86
102
  /** A build-generated flat wrangler.json to deploy instead of `./wrangler.toml` (see `prepareFlatConfig`). */
87
103
  config?: string;
104
+ /**
105
+ * A URL, served by this worker, whose trimmed body is the running version tag. When given, the
106
+ * deploy is not done until that URL answers with the tag it just made live — the control plane
107
+ * accepting a traffic shift is not the edge serving it.
108
+ */
109
+ versionEndpoint?: string;
88
110
  }
89
111
 
90
112
  export async function deployStage(
@@ -101,20 +123,25 @@ export async function deployStage(
101
123
  dependencies.cloudflare ??
102
124
  new CloudflareRestClient(accountId, requiredEnvironmentValue(apiToken, 'CLOUDFLARE_API_TOKEN'));
103
125
  const runner = dependencies.runner ?? new BunProcessRunner();
104
- 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.
105
129
  const secretValues: Record<string, string> = {};
106
- for (const name of secretNames) {
130
+ for (const name of secretPlan.required) {
107
131
  const value = processEnv[name];
108
132
  if (value) secretValues[name] = value;
109
133
  }
110
- const missingSecrets = secretNames.filter((name) => !processEnv[name]);
134
+ const gate = stageSecretGate(secretPlan, new Set(Object.keys(secretValues)), cloudflare);
111
135
  let temporaryConfigPath: string | undefined;
112
136
  let temporarySecretsPath: string | undefined;
113
137
  try {
114
138
  const prepared = options.config
115
- ? await prepareFlatConfig(options.config, stage, accountId, missingSecrets, cloudflare)
116
- : await prepareTomlConfig(cwd, stage, accountId, missingSecrets, cloudflare);
139
+ ? await prepareFlatConfig(options.config, stage, accountId, gate, cloudflare)
140
+ : await prepareTomlConfig(cwd, stage, accountId, gate, cloudflare);
117
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);
118
145
  const workerExists = await reconcileStageResources(prepared.plan, cloudflare);
119
146
  const versionTag = nxTaskVersionTag(processEnv);
120
147
  const envArgs = prepared.envFlag ? ['--env', prepared.envFlag] : [];
@@ -122,18 +149,32 @@ export async function deployStage(
122
149
  // renames the worker `<name>-<CLOUDFLARE_ENV>`. The build that produced the flat config is the
123
150
  // caller that sets the variable, so the deploy would silently succeed under a name neither
124
151
  // `reconcileStageResources` nor `versions list` looks at.
125
- 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 };
126
159
  const workerName = prepared.plan.workerName;
160
+ const probe: LiveVersionProbe = {
161
+ deployments: () => wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run),
162
+ versions: () => wranglerJson(runner, ['versions', 'list', '--name', workerName, '--json'], run),
163
+ };
127
164
 
128
165
  // The tagged-version lookup runs before the migrations: a cache hit means this exact build is
129
166
  // already live, so its migrations ran with it and re-applying them would touch the remote
130
167
  // database for nothing. The activation and upload paths below still migrate first.
168
+ //
169
+ // This reads Cloudflare every time and never the local cache. A cache hit here would mean
170
+ // "we believe this hash is live" — exactly the belief a rollback falsifies — and believing it
171
+ // would skip the deploy that repairs the rollback. Reading live state is what makes a
172
+ // redundant deploy a proven no-op instead of an assumed one, which is in turn what makes a
173
+ // cross-project `dependsOn: ["<other>:deploy"]` edge cheap enough to be the ordering mechanism.
131
174
  let taggedVersionId: string | null = null;
132
175
  if (versionTag && workerExists) {
133
- const versions = await wranglerJson(runner, ['versions', 'list', '--name', workerName, '--json'], run);
134
- const deployments = await wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run);
135
- taggedVersionId = findVersionIdByTag(versions, versionTag);
136
- if (taggedVersionId && isFullCurrentDeployment(deployments, taggedVersionId)) {
176
+ taggedVersionId = findVersionIdByTag(await probe.versions(), versionTag);
177
+ if (taggedVersionId && currentDeploymentVersionId(await probe.deployments()) === taggedVersionId) {
137
178
  return { stage, workerName, action: 'remote-cache-hit', versionTag };
138
179
  }
139
180
  }
@@ -163,6 +204,7 @@ export async function deployStage(
163
204
  ],
164
205
  run,
165
206
  );
207
+ await confirmVersionIsLive(probe, versionTag, { stage, workerName, accountId }, options, dependencies, cwd);
166
208
  return { stage, workerName, action: 'activated', versionTag };
167
209
  }
168
210
 
@@ -174,6 +216,9 @@ export async function deployStage(
174
216
  deployArgs.push('--secrets-file', temporarySecretsPath);
175
217
  }
176
218
  await wrangler(runner, deployArgs, run);
219
+ if (versionTag) {
220
+ await confirmVersionIsLive(probe, versionTag, { stage, workerName, accountId }, options, dependencies, cwd);
221
+ }
177
222
  return { stage, workerName, action: 'deployed', ...(versionTag ? { versionTag } : {}) };
178
223
  } finally {
179
224
  if (temporaryConfigPath) {
@@ -199,7 +244,7 @@ async function prepareTomlConfig(
199
244
  cwd: string,
200
245
  stage: DeploymentStage,
201
246
  accountId: string,
202
- missingSecrets: string[],
247
+ gate: SecretGate,
203
248
  cloudflare: CloudflareClient,
204
249
  ): Promise<PreparedConfig> {
205
250
  const committedConfigPath = join(cwd, 'wrangler.toml');
@@ -214,12 +259,7 @@ async function prepareTomlConfig(
214
259
  }
215
260
  const liveNamespaces = await cloudflare.listKvNamespaces();
216
261
  const plan = planPullRequestResources(committedToml, stage, liveNamespaces);
217
- const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(
218
- plan,
219
- missingSecrets,
220
- cloudflare,
221
- liveNamespaces,
222
- );
262
+ const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(plan, gate, cloudflare, liveNamespaces);
223
263
  const derivedToml = derivePullRequestWranglerConfig(committedToml, {
224
264
  stage,
225
265
  accountId,
@@ -241,7 +281,7 @@ async function prepareFlatConfig(
241
281
  configPath: string,
242
282
  stage: DeploymentStage,
243
283
  accountId: string,
244
- missingSecrets: string[],
284
+ gate: SecretGate,
245
285
  cloudflare: CloudflareClient,
246
286
  ): Promise<PreparedConfig> {
247
287
  const flat = parseJsonFileText(configPath, await readFile(configPath, 'utf8'), parseFlatWranglerConfig);
@@ -254,12 +294,7 @@ async function prepareFlatConfig(
254
294
  }
255
295
  const liveNamespaces = await cloudflare.listKvNamespaces();
256
296
  const plan = planPullRequestBindings(flat, stage, liveNamespaces);
257
- const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(
258
- plan,
259
- missingSecrets,
260
- cloudflare,
261
- liveNamespaces,
262
- );
297
+ const { kvNamespaceIds, d1DatabaseIds } = await provisionPullRequestResources(plan, gate, cloudflare, liveNamespaces);
263
298
  const derived = derivePullRequestStageConfig(flat, { stage, accountId, kvNamespaceIds, d1DatabaseIds });
264
299
  // Beside the original: its main/assets/migrations paths are relative to the file.
265
300
  const temporaryConfigPath = join(dirname(configPath), `.wrangler.smoo-${process.pid}-${randomUUID()}.json`);
@@ -293,27 +328,58 @@ function migrationBindings(config: FlatWranglerConfig): string[] {
293
328
  .map((database) => database.binding);
294
329
  }
295
330
 
296
- 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>,
297
366
  cloudflare: CloudflareClient,
298
367
  workerName: string,
299
- missingSecrets: string[],
300
368
  ): Promise<void> {
301
- const firstDeployment = !(await cloudflare.listWorkerScripts()).some((script) => script.id === workerName);
302
- if (firstDeployment && missingSecrets.length > 0) {
303
- throw new Error(
304
- `First deployment of ${workerName} requires process environment values for: ${missingSecrets.join(', ')}.`,
305
- );
306
- }
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);
307
373
  }
308
374
 
309
- /** 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. */
310
376
  async function provisionPullRequestResources(
311
377
  plan: PullRequestResourcePlan,
312
- missingSecrets: string[],
378
+ gate: SecretGate,
313
379
  cloudflare: CloudflareClient,
314
380
  liveNamespaces: LiveKvNamespace[],
315
381
  ): Promise<{ kvNamespaceIds: Map<string, string>; d1DatabaseIds: Map<string, string> }> {
316
- await refuseFirstDeploymentWithoutSecrets(cloudflare, plan.workerName, missingSecrets);
382
+ await gate(plan.workerName);
317
383
  const kvNamespaceIds = await ensureKvNamespaces(plan.kvNamespaces, liveNamespaces, cloudflare);
318
384
  const d1DatabaseIds =
319
385
  plan.d1Databases.length === 0
@@ -578,49 +644,47 @@ export function nxTaskVersionTag(environment: NodeJS.ProcessEnv): string | undef
578
644
  throw new Error('NX_TASK_HASH must be canonical decimal digits or at least 32 hexadecimal characters.');
579
645
  }
580
646
 
581
- export function findVersionIdByTag(value: unknown, tag: string): string | null {
582
- if (Array.isArray(value)) {
583
- for (const entry of value) {
584
- const found = findVersionIdByTag(entry, tag);
585
- if (found) return found;
586
- }
587
- return null;
588
- }
589
- if (!isUnknownRecord(value)) return null;
590
- const annotations = isUnknownRecord(value.annotations) ? value.annotations : undefined;
591
- const metadata = isUnknownRecord(value.metadata) ? value.metadata : undefined;
592
- const annotationTag = annotations?.['workers/tag'];
593
- const candidateTag =
594
- typeof annotationTag === 'string' ? annotationTag : typeof value.tag === 'string' ? value.tag : metadata?.tag;
595
- if (candidateTag === tag) {
596
- if (typeof value.id === 'string') return value.id;
597
- if (typeof value.version_id === 'string') return value.version_id;
598
- }
599
- for (const nested of Object.values(value)) {
600
- const found = findVersionIdByTag(nested, tag);
601
- if (found) return found;
602
- }
603
- return null;
647
+ /** What a freshly activated version has to be true of, and where the fact gets recorded. */
648
+ interface DeployedVersionIdentity {
649
+ stage: DeploymentStage;
650
+ workerName: string;
651
+ accountId: string;
604
652
  }
605
653
 
606
- export function isFullCurrentDeployment(value: unknown, versionId: string): boolean {
607
- if (Array.isArray(value)) return value.some((entry) => isFullCurrentDeployment(entry, versionId));
608
- if (!isUnknownRecord(value)) return false;
609
- if (Array.isArray(value.versions)) {
610
- return (
611
- value.versions.length === 1 &&
612
- value.versions.some(
613
- (version) =>
614
- isUnknownRecord(version) &&
615
- (version.version_id === versionId || version.id === versionId) &&
616
- Number(version.percentage) === 100,
617
- )
618
- );
654
+ /**
655
+ * Turns "Cloudflare accepted the traffic shift" into "the new version answers".
656
+ *
657
+ * Without this the deploy step goes green while the edge still serves the previous version — the
658
+ * measured gap was about 20 s — so anything ordered after this deploy could call into code that
659
+ * has not shipped yet. The wait is what a `dependsOn: ["<other>:deploy"]` edge actually buys; an
660
+ * edge onto a step that returns early orders nothing.
661
+ */
662
+ async function confirmVersionIsLive(
663
+ probe: LiveVersionProbe,
664
+ versionTag: string,
665
+ identity: DeployedVersionIdentity,
666
+ options: DeployStageOptions,
667
+ dependencies: WranglerCommandDependencies,
668
+ cwd: string,
669
+ ): Promise<void> {
670
+ const wait = dependencies.wait ?? {};
671
+ const live = await awaitLiveVersion(probe, versionTag, wait);
672
+ if (!live.ok) throw new Error(`${identity.workerName}: ${live.error.message}`);
673
+ if (options.versionEndpoint) {
674
+ const answered = await awaitVersionEndpoint(options.versionEndpoint, versionTag, wait);
675
+ if (!answered.ok) throw new Error(`${identity.workerName}: ${answered.error.message}`);
619
676
  }
620
- return Object.values(value).some((entry) => isFullCurrentDeployment(entry, versionId));
677
+ const processEnv = dependencies.processEnv ?? process.env;
678
+ const directory = dependencies.liveVersionCacheDirectory ?? liveVersionCacheDirectory(cwd, processEnv);
679
+ await writeCachedLiveVersion(
680
+ directory,
681
+ { accountId: identity.accountId, workerName: identity.workerName, stage: identity.stage },
682
+ { versionTag, versionId: live.value.versionId, fetchedAt: Date.now() },
683
+ );
621
684
  }
622
685
 
623
- async function wranglerJson(runner: ProcessRunner, args: string[], run: ProcessRunOptions): Promise<unknown> {
686
+ /** One wrangler invocation whose stdout must be JSON; shared by the deploy and the read-only query. */
687
+ export async function wranglerJson(runner: ProcessRunner, args: string[], run: ProcessRunOptions): Promise<unknown> {
624
688
  const result = await wrangler(runner, args, run);
625
689
  try {
626
690
  return JSON.parse(result.stdout);
@@ -642,8 +706,3 @@ function requiredEnvironmentValue(value: string | undefined, name: string): stri
642
706
  if (!value) throw new Error(`${name} is required.`);
643
707
  return value;
644
708
  }
645
-
646
- function readSecretNames(cwd: string): string[] {
647
- const path = join(cwd, '.dev.vars.example');
648
- return existsSync(path) ? parseDevVarsExample(readFileSync(path, 'utf8')) : [];
649
- }
@@ -0,0 +1,150 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import type { ProcessResult, ProcessRunner, ProcessRunOptions } from './deploy-stage.js';
7
+ import { deployedVersion } from './deployed-version.js';
8
+ import { LIVE_VERSION_CACHE_TTL_MS, liveVersionCachePath, writeCachedLiveVersion } from './live-version.js';
9
+
10
+ const HASH = '16577780061662788004';
11
+ const FIXTURE = `[env.staging]
12
+ name = "fixture-worker-staging"
13
+ workers_dev = false
14
+
15
+ [env.staging.vars]
16
+ ENVIRONMENT = "staging"
17
+ `;
18
+
19
+ const roots: string[] = [];
20
+
21
+ afterEach(async () => {
22
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
23
+ });
24
+
25
+ class RecordingRunner implements ProcessRunner {
26
+ readonly calls: string[][] = [];
27
+
28
+ constructor(
29
+ private readonly versions: unknown = [{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }],
30
+ private readonly deployment: unknown = { versions: [{ version_id: 'version-1', percentage: 100 }] },
31
+ ) {}
32
+
33
+ async run(_command: string, args: string[], _options: ProcessRunOptions): Promise<ProcessResult> {
34
+ this.calls.push(args);
35
+ const value = args[0] === 'versions' && args[1] === 'list' ? this.versions : this.deployment;
36
+ return { exitCode: 0, stdout: JSON.stringify(value), stderr: '' };
37
+ }
38
+ }
39
+
40
+ async function fixture(): Promise<{ root: string; cacheDirectory: string }> {
41
+ const root = await mkdtemp(join(tmpdir(), 'smoo-deployed-version-'));
42
+ roots.push(root);
43
+ await writeFile(join(root, 'wrangler.toml'), FIXTURE);
44
+ return { root, cacheDirectory: join(root, 'cache') };
45
+ }
46
+
47
+ const credentials = { CLOUDFLARE_ACCOUNT_ID: 'account-1', CLOUDFLARE_API_TOKEN: 'token' };
48
+
49
+ describe('smoo wrangler deployed-version', () => {
50
+ it('reports the tag serving all traffic and remembers it', async () => {
51
+ const { root, cacheDirectory } = await fixture();
52
+ const runner = new RecordingRunner();
53
+
54
+ const report = await deployedVersion(
55
+ root,
56
+ { stage: 'staging' },
57
+ { runner, processEnv: credentials, cacheDirectory },
58
+ );
59
+
60
+ expect(report).toMatchObject({
61
+ workerName: 'fixture-worker-staging',
62
+ versionTag: `nx-${HASH}`,
63
+ source: 'cloudflare',
64
+ });
65
+ expect(runner.calls.map((args) => args.slice(0, 2))).toEqual([
66
+ ['deployments', 'status'],
67
+ ['versions', 'list'],
68
+ ]);
69
+ });
70
+
71
+ it('answers a second query from the cache without calling wrangler at all', async () => {
72
+ const { root, cacheDirectory } = await fixture();
73
+ const first = new RecordingRunner();
74
+ await deployedVersion(root, { stage: 'staging' }, { runner: first, processEnv: credentials, cacheDirectory });
75
+
76
+ const second = new RecordingRunner();
77
+ const report = await deployedVersion(
78
+ root,
79
+ { stage: 'staging' },
80
+ { runner: second, processEnv: credentials, cacheDirectory },
81
+ );
82
+
83
+ expect(report).toMatchObject({ versionTag: `nx-${HASH}`, source: 'cache' });
84
+ expect(second.calls).toEqual([]);
85
+ });
86
+
87
+ it('asks Cloudflare again once the entry is older than the TTL', async () => {
88
+ const { root, cacheDirectory } = await fixture();
89
+ await writeCachedLiveVersion(
90
+ cacheDirectory,
91
+ { accountId: 'account-1', workerName: 'fixture-worker-staging', stage: 'staging' },
92
+ { versionTag: 'nx-stale', versionId: 'version-0', fetchedAt: 0 },
93
+ );
94
+ const runner = new RecordingRunner();
95
+
96
+ const report = await deployedVersion(
97
+ root,
98
+ { stage: 'staging' },
99
+ { runner, processEnv: credentials, cacheDirectory, now: () => LIVE_VERSION_CACHE_TTL_MS + 1 },
100
+ );
101
+
102
+ expect(report).toMatchObject({ versionTag: `nx-${HASH}`, source: 'cloudflare' });
103
+ expect(runner.calls.length).toBe(2);
104
+ });
105
+
106
+ it('refuses without a credential and writes no placeholder for a later run to trust', async () => {
107
+ const { root, cacheDirectory } = await fixture();
108
+
109
+ await expect(
110
+ deployedVersion(
111
+ root,
112
+ { stage: 'staging' },
113
+ { runner: new RecordingRunner(), processEnv: { CLOUDFLARE_ACCOUNT_ID: 'account-1' }, cacheDirectory },
114
+ ),
115
+ ).rejects.toThrow('CLOUDFLARE_API_TOKEN is required.');
116
+ expect(
117
+ existsSync(
118
+ liveVersionCachePath(cacheDirectory, {
119
+ accountId: 'account-1',
120
+ workerName: 'fixture-worker-staging',
121
+ stage: 'staging',
122
+ }),
123
+ ),
124
+ ).toBe(false);
125
+ });
126
+
127
+ it('refuses to name a live version while traffic is split', async () => {
128
+ const { root, cacheDirectory } = await fixture();
129
+ const runner = new RecordingRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
130
+ versions: [
131
+ { version_id: 'version-1', percentage: 50 },
132
+ { version_id: 'version-2', percentage: 50 },
133
+ ],
134
+ });
135
+
136
+ await expect(
137
+ deployedVersion(root, { stage: 'staging' }, { runner, processEnv: credentials, cacheDirectory }),
138
+ ).rejects.toThrow('fixture-worker-staging (staging): no single worker version is serving 100% of traffic');
139
+ });
140
+
141
+ it('names the pull-request worker the deploy would target, without provisioning anything', async () => {
142
+ const { root, cacheDirectory } = await fixture();
143
+ const runner = new RecordingRunner();
144
+
145
+ const report = await deployedVersion(root, { stage: 'pr123' }, { runner, processEnv: credentials, cacheDirectory });
146
+
147
+ expect(report.workerName).toBe('fixture-worker-pr123');
148
+ expect(runner.calls.every((args) => args[0] === 'deployments' || args[0] === 'versions')).toBe(true);
149
+ });
150
+ });
@@ -0,0 +1,130 @@
1
+ // `smoo wrangler deployed-version --stage <stage>` — what is serving this project's worker right now.
2
+ //
3
+ // Read-only by construction: it resolves the worker name from the committed config and asks
4
+ // Cloudflare. It never provisions, never derives a pull-request stage's resources, and never
5
+ // writes anything except its own cache entry.
6
+
7
+ import { existsSync } from 'node:fs';
8
+ import { readFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { parseJsonFileText } from '../lib/json.js';
11
+ import { BunProcessRunner, type ProcessRunner, type ProcessRunOptions, wranglerJson } from './deploy-stage.js';
12
+ import { parseFlatWranglerConfig, planFlatStageResources } from './flat-config.js';
13
+ import {
14
+ LIVE_VERSION_CACHE_TTL_MS,
15
+ type LiveVersionProbe,
16
+ liveVersionCacheDirectory,
17
+ readCachedLiveVersion,
18
+ readLiveVersion,
19
+ writeCachedLiveVersion,
20
+ } from './live-version.js';
21
+ import {
22
+ type DeploymentStage,
23
+ isPullRequestStage,
24
+ parseDeploymentStage,
25
+ planConfiguredStageResources,
26
+ stageResourceName,
27
+ stagingWorkerBaseName,
28
+ } from './stage.js';
29
+
30
+ export interface DeployedVersionOptions {
31
+ /** `staging`, `production`, or `prN`. */
32
+ stage: string;
33
+ /** The build-generated flat wrangler.json the deploy would use, when the project deploys one. */
34
+ config?: string;
35
+ /** Ask Cloudflare even when a fresh cache entry exists. */
36
+ refresh?: boolean;
37
+ }
38
+
39
+ export interface DeployedVersionDependencies {
40
+ runner?: ProcessRunner;
41
+ processEnv?: NodeJS.ProcessEnv;
42
+ cacheDirectory?: string;
43
+ ttlMs?: number;
44
+ now?: () => number;
45
+ }
46
+
47
+ export interface DeployedVersionReport {
48
+ workerName: string;
49
+ stage: DeploymentStage;
50
+ versionTag: string | null;
51
+ versionId: string;
52
+ source: 'cloudflare' | 'cache';
53
+ }
54
+
55
+ /**
56
+ * The worker `stage` deploys to, from committed configuration alone.
57
+ *
58
+ * A pull-request stage's worker name is derived from the staging name the same way the deploy
59
+ * derives it, but WITHOUT the account listing the deploy needs for its KV/D1/R2 isolation: a
60
+ * question about what is live must not be able to create anything.
61
+ */
62
+ export async function stageWorkerName(cwd: string, stage: DeploymentStage, configPath?: string): Promise<string> {
63
+ if (configPath) {
64
+ const flat = parseJsonFileText(configPath, await readFile(configPath, 'utf8'), parseFlatWranglerConfig);
65
+ return isPullRequestStage(stage)
66
+ ? stageResourceName(stagingWorkerBaseName(flat.name), stage)
67
+ : planFlatStageResources(flat, stage).workerName;
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');
74
+ return isPullRequestStage(stage)
75
+ ? stageResourceName(stagingWorkerBaseName(planConfiguredStageResources(toml, 'staging').workerName), stage)
76
+ : planConfiguredStageResources(toml, stage).workerName;
77
+ }
78
+
79
+ /**
80
+ * Answers with the tag serving 100% of this worker's traffic, or refuses.
81
+ *
82
+ * Refusing is the whole contract. A missing credential, an API error, or a traffic split has no
83
+ * honest answer, and returning a placeholder such as `unknown` would be worse than the error: a
84
+ * later run comparing against that placeholder would read it as a match and conclude the desired
85
+ * version is already live. For the same reason nothing here is ever written to the cache except a
86
+ * real observation, and the answer never contains a timestamp or anything else that varies per
87
+ * invocation.
88
+ */
89
+ export async function deployedVersion(
90
+ cwd: string,
91
+ options: DeployedVersionOptions,
92
+ dependencies: DeployedVersionDependencies = {},
93
+ ): Promise<DeployedVersionReport> {
94
+ const stage = parseDeploymentStage(options.stage);
95
+ const processEnv = dependencies.processEnv ?? process.env;
96
+ const accountId = processEnv.CLOUDFLARE_ACCOUNT_ID;
97
+ if (!accountId) throw new Error('CLOUDFLARE_ACCOUNT_ID is required.');
98
+ if (!processEnv.CLOUDFLARE_API_TOKEN) throw new Error('CLOUDFLARE_API_TOKEN is required.');
99
+ const workerName = await stageWorkerName(cwd, stage, options.config);
100
+ const cacheDirectory = dependencies.cacheDirectory ?? liveVersionCacheDirectory(cwd, processEnv);
101
+ const key = { accountId, workerName, stage };
102
+ const ttlMs = dependencies.ttlMs ?? LIVE_VERSION_CACHE_TTL_MS;
103
+ const now = dependencies.now ?? Date.now;
104
+ if (options.refresh !== true) {
105
+ const cached = await readCachedLiveVersion(cacheDirectory, key, ttlMs, now);
106
+ if (cached) {
107
+ return { workerName, stage, versionTag: cached.versionTag, versionId: cached.versionId, source: 'cache' };
108
+ }
109
+ }
110
+ const runner = dependencies.runner ?? new BunProcessRunner();
111
+ const run: ProcessRunOptions = { cwd, unsetEnv: ['CLOUDFLARE_ENV'] };
112
+ const probe: LiveVersionProbe = {
113
+ deployments: () => wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run),
114
+ versions: () => wranglerJson(runner, ['versions', 'list', '--name', workerName, '--json'], run),
115
+ };
116
+ const live = await readLiveVersion(probe);
117
+ if (!live.ok) throw new Error(`${workerName} (${stage}): ${live.error.message}`);
118
+ await writeCachedLiveVersion(cacheDirectory, key, {
119
+ versionTag: live.value.tag,
120
+ versionId: live.value.versionId,
121
+ fetchedAt: now(),
122
+ });
123
+ return {
124
+ workerName,
125
+ stage,
126
+ versionTag: live.value.tag,
127
+ versionId: live.value.versionId,
128
+ source: 'cloudflare',
129
+ };
130
+ }