@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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `smoo secrets` — the operator side of the reconciliation in ./index.ts.
2
+ * `smoo secrets` — the operator side of the reconciliation in ./status.ts.
3
3
  *
4
4
  * Values are handed to `gh` over stdin, never through argv (a process list is
5
5
  * world-readable) and never printed. Reading one from the terminal disables
@@ -18,19 +18,25 @@
18
18
  import { spawn, spawnSync } from 'node:child_process';
19
19
  import { getWorkspacePackages } from '../lib/workspace.js';
20
20
  import {
21
- environmentsMissing,
22
21
  fetchLocalSecret,
23
- localSecretCommandNames,
24
- reconcileSecrets,
25
- type SecretRow,
22
+ localSecretGroups,
26
23
  secretNameMapping,
27
- unsatisfiedSecrets,
28
- unwiredSecrets,
29
24
  workerSecretNames,
25
+ workerSecretStages,
30
26
  workflowEnvironments,
31
27
  workflowSecretNames,
32
28
  } from './index.js';
33
29
  import { resolveRepository } from './repository.js';
30
+ import {
31
+ type LocalSecret,
32
+ projectSecretsStatus,
33
+ reconcileSecrets,
34
+ type SecretRow,
35
+ type SecretsStatusDocument,
36
+ type SecretsStatusEnvironment,
37
+ stringifySecretsStatusDocument,
38
+ unwiredSecrets,
39
+ } from './status.js';
34
40
 
35
41
  /**
36
42
  * Every secret this checkout declares that the repository does not hold, in
@@ -129,21 +135,25 @@ export async function writeRepositorySecret(
129
135
 
130
136
  function collectSources(
131
137
  root: string,
138
+ workspaceDirs: readonly string[],
139
+ localSecrets: readonly LocalSecret[],
132
140
  repositorySecrets: readonly string[],
133
141
  environmentSecrets: Readonly<Record<string, readonly string[]>> = {},
134
142
  ) {
135
- const workspaceDirs = getWorkspacePackages(root).map((pkg) => pkg.path);
136
143
  const workerSecrets = workerSecretNames(root, workspaceDirs);
137
144
  const workflowSecrets = workflowSecretNames(root);
138
- const localCommands = localSecretCommandNames(root);
139
145
  const envNames = [
140
- ...new Set([...Object.values(workerSecrets).flatMap((names) => [...names]), ...workflowSecrets, ...localCommands]),
146
+ ...new Set([
147
+ ...Object.values(workerSecrets).flatMap((names) => [...names]),
148
+ ...workflowSecrets,
149
+ ...localSecrets.map((secret) => secret.name),
150
+ ]),
141
151
  ];
142
152
  return {
143
153
  workerSecrets,
144
154
  workflowSecrets,
145
155
  secretNames: secretNameMapping(root, envNames),
146
- localCommands,
156
+ localSecrets,
147
157
  repositorySecrets,
148
158
  environmentSecrets,
149
159
  };
@@ -167,46 +177,96 @@ function describe(row: SecretRow, scopeWidth: number): string {
167
177
  }
168
178
 
169
179
  /**
170
- * Print every known secret and the scopes holding it. Exit code 1 when a
171
- * workflow promises a value no scope a bound job reads can supply — that
172
- * combination is the one that fails a deploy, and it fails talking about the
173
- * value rather than the missing secret.
180
+ * Everything `status` reports, read from git, `gh` and the checkout before a
181
+ * single line is printed. Gathering ahead of rendering is what lets one run
182
+ * answer either a human or a machine from exactly the same facts: `--json`
183
+ * cannot drift from the table because there is nothing for it to drift from.
174
184
  */
175
- export function secretsStatus(root: string, options: { repo?: string; env?: string }): number {
185
+ async function gatherSecretsStatus(
186
+ root: string,
187
+ options: { repo?: string; env?: string },
188
+ ): Promise<{ ok: true; document: SecretsStatusDocument } | { ok: false; reason: string }> {
176
189
  const resolved = resolveRepository(root, options.repo);
177
- if (!resolved.ok) {
178
- console.error(resolved.reason);
179
- return 1;
180
- }
190
+ if (!resolved.ok) return resolved;
181
191
  const { repo, source } = resolved.choice;
182
192
  const repositorySecrets = readRepositorySecrets(repo);
183
- if (!repositorySecrets.ok) {
184
- console.error(repositorySecrets.reason);
185
- return 1;
186
- }
193
+ if (!repositorySecrets.ok) return repositorySecrets;
187
194
  // The workflows decide which environments a job reads; an explicitly asked
188
195
  // for one is shown too, so an operator can inspect a scope before a workflow
189
196
  // binds it.
190
197
  const boundEnvironments = workflowEnvironments(root);
191
198
  const asked = options.env !== undefined && !boundEnvironments.includes(options.env) ? [options.env] : [];
192
199
  const environmentSecrets: Record<string, string[]> = {};
193
- const unreadable: { environment: string; reason: string }[] = [];
194
- for (const environment of [...boundEnvironments, ...asked]) {
195
- const held = readRepositorySecrets(repo, environment);
196
- if (held.ok) environmentSecrets[environment] = held.names;
197
- else unreadable.push({ environment, reason: held.reason });
200
+ const environments: SecretsStatusEnvironment[] = [];
201
+ for (const name of [...boundEnvironments, ...asked]) {
202
+ const bound = boundEnvironments.includes(name);
203
+ const held = readRepositorySecrets(repo, name);
204
+ // An environment that cannot be read is reported, never assumed empty:
205
+ // guessing produces a refusal about a value that may well be set.
206
+ if (!held.ok) {
207
+ environments.push({ name, bound, readable: false, reason: held.reason });
208
+ continue;
209
+ }
210
+ environmentSecrets[name] = held.names;
211
+ environments.push({ name, bound, readable: true, secretCount: held.names.length });
198
212
  }
199
- // An environment that cannot be read is reported, never assumed empty:
200
- // guessing produces a refusal about a value that may well be set.
201
- const readable = boundEnvironments.filter((environment) => environment in environmentSecrets);
202
- const rows = reconcileSecrets(collectSources(root, repositorySecrets.names, environmentSecrets));
213
+ const workspaceDirs = getWorkspacePackages(root).map((pkg) => pkg.path);
214
+ const stageScopes = workerSecretStages(root, workspaceDirs);
215
+ if (!stageScopes.ok) return stageScopes;
216
+ // A `smoo.secrets` declaration smoo cannot read is a refusal, not an empty
217
+ // list: reporting "nothing is fetchable locally" for a manifest that says
218
+ // otherwise is the confident wrong answer.
219
+ const localSecrets = await localSecretGroups(root);
220
+ if (!localSecrets.ok) return localSecrets;
221
+ return {
222
+ ok: true,
223
+ document: projectSecretsStatus({
224
+ repository: { repo, source, secretCount: repositorySecrets.names.length },
225
+ environments,
226
+ rows: reconcileSecrets(
227
+ collectSources(root, workspaceDirs, localSecrets.secrets, repositorySecrets.names, environmentSecrets),
228
+ ),
229
+ stageScopes: stageScopes.byWorker,
230
+ }),
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Print every known secret and the scopes holding it, or - with `json` - the
236
+ * same facts as one document on stdout and nothing else. Exit code 1 when a
237
+ * workflow promises a value no scope a bound job reads can supply — that
238
+ * combination is the one that fails a deploy, and it fails talking about the
239
+ * value rather than the missing secret. `--json` reports that refusal in
240
+ * `unsatisfied` and still exits 1: a machine-readable status that always
241
+ * succeeded would be a status nobody could gate on.
242
+ */
243
+ export async function secretsStatus(
244
+ root: string,
245
+ options: { repo?: string; env?: string; json?: boolean },
246
+ ): Promise<number> {
247
+ const gathered = await gatherSecretsStatus(root, options);
248
+ if (!gathered.ok) {
249
+ console.error(gathered.reason);
250
+ return 1;
251
+ }
252
+ const { document } = gathered;
253
+ const { repo, source } = document.repository;
254
+ if (options.json) {
255
+ console.log(stringifySecretsStatusDocument(document));
256
+ return document.unsatisfied.length > 0 ? 1 : 0;
257
+ }
258
+ const rows = document.secrets;
203
259
 
204
- console.log(`repository ${repo} (${source}), holding ${repositorySecrets.names.length} secrets`);
205
- for (const [environment, names] of Object.entries(environmentSecrets)) {
206
- console.log(`environment ${environment}, holding ${names.length} secrets`);
260
+ console.log(`repository ${repo} (${source}), holding ${document.repository.secretCount} secrets`);
261
+ for (const environment of document.environments) {
262
+ if (environment.readable)
263
+ console.log(`environment ${environment.name}, holding ${environment.secretCount} secrets`);
207
264
  }
208
- for (const { environment, reason } of unreadable) {
209
- console.log(`environment ${environment} could not be read, so nothing below claims what it holds: ${reason}`);
265
+ for (const environment of document.environments) {
266
+ if (environment.readable) continue;
267
+ console.log(
268
+ `environment ${environment.name} could not be read, so nothing below claims what it holds: ${environment.reason}`,
269
+ );
210
270
  }
211
271
  const scopeWidth = Math.max('held by'.length, ...rows.map((row) => heldBy(row).length));
212
272
  console.log(`${'held by'.padEnd(scopeWidth)} ${'name'.padEnd(32)} workflow local declared by`);
@@ -222,6 +282,25 @@ export function secretsStatus(root: string, options: { repo?: string; env?: stri
222
282
  );
223
283
  }
224
284
  }
285
+ // A declared `group` that disagrees with what this repository's own
286
+ // declarations derive is the override working — and a silent override is
287
+ // how the next reader loses an hour wondering why a `.npmrc` credential
288
+ // resolves at shell entry. It is stated here, where a human is already
289
+ // looking at every declared secret.
290
+ const overridden = rows.filter(
291
+ (row) => row.localGroup !== undefined && row.localGroup.resolves !== row.localGroup.derived,
292
+ );
293
+ if (overridden.length > 0) {
294
+ console.log('');
295
+ for (const row of overridden) {
296
+ const local = row.localGroup;
297
+ if (local === undefined) continue;
298
+ console.log(
299
+ `note: ${row.name} declares group \`${local.resolves}\`, overriding the \`${local.derived}\` this ` +
300
+ `repository's declarations derive; \`smoo secrets run ${local.resolves} <command>\` is what resolves it.`,
301
+ );
302
+ }
303
+ }
225
304
  // A value an environment holds is not a value to duplicate at repository
226
305
  // scope: offering it invites two sources of truth for one credential, and
227
306
  // the table above already shows where it lives. Only a name no scope holds
@@ -236,22 +315,20 @@ export function secretsStatus(root: string, options: { repo?: string; env?: stri
236
315
  console.log(`set one: smoo secrets set ${needed[0]?.repositorySecret ?? 'NAME'} -R ${repo}`);
237
316
  }
238
317
 
239
- const unsatisfied = unsatisfiedSecrets(rows, readable);
240
- if (unsatisfied.length === 0) return 0;
318
+ if (document.unsatisfied.length === 0) return 0;
241
319
  console.log('');
242
- for (const row of unsatisfied) {
243
- const missing = environmentsMissing(row, readable);
244
- const scopes = missing.length > 0 ? missing.join(', ') : 'the repository';
245
- console.error(`missing: ${row.name} a workflow passes secrets.${row.name} and no value exists in ${scopes}.`);
246
- if (missing.length === 0) {
247
- console.error(` smoo secrets set ${row.repositorySecret} -R ${repo}`);
320
+ for (const { name, repositorySecret, missingIn } of document.unsatisfied) {
321
+ const scopes = missingIn.length > 0 ? missingIn.join(', ') : 'the repository';
322
+ console.error(`missing: ${name} a workflow passes secrets.${name} and no value exists in ${scopes}.`);
323
+ if (missingIn.length === 0) {
324
+ console.error(` smoo secrets set ${repositorySecret} -R ${repo}`);
248
325
  continue;
249
326
  }
250
- for (const environment of missing) {
327
+ for (const environment of missingIn) {
251
328
  const named = environment.includes(' ') ? `'${environment}'` : environment;
252
- console.error(` smoo secrets set ${row.repositorySecret} -R ${repo} --env ${named}`);
329
+ console.error(` smoo secrets set ${repositorySecret} -R ${repo} --env ${named}`);
253
330
  }
254
- console.error(` or one value for every environment: smoo secrets set ${row.repositorySecret} -R ${repo}`);
331
+ console.error(` or one value for every environment: smoo secrets set ${repositorySecret} -R ${repo}`);
255
332
  }
256
333
  return 1;
257
334
  }
@@ -318,7 +395,13 @@ export async function secretsSet(
318
395
  }
319
396
  environmentSecrets[environment] = inEnvironment.names;
320
397
  }
321
- const rows = reconcileSecrets(collectSources(root, held.names, environmentSecrets));
398
+ const workspaceDirs = getWorkspacePackages(root).map((pkg) => pkg.path);
399
+ const local = await localSecretGroups(root);
400
+ if (!local.ok) {
401
+ console.error(local.reason);
402
+ return 1;
403
+ }
404
+ const rows = reconcileSecrets(collectSources(root, workspaceDirs, local.secrets, held.names, environmentSecrets));
322
405
  const needed =
323
406
  environment === undefined ? secretsNeedingValues(rows) : secretsMissingInEnvironment(rows, environment);
324
407
  const target =
@@ -386,7 +469,15 @@ export async function secretsSync(root: string, options: { repo?: string; env?:
386
469
  }
387
470
  const { repo } = resolved.choice;
388
471
  const environment = options.env;
389
- const names = localSecretCommandNames(root);
472
+ const local = await localSecretGroups(root);
473
+ if (!local.ok) {
474
+ console.error(local.reason);
475
+ return 1;
476
+ }
477
+ // Every group: a repository secret store holds the credential whatever
478
+ // resolves it locally, and a `smoo secrets run` group is about which
479
+ // command pays the provider prompt, not about which values CI needs.
480
+ const names = local.secrets.map((secret) => secret.name);
390
481
  if (names.length === 0) {
391
482
  console.error('smoo.secrets declares no fetch commands; nothing to sync.');
392
483
  return 1;
@@ -3,14 +3,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { repositorySecretMapping } from '../lib/secret-names.js';
6
- import {
7
- environmentsMissing,
8
- reconcileSecrets,
9
- type SecretRow,
10
- unsatisfiedSecrets,
11
- unwiredSecrets,
12
- workflowEnvironments,
13
- } from './index.js';
6
+ import { workflowEnvironments } from './index.js';
7
+ import { environmentsMissing, reconcileSecrets, type SecretRow, unsatisfiedSecrets, unwiredSecrets } from './status.js';
14
8
 
15
9
  const sources = {
16
10
  workerSecrets: {
@@ -18,7 +12,7 @@ const sources = {
18
12
  'targets/mail': ['MAIL_CAPTURE_CONTROL_TOKEN'],
19
13
  },
20
14
  workflowSecrets: ['MAIL_CAPTURE_CONTROL_TOKEN', 'STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY'],
21
- localCommands: ['NPM_READ_TOKEN'],
15
+ localSecrets: [{ name: 'NPM_READ_TOKEN', group: 'registry', derivedGroup: 'registry' }],
22
16
  secretNames: {
23
17
  STRIPE_PUBLISHABLE_KEY: 'STRIPE_PUBLISHABLE_KEY',
24
18
  STRIPE_SECRET_KEY: 'STRIPE_SECRET_KEY',
@@ -84,7 +78,7 @@ describe('secret reconciliation', () => {
84
78
  workerSecrets: { 'targets/backend': ['GITHUB_CLIENT_SECRET'] },
85
79
  workflowSecrets: ['GITHUB_CLIENT_SECRET'],
86
80
  secretNames: repositorySecretMapping(['GITHUB_CLIENT_SECRET'], 'acme'),
87
- localCommands: [],
81
+ localSecrets: [],
88
82
  repositorySecrets: ['ACME_GITHUB_CLIENT_SECRET'],
89
83
  });
90
84
 
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Reconciles the three places a repository's secrets are described, all of
3
- * which smoo already owns:
2
+ * Reads the three places a repository's secrets are described, all of which
3
+ * smoo already owns:
4
4
  *
5
5
  * 1. **Workers declare names.** `.dev.vars.example` per wrangler project — the
6
- * file `wrangler types --env-file` reads and `prepare-env` prompts from.
6
+ * file `wrangler types --env-file` reads and `prepare-env` prompts from
7
+ * and `smoo.wrangler.secretStages` beside it says which stages each name
8
+ * belongs to.
7
9
  * 2. **Workflows declare where values come from.** `smoo.github.deploySecrets`
8
10
  * and `e2eSecrets` map an env name to a repository secret, and the managed
9
11
  * workflows render exactly those into the job environment.
@@ -17,6 +19,9 @@
17
19
  * `STRIPE_PUBLISHABLE_KEY`, `ci.yml` passes `secrets.STRIPE_PUBLISHABLE_KEY`,
18
20
  * the repository holds no such secret, and the empty value surfaces as the
19
21
  * payment library's own `publishable_key_mismatch` refusal.
22
+ *
23
+ * The join itself is ./status.ts: pure, and the owner of the document type
24
+ * these readings are projected into.
20
25
  */
21
26
 
22
27
  import { spawnSync } from 'node:child_process';
@@ -25,114 +30,8 @@ import { join } from 'node:path';
25
30
  import { repositoryOwnerFromUrl, repositorySecretMapping } from '../lib/secret-names.js';
26
31
  import { readPackageJsonObject, repositoryInfo } from '../lib/workspace.js';
27
32
  import { parseDevVarsExample } from '../wrangler/prepare-env.js';
28
-
29
- /** Where an env name is described, which repository secret carries it, and whether that exists. */
30
- export interface SecretRow {
31
- name: string;
32
- /** The repository secret this env name reads from, by convention or declaration. */
33
- repositorySecret: string;
34
- /** Wrangler projects whose `.dev.vars.example` declares it. */
35
- declaredByWorkers: string[];
36
- /** True when a managed workflow renders `secrets.<name>` into a job. */
37
- suppliedByWorkflow: boolean;
38
- /** True when `smoo.secrets` can fetch it for a developer shell. */
39
- fetchableLocally: boolean;
40
- /** True when the repository holds a secret of this name. */
41
- onRepository: boolean;
42
- /**
43
- * GitHub Environments holding their own value for this name. A job bound to
44
- * an environment reads that value in preference to the repository's, which
45
- * is how one name carries test credentials on a preview stage and live ones
46
- * in production.
47
- */
48
- heldByEnvironment: string[];
49
- }
50
-
51
- export interface SecretSources {
52
- /** Worker label -> env names it declares. */
53
- workerSecrets: Record<string, readonly string[]>;
54
- /** Env names a managed workflow passes into a job. */
55
- workflowSecrets: readonly string[];
56
- /** Env name -> repository secret, by convention with declared exceptions. */
57
- secretNames: Readonly<Record<string, string>>;
58
- /** Env names `smoo.secrets` can fetch locally. */
59
- localCommands: readonly string[];
60
- /** Repository secret names GitHub currently holds. */
61
- repositorySecrets: readonly string[];
62
- /** Environment name -> secret names that environment holds. */
63
- environmentSecrets?: Readonly<Record<string, readonly string[]>>;
64
- }
65
-
66
- /**
67
- * One row per name known to any source, sorted, so a reader sees the whole
68
- * picture rather than one source's view of it.
69
- */
70
- export function reconcileSecrets(sources: SecretSources): SecretRow[] {
71
- const declaredByAnyWorker: string[] = Object.values(sources.workerSecrets).flatMap((names) => [...names]);
72
- const names = new Set<string>([...declaredByAnyWorker, ...sources.workflowSecrets, ...sources.localCommands]);
73
- // A repository secret that already carries a known env name is that name's
74
- // row, not a row of its own: listing ACME_GITHUB_CLIENT_SECRET beside
75
- // GITHUB_CLIENT_SECRET would report one value as two secrets, one of them
76
- // permanently "declared by nothing".
77
- const carriesKnownEnvName = new Set(
78
- names.size > 0 ? [...names].map((name) => sources.secretNames[name] ?? name) : [],
79
- );
80
- // A value only an environment holds is still a value: leaving it out of the
81
- // rows is how an operator ends up hunting for a secret that is already set.
82
- const addUndeclared = (secret: string): void => {
83
- if (!carriesKnownEnvName.has(secret)) names.add(secret);
84
- };
85
- for (const secret of sources.repositorySecrets) addUndeclared(secret);
86
- for (const held of Object.values(sources.environmentSecrets ?? {})) {
87
- for (const secret of held) addUndeclared(secret);
88
- }
89
- return [...names]
90
- .sort((left, right) => left.localeCompare(right))
91
- .map((name) => ({
92
- name,
93
- repositorySecret: sources.secretNames[name] ?? name,
94
- declaredByWorkers: Object.entries(sources.workerSecrets)
95
- .filter(([, declared]) => declared.includes(name))
96
- .map(([label]) => label)
97
- .sort((left, right) => left.localeCompare(right)),
98
- suppliedByWorkflow: sources.workflowSecrets.includes(name),
99
- fetchableLocally: sources.localCommands.includes(name),
100
- onRepository: sources.repositorySecrets.includes(sources.secretNames[name] ?? name),
101
- heldByEnvironment: Object.entries(sources.environmentSecrets ?? {})
102
- .filter(([, held]) => held.includes(sources.secretNames[name] ?? name))
103
- .map(([environment]) => environment)
104
- .sort((left, right) => left.localeCompare(right)),
105
- }));
106
- }
107
-
108
- /**
109
- * The rows a CI deploy cannot satisfy: a Worker declares the name, a workflow
110
- * promises to pass it, and the repository has no value to pass. Reported
111
- * separately from "declared but not wired into any workflow", because the
112
- * remedies differ — set a secret, versus declare it in `smoo.github`.
113
- */
114
- export function unsatisfiedSecrets(rows: readonly SecretRow[], boundEnvironments: readonly string[] = []): SecretRow[] {
115
- return rows.filter((row) => {
116
- if (!row.suppliedByWorkflow) return false;
117
- if (row.onRepository) return false;
118
- // With no environment bound, the repository is the only scope a job reads.
119
- // With environments bound, each one can carry the value instead - so the
120
- // name is satisfied only when every bound environment holds it.
121
- if (boundEnvironments.length === 0) return true;
122
- return !boundEnvironments.every((environment) => row.heldByEnvironment.includes(environment));
123
- });
124
- }
125
-
126
- /** Bound environments that lack their own value and cannot fall back to the repository. */
127
- export function environmentsMissing(row: SecretRow, boundEnvironments: readonly string[]): string[] {
128
- if (row.onRepository) return [];
129
- return boundEnvironments.filter((environment) => !row.heldByEnvironment.includes(environment));
130
- }
131
-
132
- /** Worker-declared names no managed workflow passes: a CI deploy will run without them. */
133
- export function unwiredSecrets(rows: readonly SecretRow[]): SecretRow[] {
134
- return rows.filter((row) => row.declaredByWorkers.length > 0 && !row.suppliedByWorkflow);
135
- }
33
+ import { readSecretStageMap, type SecretStageMap } from '../wrangler/stage-secrets.js';
34
+ import { type GroupedSecret, readSecretGroups } from './resolver.js';
136
35
 
137
36
  /** Env names the managed workflows pass into a job, from the declarations that render them. */
138
37
  export function workflowSecretNames(root: string): string[] {
@@ -250,10 +149,50 @@ export function workerSecretNames(root: string, workspaceDirs: readonly string[]
250
149
  return byWorker;
251
150
  }
252
151
 
253
- /** Names `smoo.secrets` declares a fetch command for. */
254
- export function localSecretCommandNames(root: string): string[] {
255
- const manifest = readPackageJsonObject(join(root, 'package.json'));
256
- return Object.keys(manifest?.smoo?.secrets ?? {}).sort((left, right) => left.localeCompare(right));
152
+ /**
153
+ * Every wrangler project's `smoo.wrangler.secretStages`, keyed by the project
154
+ * directory — the same keys `workerSecretNames` uses, so a name's scopes and
155
+ * its declarations are joined on one label.
156
+ *
157
+ * A malformed block is a refusal rather than a throw: it is the same class of
158
+ * problem as a repository `gh` will not talk to, and `smoo secrets status`
159
+ * reports those instead of dying with a stack. Silently reading it as "no
160
+ * scopes" is the one thing this must not do — that is the direction where
161
+ * every secret quietly reaches every stage.
162
+ */
163
+ export function workerSecretStages(
164
+ root: string,
165
+ workspaceDirs: readonly string[],
166
+ ): { ok: true; byWorker: Record<string, SecretStageMap> } | { ok: false; reason: string } {
167
+ const byWorker: Record<string, SecretStageMap> = {};
168
+ for (const dir of workspaceDirs) {
169
+ if (!existsSync(join(root, dir, '.dev.vars.example'))) continue;
170
+ try {
171
+ const scopes = readSecretStageMap(join(root, dir));
172
+ if (Object.keys(scopes).length > 0) byWorker[dir] = scopes;
173
+ } catch (error) {
174
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
175
+ }
176
+ }
177
+ return { ok: true, byWorker };
178
+ }
179
+
180
+ /**
181
+ * Every `smoo.secrets` entry with the group that resolves it, sorted by name,
182
+ * or a refusal naming the declaration that could not be read. The groups come
183
+ * from ./resolver.ts — the same file shell entry routes with — so a status
184
+ * table and a `smoo secrets run` cannot disagree about which group resolves
185
+ * a name.
186
+ */
187
+ export async function localSecretGroups(
188
+ root: string,
189
+ ): Promise<{ ok: true; secrets: GroupedSecret[] } | { ok: false; reason: string }> {
190
+ try {
191
+ const secrets = await readSecretGroups(root);
192
+ return { ok: true, secrets: secrets.sort((left, right) => left.name.localeCompare(right.name)) };
193
+ } catch (error) {
194
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
195
+ }
257
196
  }
258
197
 
259
198
  /**
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The bootstrap secret resolver, as the CLI sees it.
3
+ *
4
+ * `managed/raw/tooling/direnv/secret-references.ts` holds the one
5
+ * implementation of THE RULE — which group resolves where, and what CI and a
6
+ * cowshed workspace refuse instead. A managed repository runs the copy smoo
7
+ * writes into its own `tooling/direnv/secret-references.ts` at shell entry;
8
+ * `smoo secrets run` runs THIS package's copy of the same file. One
9
+ * implementation, so the command and the shell cannot disagree about a group.
10
+ *
11
+ * It is loaded, not statically imported, and that is forced rather than
12
+ * chosen: the file is a raw bootstrap script that must load before any
13
+ * workspace package and before the Typia transform exists, so it lives
14
+ * outside `src` — outside this package's compiled `rootDir`, which a static
15
+ * import may not cross. A dynamic `import()` reaches it in both layouts
16
+ * (`src/secrets/` and `dist/secrets/` are each two directories below the
17
+ * package root), and typia validates what comes back: the same boundary
18
+ * ../monorepo/packed-package.ts puts around a foreign module. `require` is
19
+ * not the alternative — under a Bun loader plugin, which this package's own
20
+ * tests preload, every module is async and `require` of one throws.
21
+ */
22
+
23
+ import { fileURLToPath, pathToFileURL } from 'node:url';
24
+ import typia from 'typia';
25
+
26
+ /** A declared `smoo.secrets` entry with its group settled. */
27
+ export interface GroupedSecret {
28
+ name: string;
29
+ /** The group that resolves it: the declared one when stated, `derivedGroup` otherwise. */
30
+ group: string;
31
+ /**
32
+ * What the repository's own declarations imply: `registry` for a variable
33
+ * `.npmrc` interpolates, `nx-cache` for the declared cache token, `shell`
34
+ * for everything else. Differs from `group` exactly when an entry overrides
35
+ * the derivation.
36
+ */
37
+ derivedGroup: string;
38
+ }
39
+
40
+ /** A declared secret a request declined to resolve, and how to supply it. */
41
+ export interface DeferredSecret {
42
+ name: string;
43
+ group: string;
44
+ guidance: string;
45
+ }
46
+
47
+ /** What belongs in a child's environment, and what was deliberately left out of it. */
48
+ export interface SecretResolution {
49
+ values: Record<string, string>;
50
+ deferred: DeferredSecret[];
51
+ }
52
+
53
+ interface SecretReferencesModule {
54
+ readSecretGroups?: unknown;
55
+ resolveSecretEnvironment?: unknown;
56
+ }
57
+
58
+ const isSecretReferencesModule = typia.createIs<SecretReferencesModule>();
59
+ const isGroupedSecrets = typia.createIs<GroupedSecret[]>();
60
+ const isSecretResolution = typia.createIs<SecretResolution>();
61
+
62
+ const RESOLVER_PATH = fileURLToPath(new URL('../../managed/raw/tooling/direnv/secret-references.ts', import.meta.url));
63
+
64
+ interface SecretReferences {
65
+ readSecretGroups: (root: string) => unknown;
66
+ resolveSecretEnvironment: (options: { root: string; group: string }) => unknown;
67
+ }
68
+
69
+ /**
70
+ * A loaded export, narrowed to the signature this file calls it with. typia
71
+ * validates data, not functions — it reports a module missing both of these
72
+ * as valid — so presence is checked here, and what each one RETURNS is
73
+ * validated below, where it is data again.
74
+ */
75
+ function isSecretGroupsReader(value: unknown): value is SecretReferences['readSecretGroups'] {
76
+ return typeof value === 'function';
77
+ }
78
+
79
+ function isSecretGroupResolver(value: unknown): value is SecretReferences['resolveSecretEnvironment'] {
80
+ return typeof value === 'function';
81
+ }
82
+
83
+ let loaded: SecretReferences | undefined;
84
+
85
+ async function secretReferences(): Promise<SecretReferences> {
86
+ if (loaded !== undefined) {
87
+ return loaded;
88
+ }
89
+ // Dynamic by necessity: the target is a managed raw script outside this
90
+ // package's compiled rootDir, so no static import can name it.
91
+ const imported: unknown = await import(pathToFileURL(RESOLVER_PATH).href);
92
+ if (!isSecretReferencesModule(imported)) {
93
+ throw new Error(`${RESOLVER_PATH} did not load as a module`);
94
+ }
95
+ const { readSecretGroups: read, resolveSecretEnvironment: resolve } = imported;
96
+ if (!isSecretGroupsReader(read) || !isSecretGroupResolver(resolve)) {
97
+ throw new Error(`${RESOLVER_PATH} does not expose the expected secret resolver API`);
98
+ }
99
+ loaded = { readSecretGroups: read, resolveSecretEnvironment: resolve };
100
+ return loaded;
101
+ }
102
+
103
+ /**
104
+ * Every `smoo.secrets` entry of the repository at `root`, each with the group
105
+ * that resolves it. Rejects with the offending declaration named — never a
106
+ * value — when the manifest or `.npmrc` cannot be read.
107
+ */
108
+ export async function readSecretGroups(root: string): Promise<GroupedSecret[]> {
109
+ const references = await secretReferences();
110
+ const groups: unknown = references.readSecretGroups(root);
111
+ if (!isGroupedSecrets(groups)) {
112
+ throw new Error(`${RESOLVER_PATH} returned an unexpected group listing`);
113
+ }
114
+ return groups;
115
+ }
116
+
117
+ /**
118
+ * Resolve exactly one group's secrets for one child process. Everything
119
+ * outside the group comes back deferred rather than resolved, so a run for
120
+ * one group never triggers another group's provider command. Rejects with the
121
+ * resolver's aggregated refusal, which names variables and groups only.
122
+ */
123
+ export async function resolveSecretGroup(root: string, group: string): Promise<SecretResolution> {
124
+ const references = await secretReferences();
125
+ const resolution: unknown = await references.resolveSecretEnvironment({ root, group });
126
+ if (!isSecretResolution(resolution)) {
127
+ throw new Error(`${RESOLVER_PATH} returned an unexpected resolution`);
128
+ }
129
+ return resolution;
130
+ }