@smoothbricks/cli 0.11.19 → 0.11.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) 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 -1
  14. package/dist/nx/index.d.ts +6 -0
  15. package/dist/nx/index.d.ts.map +1 -1
  16. package/dist/nx/index.js +14 -0
  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/packs/index.ts +10 -1
  60. package/src/monorepo/secret-references.test.ts +412 -6
  61. package/src/monorepo/setup-environment.test.ts +181 -0
  62. package/src/nx/index.test.ts +8 -0
  63. package/src/nx/index.ts +20 -0
  64. package/src/secrets/commands.test.ts +186 -4
  65. package/src/secrets/commands.ts +142 -51
  66. package/src/secrets/index.test.ts +4 -10
  67. package/src/secrets/index.ts +54 -115
  68. package/src/secrets/resolver.ts +130 -0
  69. package/src/secrets/run.test.ts +359 -0
  70. package/src/secrets/run.ts +148 -0
  71. package/src/secrets/status.test.ts +164 -0
  72. package/src/secrets/status.ts +297 -0
  73. package/src/wrangler/deploy-stage.test.ts +95 -2
  74. package/src/wrangler/deploy-stage.ts +26 -23
  75. package/src/wrangler/deployed-version.ts +7 -11
  76. package/src/wrangler/flat-config.test.ts +9 -4
  77. package/src/wrangler/flat-config.ts +1 -20
  78. package/src/wrangler/format-parity.test.ts +433 -0
  79. package/src/wrangler/prepare-env.ts +7 -5
  80. package/src/wrangler/source-config.test.ts +102 -0
  81. package/src/wrangler/source-config.ts +110 -0
  82. package/src/wrangler/stage.test.ts +61 -32
  83. package/src/wrangler/stage.ts +124 -173
@@ -10,49 +10,142 @@
10
10
  * declares. Keep them aligned; smoo's Typia validation fails the manifest at
11
11
  * generation time for anything this file would reject at runtime.
12
12
  *
13
+ * THE RULE: shell entry resolves the `shell` group and nothing else. Every
14
+ * other group belongs to the command that needs it:
15
+ * `smoo secrets run <group> <command...>`.
16
+ *
17
+ * Shell entry happens on every direnv reload and every `devenv shell --
18
+ * <command>`, and a provider authorises per requesting process lineage, which
19
+ * is new each time — so a provider command placed there is a credential
20
+ * prompt on every one of them: dozens an hour for a developer, one more for
21
+ * every scripted subprocess. A credential whose only consumer is a deliberate
22
+ * command therefore belongs to that command.
23
+ *
24
+ * A group is NOT a fourth thing to declare. It is derived from the
25
+ * declarations a repository already carries, and an explicit `group` on the
26
+ * entry is only for what those cannot say. Derivation, per variable:
27
+ *
28
+ * - `nx-cache` — the variable `smoo.remoteCache.tokenSecret` names. A remote
29
+ * cache is an optimization: Nx reads NX_SELF_HOSTED_REMOTE_CACHE_SERVER
30
+ * and _ACCESS_TOKEN from the environment it runs in — CI injects them, a
31
+ * developer exports the token once in the terminal that wants the cache —
32
+ * and every nested shell inherits them. Absent, Nx keeps to its local
33
+ * cache.
34
+ * - `registry` — a variable `.npmrc` interpolates as `${VAR}`. An installed
35
+ * checkout contacts no registry at all (measured: `bun install --dry-run`
36
+ * succeeds in 14ms with the token absent), while a request that does
37
+ * contact one answers 401 without it.
38
+ * - `shell` — everything else: what the shell itself is for, resolved at
39
+ * entry because that is when it is needed.
40
+ *
41
+ * An explicit `group` wins over the derivation, and no group is privileged in
42
+ * code: a repository may name one of its own and run it the same way. Only
43
+ * the shape is validated, so a new group needs no new smoo release.
44
+ *
13
45
  * Routing per declared variable, in first-match order:
14
46
  *
15
47
  * 1. An existing nonempty environment value wins; no command runs.
16
- * 2. CI (`CI` set): variables must be injected from the CI secret store;
17
- * provider commands never run there. Missing variables refuse with
18
- * injected-secret guidance instead of a confusing auth failure later.
19
- * 3. Inside a cowshed workspace (`COWSHED_WORKSPACE_TOKEN` set), a variable
20
- * referenced from `.npmrc` is registry routing intent: it is excluded
21
- * from local provider resolution and refuses with names-only
22
- * gateway-enrollment guidance. Generic variables still use env/provider.
48
+ * 2. For the requested group, and for a group the request depends on (see
49
+ * below), a context that cannot run a provider command at all decides
50
+ * before the group does:
51
+ * a. CI (`CI` set): variables must be injected from the CI secret store;
52
+ * provider commands never run there. Missing variables refuse with
53
+ * injected-secret guidance instead of a confusing auth failure later.
54
+ * b. A `registry` variable inside a cowshed workspace
55
+ * (`COWSHED_WORKSPACE_TOKEN` set) refuses with names-only
56
+ * gateway-enrollment guidance: such a workspace holds no provider
57
+ * session, and the gateway exists to be the thing that does.
58
+ * 3. A variable outside the requested group is deferred — named in the
59
+ * result, not resolved, and not a failure.
23
60
  * 4. Otherwise the command argv executes directly, without a shell; stdout
24
61
  * with one terminal newline trimmed becomes the value.
25
62
  *
63
+ * A request DEPENDS ON a group when the operation being resolved for reads
64
+ * that group's own declaration. Shell entry runs `bun install`, and
65
+ * `bun install` reads `.npmrc` — so `registry` is an input to shell entry
66
+ * that shell entry deliberately does not resolve. That dependency is what
67
+ * keeps rule 2 honest in a context which cannot honour a deferral: CI has no
68
+ * later command to run and a cowshed workspace has no provider session, so
69
+ * both refuse by name rather than promising that `smoo secrets run` would
70
+ * fix it. Nothing shell entry runs reads `smoo.remoteCache`, so an
71
+ * `nx-cache` variable is deferred in every context and blocks nothing.
72
+ *
73
+ * A deferred variable is a value in the result rather than a silent omission,
74
+ * and there is no fallback that resolves it anyway: a fallback would
75
+ * reinstate the prompt this rule removes, at the least predictable moment.
76
+ * The caller names the variable and the command that supplies it when the
77
+ * operation that needed it fails.
78
+ *
26
79
  * Failures aggregate so a single direnv reload surfaces every problem.
27
- * Error text names variables and exit codes only: secret values, provider
28
- * stdout/stderr, and command arguments are never echoed.
80
+ * Error text names variables, groups and exit codes only: secret values,
81
+ * provider stdout/stderr, and command arguments are never echoed.
29
82
  * A resolved value still reaches the environment of the install its caller
30
83
  * runs next, so `maskSecretValues` redacts those values out of any captured
31
84
  * child output that caller replays.
32
85
  *
33
- * The variable `smoo.remoteCache.tokenSecret` names is never resolved here,
34
- * nor anywhere at shell entry. A remote cache is an optimization, and shell
35
- * entry happens on every direnv reload and every `devenv shell -- <command>`:
36
- * a provider command run there is a credential prompt on each of them. Nx
37
- * reads NX_SELF_HOSTED_REMOTE_CACHE_SERVER and _ACCESS_TOKEN from the
38
- * environment it runs in CI injects them, a developer exports the token
39
- * once in the terminal that wants the cache and every nested shell inherits
40
- * them. Absent, Nx keeps to its local cache.
86
+ * Zero prompts, as an option and not a recommendation: a declared `command`
87
+ * may point at the OS keychain instead of at the provider. On macOS, populate
88
+ * the item once from the 1Password item (`security add-generic-password -s
89
+ * <name> -a <account> -w`) and declare
90
+ * `["security", "find-generic-password", "-w", "-s", "<name>"]`. macOS
91
+ * authorises a keychain item per calling binary, so the first approval covers
92
+ * every later read by that binary: rule 4 then answers without prompting and
93
+ * the deferral above never has to be paid. 1Password stays the source of
94
+ * truth and the keychain is a session cache of it — but the trade-off is
95
+ * exactly that, a second copy of the credential now lives on disk, outside
96
+ * the provider's rotation and revocation, and nothing here refreshes it.
41
97
  */
42
98
  import { existsSync, readFileSync } from 'node:fs';
43
99
  import { join } from 'node:path';
44
100
 
101
+ /**
102
+ * Which operation resolves a declared secret. Open by construction: the three
103
+ * below are the DERIVED defaults, not the permitted values, so a repository
104
+ * that declares `"group": "deploy"` needs no smoo release to run it.
105
+ */
106
+ export type SecretGroup = string;
107
+
108
+ /** Resolved at shell entry, because that is the operation that needs it. */
109
+ export const SHELL_GROUP: SecretGroup = 'shell';
110
+
111
+ /** Derived for a variable `.npmrc` interpolates as `${VAR}`. */
112
+ export const REGISTRY_GROUP: SecretGroup = 'registry';
113
+
114
+ /** Derived for the variable `smoo.remoteCache.tokenSecret` names. */
115
+ export const NX_CACHE_GROUP: SecretGroup = 'nx-cache';
116
+
45
117
  export interface SecretSpec {
46
118
  /** Executed directly, without a shell; stdout supplies the secret value. */
47
119
  readonly command: readonly [string, ...string[]];
120
+ /**
121
+ * Overrides the derived group. Declared only for what the repository's own
122
+ * declarations cannot say — a credential no `.npmrc` reference and no cache
123
+ * token identifies, or one whose derivation is deliberately overridden.
124
+ */
125
+ readonly group?: SecretGroup;
126
+ }
127
+
128
+ /** A declared secret with its group settled: explicit if stated, derived otherwise. */
129
+ export interface GroupedSecret {
130
+ readonly name: string;
131
+ /** The group that resolves it: `spec.group` when declared, `derivedGroup` otherwise. */
132
+ readonly group: SecretGroup;
133
+ /**
134
+ * What this repository's own declarations imply, kept beside the effective
135
+ * group so an override is a visible fact rather than a silent one. Equal to
136
+ * `group` unless the entry declares a different one.
137
+ */
138
+ readonly derivedGroup: SecretGroup;
139
+ readonly spec: SecretSpec;
48
140
  }
49
141
 
50
142
  export type SecretCommandRunner = (argv: readonly string[]) => Promise<string>;
51
143
 
52
144
  export interface SecretResolutionRequest {
53
- readonly secrets: Readonly<Record<string, SecretSpec>>;
54
- /** Env names referenced from `.npmrc`; registry routing intent inside a cowshed workspace. */
55
- readonly registryIntentEnvs: ReadonlySet<string>;
145
+ /** Every declared secret of the repository, each with its group settled. */
146
+ readonly secrets: readonly GroupedSecret[];
147
+ /** The one group to resolve. No default: the wrong one is a credential prompt. */
148
+ readonly group: SecretGroup;
56
149
  /** Environment consulted for existing values and mode flags; read, never written. */
57
150
  readonly env: Readonly<Record<string, string | undefined>>;
58
151
  /** Overrides the default direct-argv runner (test seam). */
@@ -62,21 +155,71 @@ export interface SecretResolutionRequest {
62
155
  export interface SecretResolutionOptions {
63
156
  /** Repository root holding package.json and .npmrc. */
64
157
  readonly root: string;
158
+ /** The one group to resolve. No default: the wrong one is a credential prompt. */
159
+ readonly group: SecretGroup;
65
160
  /** Environment to consult; defaults to process.env. Read, never written. */
66
161
  readonly env?: Record<string, string | undefined>;
67
162
  /** Overrides the default direct-argv runner (test seam). */
68
163
  readonly runCommand?: SecretCommandRunner;
69
164
  }
70
165
 
166
+ /** A declared secret this request declined to resolve, and how to supply it. */
167
+ export interface DeferredSecret {
168
+ readonly name: string;
169
+ readonly group: SecretGroup;
170
+ readonly guidance: string;
171
+ }
172
+
173
+ /**
174
+ * The resolution as a value: what belongs in a child's environment, and what
175
+ * was deliberately left out of it. Every declared secret appears in exactly
176
+ * one of `values`, `deferred`, or neither — the environment already carried
177
+ * it.
178
+ */
179
+ export interface SecretResolution {
180
+ readonly values: Readonly<Record<string, string>>;
181
+ readonly deferred: readonly DeferredSecret[];
182
+ }
183
+
71
184
  /**
72
185
  * The env name pattern every shell accepts, so the same regex classifies
73
186
  * declared names and `.npmrc` references without echoing values.
74
187
  */
75
188
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
76
189
 
190
+ /**
191
+ * A group label's shape, and only its shape. It is one argv word of
192
+ * `smoo secrets run <group> <command...>`, so whitespace would make the
193
+ * command unwritable; every other value is the repository's business.
194
+ */
195
+ const GROUP_NAME = /^\S+$/;
196
+
77
197
  /** The `.npmrc` interpolations Bun substitutes while reading auth and registry config. */
78
198
  const NPMRC_ENV_REFERENCE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
79
199
 
200
+ /**
201
+ * The command that resolves one group for exactly one child process. Every
202
+ * deferral names it, so what a developer reads is the command to run next
203
+ * rather than a description of one.
204
+ */
205
+ const ON_DEMAND_COMMAND = 'smoo secrets run';
206
+
207
+ /**
208
+ * Groups an operation depends on without resolving them, keyed by the group
209
+ * it does resolve. Shell entry runs `bun install`, which reads `.npmrc` —
210
+ * the declaration that derives `registry`. This is a dependency edge, not an
211
+ * exclusion: it makes CI and a cowshed workspace refuse a registry credential
212
+ * they cannot supply later, where a group nothing depends on stays silent.
213
+ */
214
+ const DEPENDENT_GROUPS: Readonly<Record<string, readonly SecretGroup[]>> = {
215
+ [SHELL_GROUP]: [REGISTRY_GROUP],
216
+ };
217
+
218
+ /** Groups a request for `group` needs supplied but does not resolve itself. */
219
+ export function dependentGroups(group: SecretGroup): readonly SecretGroup[] {
220
+ return DEPENDENT_GROUPS[group] ?? [];
221
+ }
222
+
80
223
  function isRecord(value: unknown): value is Record<string, unknown> {
81
224
  return typeof value === 'object' && value !== null && !Array.isArray(value);
82
225
  }
@@ -125,7 +268,16 @@ function parseSecretSpec(name: string, spec: unknown): SecretSpec {
125
268
  }
126
269
  }
127
270
  const [first, ...rest] = raw;
128
- return { command: [first, ...rest] };
271
+ const group = spec.group;
272
+ if (group === undefined) {
273
+ return { command: [first, ...rest] };
274
+ }
275
+ if (typeof group !== 'string' || !GROUP_NAME.test(group)) {
276
+ throw new Error(
277
+ `smoo.secrets.${name}.group: must be a nonempty label without whitespace, so \`${ON_DEMAND_COMMAND} <group>\` can name it`,
278
+ );
279
+ }
280
+ return { command: [first, ...rest], group };
129
281
  }
130
282
 
131
283
  /**
@@ -136,7 +288,7 @@ function parseSecretSpec(name: string, spec: unknown): SecretSpec {
136
288
  */
137
289
  export interface RemoteCacheSpec {
138
290
  readonly server: string;
139
- /** Variable holding the cache token: an ambient value, or a `smoo.secrets` entry resolved here. */
291
+ /** Variable holding the cache token: an ambient value, or a `smoo.secrets` entry in group `nx-cache`. */
140
292
  readonly tokenSecret: string;
141
293
  }
142
294
 
@@ -181,88 +333,139 @@ export function registryAuthEnvNames(npmrcText: string | null): ReadonlySet<stri
181
333
  return names;
182
334
  }
183
335
 
336
+ /** What a repository's own declarations say about one variable, before any override. */
337
+ export interface GroupDerivationContext {
338
+ /** Env names referenced from `.npmrc`: which declared secrets a registry request needs. */
339
+ readonly registryIntentEnvs: ReadonlySet<string>;
340
+ /** The variable `smoo.remoteCache.tokenSecret` names, or null when no cache is declared. */
341
+ readonly cacheTokenEnv: string | null;
342
+ }
343
+
344
+ /**
345
+ * The group a repository's existing declarations already imply for one
346
+ * variable. The cache token is checked first: a variable that is both the
347
+ * declared cache token and `.npmrc`-referenced was excluded from shell entry
348
+ * outright before groups existed, and `nx-cache` is the group that keeps that
349
+ * true.
350
+ */
351
+ export function deriveSecretGroup(name: string, context: GroupDerivationContext): SecretGroup {
352
+ if (context.cacheTokenEnv === name) return NX_CACHE_GROUP;
353
+ if (context.registryIntentEnvs.has(name)) return REGISTRY_GROUP;
354
+ return SHELL_GROUP;
355
+ }
356
+
357
+ /** Every declared secret with its group settled, in declaration order. */
358
+ export function groupSecrets(
359
+ secrets: Readonly<Record<string, SecretSpec>>,
360
+ context: GroupDerivationContext,
361
+ ): readonly GroupedSecret[] {
362
+ return Object.entries(secrets).map(([name, spec]) => {
363
+ const derivedGroup = deriveSecretGroup(name, context);
364
+ return { name, group: spec.group ?? derivedGroup, derivedGroup, spec };
365
+ });
366
+ }
367
+
184
368
  type SecretOutcome =
185
- | { readonly name: string; readonly kind: 'env-wins' }
186
- | { readonly name: string; readonly kind: 'resolved'; readonly value: string }
187
- | { readonly name: string; readonly kind: 'failed'; readonly guidance: string };
369
+ | { readonly name: string; readonly group: SecretGroup; readonly kind: 'env-wins' }
370
+ | { readonly name: string; readonly group: SecretGroup; readonly kind: 'resolved'; readonly value: string }
371
+ | { readonly name: string; readonly group: SecretGroup; readonly kind: 'deferred'; readonly guidance: string }
372
+ | { readonly name: string; readonly group: SecretGroup; readonly kind: 'failed'; readonly guidance: string };
188
373
 
189
374
  /**
190
- * One variable's routing, the four rules in the header applied in order. It is
191
- * a value rather than a throw so each caller presents a failure in its own
192
- * terms: an install refuses, the remote cache turns itself off.
375
+ * One variable's routing, the four rules in the header applied in order. It
376
+ * is a value rather than a throw so each caller presents the result in its
377
+ * own terms: an install refuses, the remote cache turns itself off, and a
378
+ * deferred credential is named only if something then needs it.
193
379
  */
194
380
  async function routeSecret(
195
- name: string,
196
- spec: SecretSpec,
381
+ secret: GroupedSecret,
197
382
  context: {
198
383
  readonly env: Readonly<Record<string, string | undefined>>;
199
- readonly registryIntentEnvs: ReadonlySet<string>;
384
+ readonly group: SecretGroup;
385
+ readonly dependsOn: readonly SecretGroup[];
200
386
  readonly run: SecretCommandRunner;
201
387
  },
202
388
  ): Promise<SecretOutcome> {
203
389
  const { env } = context;
390
+ const { name, group } = secret;
204
391
  if (isNonemptyEnvValue(env[name])) {
205
- return { name, kind: 'env-wins' };
392
+ return { name, group, kind: 'env-wins' };
206
393
  }
207
- if (isNonemptyEnvValue(env.CI)) {
208
- return {
209
- name,
210
- kind: 'failed',
211
- guidance:
212
- 'CI does not run secret provider commands — inject this variable into the job environment from the CI secret store',
213
- };
394
+ const requested = group === context.group;
395
+ if (requested || context.dependsOn.includes(group)) {
396
+ if (isNonemptyEnvValue(env.CI)) {
397
+ return {
398
+ name,
399
+ group,
400
+ kind: 'failed',
401
+ guidance:
402
+ 'CI does not run secret provider commands — inject this variable into the job environment from the CI secret store',
403
+ };
404
+ }
405
+ if (group === REGISTRY_GROUP && isNonemptyEnvValue(env.COWSHED_WORKSPACE_TOKEN)) {
406
+ return {
407
+ name,
408
+ group,
409
+ kind: 'failed',
410
+ guidance:
411
+ 'a registry credential absent in this cowshed workspace — enroll registry credentials through the cowshed gateway; gateway-managed workspaces never resolve a registry credential via a local provider command',
412
+ };
413
+ }
214
414
  }
215
- if (isNonemptyEnvValue(env.COWSHED_WORKSPACE_TOKEN) && context.registryIntentEnvs.has(name)) {
415
+ if (!requested) {
216
416
  return {
217
417
  name,
218
- kind: 'failed',
219
- guidance:
220
- 'referenced from .npmrc and absent in this cowshed workspace enroll registry credentials through the cowshed gateway; gateway-managed workspaces never resolve .npmrc registry variables via local provider commands',
418
+ group,
419
+ kind: 'deferred',
420
+ guidance: `in group \`${group}\`, which a \`${context.group}\` request does not resolve run \`${ON_DEMAND_COMMAND} ${group} <command>\` to supply it to that one command`,
221
421
  };
222
422
  }
223
423
  try {
224
- const value = (await context.run(spec.command)).replace(/\r?\n$/, '');
424
+ const value = (await context.run(secret.spec.command)).replace(/\r?\n$/, '');
225
425
  if (value.length === 0) {
226
426
  return {
227
427
  name,
428
+ group,
228
429
  kind: 'failed',
229
430
  guidance:
230
431
  'provider command produced no output — run it locally to see why (its arguments and output are never logged here)',
231
432
  };
232
433
  }
233
- return { name, kind: 'resolved', value };
434
+ return { name, group, kind: 'resolved', value };
234
435
  } catch (error) {
235
- return { name, kind: 'failed', guidance: describeCommandFailure(error) };
436
+ return { name, group, kind: 'failed', guidance: describeCommandFailure(error) };
236
437
  }
237
438
  }
238
439
 
239
- export async function resolveSecrets(request: SecretResolutionRequest): Promise<Readonly<Record<string, string>>> {
440
+ export async function resolveSecrets(request: SecretResolutionRequest): Promise<SecretResolution> {
240
441
  const context = {
241
442
  env: request.env,
242
- registryIntentEnvs: request.registryIntentEnvs,
443
+ group: request.group,
444
+ dependsOn: dependentGroups(request.group),
243
445
  run: request.runCommand ?? runSecretCommand,
244
446
  };
245
- const outcomes = await Promise.all(
246
- Object.entries(request.secrets).map(async ([name, spec]) => routeSecret(name, spec, context)),
247
- );
447
+ const outcomes = await Promise.all(request.secrets.map(async (secret) => routeSecret(secret, context)));
248
448
  const failures = outcomes.filter(
249
449
  (outcome): outcome is Extract<SecretOutcome, { kind: 'failed' }> => outcome.kind === 'failed',
250
450
  );
251
451
  if (failures.length > 0) {
252
452
  throw new Error(
253
453
  [
254
- 'smoo secret resolution failed; dependencies were not installed:',
255
- ...failures.map((failure) => `- ${failure.name}: ${failure.guidance}`),
454
+ 'smoo secret resolution failed:',
455
+ ...failures.map((failure) => `- ${failure.name} (${failure.group}): ${failure.guidance}`),
256
456
  ].join('\n'),
257
457
  );
258
458
  }
259
- const resolved: Record<string, string> = {};
459
+ const values: Record<string, string> = {};
460
+ const deferred: DeferredSecret[] = [];
260
461
  for (const outcome of outcomes) {
261
462
  if (outcome.kind === 'resolved') {
262
- resolved[outcome.name] = outcome.value;
463
+ values[outcome.name] = outcome.value;
464
+ } else if (outcome.kind === 'deferred') {
465
+ deferred.push({ name: outcome.name, group: outcome.group, guidance: outcome.guidance });
263
466
  }
264
467
  }
265
- return resolved;
468
+ return { values, deferred };
266
469
  }
267
470
 
268
471
  class SecretCommandExitError extends Error {
@@ -378,28 +581,29 @@ function indexOfBytes(haystack: Uint8Array, needle: Uint8Array, from: number): n
378
581
  }
379
582
 
380
583
  /**
381
- * The declared secrets an install needs, resolved before it runs. The cache
382
- * token is deliberately not among them: a remote cache is an optimization, so
383
- * its credential is resolved by the shell's cache export where an
384
- * unreachable secret provider costs a stderr line while every secret an
385
- * install actually depends on still refuses loudly here. Which variable that
386
- * is comes from `smoo.remoteCache.tokenSecret` rather than a second flag, so
387
- * the two declarations cannot disagree.
584
+ * Every declared secret of the repository at `root`, each with its group
585
+ * settled from that repository's own declarations. The listing
586
+ * `smoo secrets run` prints and the routing below read the same value, so the
587
+ * groups a developer is offered are exactly the groups that resolve.
388
588
  */
389
- export async function resolveSecretEnvironment(
390
- options: SecretResolutionOptions,
391
- ): Promise<Readonly<Record<string, string>>> {
392
- const packageJson = readPackageJson(options.root);
393
- const cacheToken = parseSmooRemoteCache(packageJson)?.tokenSecret;
394
- const required: Record<string, SecretSpec> = {};
395
- for (const [name, spec] of Object.entries(parseSmooSecrets(packageJson))) {
396
- if (name !== cacheToken) {
397
- required[name] = spec;
398
- }
399
- }
589
+ export function readSecretGroups(root: string): readonly GroupedSecret[] {
590
+ const packageJson = readPackageJson(root);
591
+ return groupSecrets(parseSmooSecrets(packageJson), {
592
+ registryIntentEnvs: registryAuthEnvNames(readNpmrcText(root)),
593
+ cacheTokenEnv: parseSmooRemoteCache(packageJson)?.tokenSecret ?? null,
594
+ });
595
+ }
596
+
597
+ /**
598
+ * The declared secrets of `options.group`, resolved before the thing that
599
+ * needs them runs. Everything outside that group comes back deferred: named,
600
+ * unresolved, and not a failure — except where rule 2 applies, which is a
601
+ * context that could not honour the deferral anyway.
602
+ */
603
+ export async function resolveSecretEnvironment(options: SecretResolutionOptions): Promise<SecretResolution> {
400
604
  return resolveSecrets({
401
- secrets: required,
402
- registryIntentEnvs: registryAuthEnvNames(readNpmrcText(options.root)),
605
+ secrets: readSecretGroups(options.root),
606
+ group: options.group,
403
607
  env: options.env ?? process.env,
404
608
  runCommand: options.runCommand,
405
609
  });
@@ -4,7 +4,13 @@ import { mkdir, rmdir, stat } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import { $ } from 'bun';
7
- import { maskSecretValues, resolveSecretEnvironment } from './secret-references.ts';
7
+ import {
8
+ type DeferredSecret,
9
+ dependentGroups,
10
+ maskSecretValues,
11
+ resolveSecretEnvironment,
12
+ SHELL_GROUP,
13
+ } from './secret-references.ts';
8
14
 
9
15
  // DEVENV_ROOT is set by the devenv shell, which is how this script normally
10
16
  // runs. CI jobs that install dependencies without building that shell (the
@@ -32,6 +38,15 @@ class CapturedCommandError extends Error {
32
38
  // of the failure it was called to report.
33
39
  const resolvedSecretValues: string[] = [];
34
40
 
41
+ // The declared secrets shell entry deliberately did NOT resolve — see THE
42
+ // RULE in secret-references.ts: shell entry resolves the `shell` group and
43
+ // nothing else. Nonempty only when this repository declares a secret in
44
+ // another group that is absent from this environment. An installed checkout
45
+ // contacts no registry and Nx needs no cache to run, so this stays silent;
46
+ // it is printed by reportDegradedSetup, where an install has already failed
47
+ // and a 401 is one of the things it might have been.
48
+ const deferredSecrets: DeferredSecret[] = [];
49
+
35
50
  async function resolveProjectRoot(): Promise<string> {
36
51
  if (devenvRoot) {
37
52
  return path.resolve(`${devenvRoot}/../..`);
@@ -145,12 +160,22 @@ try {
145
160
  * scripts it runs, and every later child of this script inherit them (for
146
161
  * example .npmrc `${VAR}` auth); the direnv shell itself does not, which is
147
162
  * the point: this script must never act as a global shell export.
163
+ *
164
+ * `shell` is the group, and it is what keeps a credential only a deliberate
165
+ * command needs — a registry token, the Nx cache token — from running its
166
+ * provider command on every direnv reload. Such a variable comes back
167
+ * deferred instead of resolved, and an install proceeds without it — which
168
+ * is the normal case, because an installed checkout contacts no registry.
169
+ * The install that does need one fails, and reportDegradedSetup then names
170
+ * the variable and the exact command that supplies it.
148
171
  */
149
172
  async function resolveSecrets(): Promise<void> {
150
- for (const [name, value] of Object.entries(await resolveSecretEnvironment({ root: projectRoot }))) {
173
+ const resolution = await resolveSecretEnvironment({ root: projectRoot, group: SHELL_GROUP });
174
+ for (const [name, value] of Object.entries(resolution.values)) {
151
175
  process.env[name] = value;
152
176
  resolvedSecretValues.push(value);
153
177
  }
178
+ deferredSecrets.push(...resolution.deferred);
154
179
  }
155
180
 
156
181
  async function installLocalDependencies(): Promise<unknown> {
@@ -416,6 +441,7 @@ function reportSetupFailure(error: unknown): never {
416
441
 
417
442
  function reportDegradedSetup(error: unknown): void {
418
443
  describeFailure('WARNING', error);
444
+ reportDeferredSecrets();
419
445
  console.error(
420
446
  'The shell is loaded WITHOUT installed dependencies so the tools to repair this stay available.\n' +
421
447
  'Fix the cause above (missing registry credential, unpublished package, stale lockfile → `devenv update`),\n' +
@@ -424,6 +450,36 @@ function reportDegradedSetup(error: unknown): void {
424
450
  console.error('---');
425
451
  }
426
452
 
453
+ /**
454
+ * The declared secrets this shell entry deliberately did not resolve, named
455
+ * now that an install has actually failed. Printing them on a healthy shell
456
+ * entry would be noise on every reload — an installed checkout contacts no
457
+ * registry and needs none of them — while an install that failed may be
458
+ * exactly the 401 they explain, and then the useful output is the variable
459
+ * and the command that supplies it.
460
+ */
461
+ function reportDeferredSecrets(): void {
462
+ if (deferredSecrets.length === 0) {
463
+ return;
464
+ }
465
+ console.error('Secrets outside the `shell` group are not resolved at shell entry, by design:');
466
+ for (const secret of deferredSecrets) {
467
+ console.error(`- ${secret.name} (${secret.group}): ${secret.guidance}`);
468
+ }
469
+ // Only a group the install itself reads can explain this failure, and only
470
+ // for those is re-running the install the command worth printing. A group
471
+ // nothing here consumes — the Nx cache token — is listed above and left
472
+ // alone: it did not cause this and re-running the install with it would
473
+ // not fix it.
474
+ for (const group of dependentGroups(SHELL_GROUP)) {
475
+ if (!deferredSecrets.some((secret) => secret.group === group)) {
476
+ continue;
477
+ }
478
+ console.error(`The install reads group \`${group}\`; supply it to exactly that one command with:`);
479
+ console.error(` smoo secrets run ${group} bun install`);
480
+ }
481
+ }
482
+
427
483
  function describeFailure(level: 'ERROR' | 'WARNING', error: unknown): void {
428
484
  if (error instanceof CapturedCommandError) {
429
485
  console.error(`--- ${level}: setup-environment.ts failed while running: ${error.command}`);
@@ -0,0 +1,28 @@
1
+ {
2
+ // The managed raw scripts are their own TypeScript project, and must be:
3
+ // they run as plain Bun source during bootstrap — before any workspace
4
+ // package, before node_modules, before the Typia transform — so they can
5
+ // belong to no project that assumes those. This file is what says so.
6
+ //
7
+ // It is also load-bearing at runtime. Bun's ttsc loader (see
8
+ // packages/validation/src/bun/preload.ts) routes each file to the nearest
9
+ // `tsconfig.lib.json` above it; without this one, a raw script under
10
+ // managed/ routed to packages/cli/tsconfig.lib.json, whose `include` is
11
+ // `src/**/*.ts`, and the transform returned no output at all. `smoo secrets
12
+ // run` imports secret-references.ts from here, and a consuming repo does
13
+ // the same from inside node_modules, so this file ships with the package.
14
+ "compilerOptions": {
15
+ "target": "ESNext",
16
+ "lib": ["ESNext"],
17
+ "module": "Preserve",
18
+ "moduleResolution": "bundler",
19
+ "allowImportingTsExtensions": true,
20
+ "verbatimModuleSyntax": true,
21
+ "noEmit": true,
22
+ "strict": true,
23
+ "noUncheckedIndexedAccess": false,
24
+ "skipLibCheck": true,
25
+ "types": ["bun"]
26
+ },
27
+ "include": ["**/*.ts"]
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.11.19",
3
+ "version": "0.11.20",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -39,6 +39,11 @@
39
39
  "import": "./dist/release/pack.js",
40
40
  "default": "./dist/release/pack.js"
41
41
  },
42
+ "./secrets": {
43
+ "types": "./dist/secrets/status.d.ts",
44
+ "import": "./dist/secrets/status.js",
45
+ "default": "./dist/secrets/status.js"
46
+ },
42
47
  "./wrangler/prepare-env": {
43
48
  "types": "./dist/wrangler/prepare-env.d.ts",
44
49
  "import": "./dist/wrangler/prepare-env.js",
@@ -64,7 +69,7 @@
64
69
  ],
65
70
  "dependencies": {
66
71
  "@arethetypeswrong/core": "^0.18.2",
67
- "@smoothbricks/nx-plugin": "0.4.13",
72
+ "@smoothbricks/nx-plugin": "0.4.14",
68
73
  "@smoothbricks/validation": "0.1.8",
69
74
  "commander": "^14.0.3",
70
75
  "make-synchronized": "^0.8.0",