@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,4 +1,5 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
+ import { existsSync, readFileSync } from 'node:fs';
2
3
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { join, resolve } from 'node:path';
@@ -32,7 +33,8 @@ const RESOLVE_SCRIPT = `
32
33
  const resolver = await import(Bun.argv[1]);
33
34
  const request = JSON.parse(Bun.argv[2]);
34
35
  try {
35
- process.stdout.write(JSON.stringify({ resolved: await resolver.resolveSecretEnvironment(request) }));
36
+ const { values, deferred } = await resolver.resolveSecretEnvironment(request);
37
+ process.stdout.write(JSON.stringify({ resolved: values, deferred }));
36
38
  } catch (error) {
37
39
  process.stdout.write(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
38
40
  }
@@ -54,16 +56,44 @@ const names = [...resolver.registryAuthEnvNames(npmrc)];
54
56
  process.stdout.write(JSON.stringify({ names }));
55
57
  `;
56
58
 
59
+ const GROUPS_SCRIPT = `
60
+ const resolver = await import(Bun.argv[1]);
61
+ const { root } = JSON.parse(Bun.argv[2]);
62
+ const groups = resolver.readSecretGroups(root).map(({ name, group, derivedGroup }) => ({ name, group, derivedGroup }));
63
+ process.stdout.write(JSON.stringify({ groups }));
64
+ `;
65
+
57
66
  interface BootstrapOutcome {
58
67
  readonly resolved?: Record<string, string>;
68
+ readonly deferred?: readonly { readonly name: string; readonly group: string; readonly guidance: string }[];
59
69
  readonly parsed?: unknown;
60
70
  readonly error?: string;
61
71
  }
62
72
 
73
+ interface GroupListing {
74
+ readonly groups: { name: string; group: string; derivedGroup: string }[];
75
+ }
76
+
77
+ function isGroupListing(value: unknown): value is GroupListing {
78
+ if (typeof value !== 'object' || value === null || !('groups' in value) || !Array.isArray(value.groups)) return false;
79
+ return value.groups.every(
80
+ (entry: unknown) =>
81
+ typeof entry === 'object' &&
82
+ entry !== null &&
83
+ 'name' in entry &&
84
+ typeof entry.name === 'string' &&
85
+ 'group' in entry &&
86
+ typeof entry.group === 'string' &&
87
+ 'derivedGroup' in entry &&
88
+ typeof entry.derivedGroup === 'string',
89
+ );
90
+ }
91
+
63
92
  /** Narrows a harness JSON envelope with `in` checks instead of assertions: unknown shapes fail the test. Field-level expectations below do the precise checking. */
64
93
  function isBootstrapOutcome(value: unknown): value is BootstrapOutcome {
65
94
  if (typeof value !== 'object' || value === null) return false;
66
95
  if ('resolved' in value && (typeof value.resolved !== 'object' || value.resolved === null)) return false;
96
+ if ('deferred' in value && !Array.isArray(value.deferred)) return false;
67
97
  if ('error' in value && typeof value.error !== 'string') return false;
68
98
  return true;
69
99
  }
@@ -96,6 +126,39 @@ const emit = (text: string): readonly [string, ...string[]] => [
96
126
  '-e',
97
127
  `process.stdout.write(${JSON.stringify(text)})`,
98
128
  ];
129
+
130
+ /** `emit`, plus one line appended to `ledger` every time the command actually runs. */
131
+ const emitAndRecord = (text: string, ledger: string): readonly [string, ...string[]] => [
132
+ process.execPath,
133
+ '-e',
134
+ `require('node:fs').appendFileSync(${JSON.stringify(ledger)}, '1\\n');process.stdout.write(${JSON.stringify(text)})`,
135
+ ];
136
+
137
+ /**
138
+ * A ledger a provider command appends to, and the count of what it recorded.
139
+ * Counting invocations is the only direct evidence that a path ran no
140
+ * provider command: an absent value is also what a silently failing provider
141
+ * leaves behind, and "no credential prompt" is the whole point of the rule.
142
+ */
143
+ async function withProviderLedger(
144
+ run: (ledger: { readonly path: string; readonly invocations: () => number }) => Promise<void>,
145
+ ): Promise<void> {
146
+ const dir = await mkdtemp(join(tmpdir(), 'smoo-provider-ledger-'));
147
+ const path = join(dir, 'invocations');
148
+ try {
149
+ await run({
150
+ path,
151
+ invocations: () =>
152
+ existsSync(path)
153
+ ? readFileSync(path, 'utf8')
154
+ .split('\n')
155
+ .filter((line) => line.length > 0).length
156
+ : 0,
157
+ });
158
+ } finally {
159
+ await rm(dir, { recursive: true, force: true });
160
+ }
161
+ }
99
162
  /**
100
163
  * Runs one script against the raw module in a plain Bun child. The child env
101
164
  * carries only PATH/HOME so parent suite state (CI, workspace tokens) cannot
@@ -124,16 +187,33 @@ async function runInBootstrap(
124
187
  async function resolveInBootstrap(options: {
125
188
  root: string;
126
189
  env?: Record<string, string | undefined>;
190
+ /**
191
+ * Defaults to the group shell entry resolves, which is what a direnv
192
+ * reload performs. The resolver itself declares no default — resolving the
193
+ * wrong group is a credential prompt — so every case that is not shell
194
+ * entry names its group out loud.
195
+ */
196
+ group?: string;
127
197
  }): Promise<BootstrapOutcome> {
128
198
  const { stdout, stderr, exitCode } = await runInBootstrap(
129
199
  RESOLVE_SCRIPT,
130
- JSON.stringify({ root: options.root, env: options.env ?? {} }),
200
+ JSON.stringify({ root: options.root, env: options.env ?? {}, group: options.group ?? 'shell' }),
131
201
  );
132
202
  expect(stderr).toBe('');
133
203
  expect(exitCode).toBe(0);
134
204
  return mustOutcome(stdout);
135
205
  }
136
206
 
207
+ /** The groups this repository's own declarations settle on, per declared variable. */
208
+ async function groupsInBootstrap(root: string): Promise<{ name: string; group: string; derivedGroup: string }[]> {
209
+ const { stdout, stderr, exitCode } = await runInBootstrap(GROUPS_SCRIPT, JSON.stringify({ root }));
210
+ expect(stderr).toBe('');
211
+ expect(exitCode).toBe(0);
212
+ const value: unknown = JSON.parse(stdout);
213
+ if (!isGroupListing(value)) throw new Error(`bootstrap harness returned a malformed listing: ${stdout}`);
214
+ return value.groups;
215
+ }
216
+
137
217
  /** Builds a fixture repository whose package.json and .npmrc are the resolver's real inputs. */
138
218
  async function withFixture(
139
219
  options: { secrets: Record<string, SecretSpec>; npmrc?: string; remoteCache?: unknown },
@@ -195,6 +275,19 @@ describe('smoo.secrets shape validation', () => {
195
275
  expect((await parse({ smoo: { secrets: { 'a b': { command: ['op'] } } } })).error).toContain('a b');
196
276
  expect((await parse({ smoo: { secrets: { '': { command: ['op'] } } } })).error).toContain('smoo.secrets');
197
277
  });
278
+
279
+ it('accepts any group label the command line can name, and rejects one it cannot', async () => {
280
+ // The shape is validated, never the value: a repository naming a group
281
+ // of its own must not need a new smoo release to run it.
282
+ expect(
283
+ (await parse({ smoo: { secrets: { SMOO_TOKEN: { command: ['op'], group: 'deploy-eu' } } } })).parsed,
284
+ ).toEqual({ SMOO_TOKEN: { command: ['op'], group: 'deploy-eu' } });
285
+ for (const malformed of ['', 'two words', 7]) {
286
+ const outcome = await parse({ smoo: { secrets: { SMOO_TOKEN: { command: ['op'], group: malformed } } } });
287
+ expect(outcome.error).toContain('SMOO_TOKEN');
288
+ expect(outcome.error).toContain('group');
289
+ }
290
+ });
198
291
  });
199
292
 
200
293
  describe('registryAuthEnvNames', () => {
@@ -215,6 +308,78 @@ describe('registryAuthEnvNames', () => {
215
308
  });
216
309
  });
217
310
 
311
+ /**
312
+ * The group is derived from declarations the repository already carries, so
313
+ * a manifest states a group only where those cannot: nobody restates a fact
314
+ * the repo already declares.
315
+ */
316
+ describe('group derivation', () => {
317
+ const REGISTRY_NPMRC = '//npm.example.net/:_authToken=${SMOO_NPM_TOKEN}\n';
318
+
319
+ it('derives registry from a .npmrc reference, nx-cache from the cache token, and shell from neither', async () => {
320
+ await withFixture(
321
+ {
322
+ npmrc: REGISTRY_NPMRC,
323
+ secrets: {
324
+ SMOO_NPM_TOKEN: { command: emit('x') },
325
+ NX_REMOTE_CACHE_TOKEN: { command: emit('x') },
326
+ SMOO_TOKEN: { command: emit('x') },
327
+ },
328
+ remoteCache: { server: 'https://nx-cache.example.net', tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
329
+ },
330
+ async (root) => {
331
+ expect(await groupsInBootstrap(root)).toEqual([
332
+ { name: 'SMOO_NPM_TOKEN', group: 'registry', derivedGroup: 'registry' },
333
+ { name: 'NX_REMOTE_CACHE_TOKEN', group: 'nx-cache', derivedGroup: 'nx-cache' },
334
+ { name: 'SMOO_TOKEN', group: 'shell', derivedGroup: 'shell' },
335
+ ]);
336
+ },
337
+ );
338
+ });
339
+
340
+ it('a declared group wins, and the derivation it overrode stays visible', async () => {
341
+ await withFixture(
342
+ {
343
+ npmrc: REGISTRY_NPMRC,
344
+ secrets: {
345
+ SMOO_NPM_TOKEN: { command: emit('x'), group: 'shell' },
346
+ SMOO_TOKEN: { command: emit('x'), group: 'deploy' },
347
+ },
348
+ },
349
+ async (root) => {
350
+ expect(await groupsInBootstrap(root)).toEqual([
351
+ { name: 'SMOO_NPM_TOKEN', group: 'shell', derivedGroup: 'registry' },
352
+ { name: 'SMOO_TOKEN', group: 'deploy', derivedGroup: 'shell' },
353
+ ]);
354
+ },
355
+ );
356
+ });
357
+
358
+ it('the cache token stays nx-cache even when .npmrc also references it', async () => {
359
+ await withFixture(
360
+ {
361
+ npmrc: '//npm.example.net/:_authToken=${NX_REMOTE_CACHE_TOKEN}\n',
362
+ secrets: { NX_REMOTE_CACHE_TOKEN: { command: emit('x') } },
363
+ remoteCache: { server: 'https://nx-cache.example.net', tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
364
+ },
365
+ async (root) => {
366
+ // The by-name exclusion this replaced was applied before the .npmrc
367
+ // reference was ever consulted; the derivation keeps that order, so
368
+ // one pathological manifest behaves exactly as it did.
369
+ expect(await groupsInBootstrap(root)).toEqual([
370
+ { name: 'NX_REMOTE_CACHE_TOKEN', group: 'nx-cache', derivedGroup: 'nx-cache' },
371
+ ]);
372
+ },
373
+ );
374
+ });
375
+
376
+ it('a repository declaring nothing has no groups', async () => {
377
+ await withFixture({ secrets: {} }, async (root) => {
378
+ expect(await groupsInBootstrap(root)).toEqual([]);
379
+ });
380
+ });
381
+ });
382
+
218
383
  describe('resolveSecretEnvironment', () => {
219
384
  it('an existing nonempty environment value wins and no command runs', async () => {
220
385
  await withFixture(
@@ -222,7 +387,10 @@ describe('resolveSecretEnvironment', () => {
222
387
  async (root) => {
223
388
  // The declared command cannot run at all, so a resolution attempt would
224
389
  // fail loudly; an empty outcome proves the existing value preempted it.
225
- expect(await resolveInBootstrap({ root, env: { SMOO_TOKEN: 'already-set' } })).toEqual({ resolved: {} });
390
+ expect(await resolveInBootstrap({ root, env: { SMOO_TOKEN: 'already-set' } })).toEqual({
391
+ resolved: {},
392
+ deferred: [],
393
+ });
226
394
  },
227
395
  );
228
396
  });
@@ -231,16 +399,23 @@ describe('resolveSecretEnvironment', () => {
231
399
  await withFixture({ secrets: { SMOO_TOKEN: { command: emit('tok-from-provider') } } }, async (root) => {
232
400
  expect(await resolveInBootstrap({ root, env: { SMOO_TOKEN: '' } })).toEqual({
233
401
  resolved: { SMOO_TOKEN: 'tok-from-provider' },
402
+ deferred: [],
234
403
  });
235
404
  });
236
405
  });
237
406
 
238
407
  it('trims one terminal newline, LF or CRLF, preserving interior content', async () => {
239
408
  await withFixture({ secrets: { SMOO_TOKEN: { command: emit('tok-from-provider\n') } } }, async (root) => {
240
- expect(await resolveInBootstrap({ root, env: {} })).toEqual({ resolved: { SMOO_TOKEN: 'tok-from-provider' } });
409
+ expect(await resolveInBootstrap({ root, env: {} })).toEqual({
410
+ resolved: { SMOO_TOKEN: 'tok-from-provider' },
411
+ deferred: [],
412
+ });
241
413
  });
242
414
  await withFixture({ secrets: { SMOO_TOKEN: { command: emit('tok\r\nmid\r\n') } } }, async (root) => {
243
- expect(await resolveInBootstrap({ root, env: {} })).toEqual({ resolved: { SMOO_TOKEN: 'tok\r\nmid' } });
415
+ expect(await resolveInBootstrap({ root, env: {} })).toEqual({
416
+ resolved: { SMOO_TOKEN: 'tok\r\nmid' },
417
+ deferred: [],
418
+ });
244
419
  });
245
420
  });
246
421
 
@@ -265,16 +440,89 @@ describe('resolveSecretEnvironment', () => {
265
440
  async (root) => {
266
441
  // A cache is an optimization: its provider being down installs
267
442
  // dependencies anyway. Every other declared secret keeps its refusal.
443
+ // The cache token is not excluded by name any more — it is in group
444
+ // `nx-cache`, which shell entry defers, so its provider is never
445
+ // reached to be unreachable.
268
446
  expect(await resolveInBootstrap({ root, env: {} })).toEqual({
269
447
  resolved: { SMOO_TOKEN: 'tok-from-provider' },
448
+ deferred: [
449
+ { name: 'NX_REMOTE_CACHE_TOKEN', group: 'nx-cache', guidance: expect.stringContaining('nx-cache') },
450
+ ],
270
451
  });
271
452
  const withoutCacheDeclaration = await resolveInBootstrap({ root, env: { CI: 'true' } });
272
453
  expect(withoutCacheDeclaration.error).toContain('SMOO_TOKEN');
454
+ // CI refuses what shell entry needs, and the cache token is not that:
455
+ // nothing shell entry runs reads `smoo.remoteCache`.
273
456
  expect(withoutCacheDeclaration.error).not.toContain('NX_REMOTE_CACHE_TOKEN');
274
457
  },
275
458
  );
276
459
  });
277
460
 
461
+ it('the declared cache token is deferred at shell entry and its provider never runs', async () => {
462
+ await withProviderLedger(async (ledger) => {
463
+ await withFixture(
464
+ {
465
+ secrets: { NX_REMOTE_CACHE_TOKEN: { command: emitAndRecord('cache-tok', ledger.path) } },
466
+ remoteCache: { server: 'https://nx-cache.example.net', tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
467
+ },
468
+ async (root) => {
469
+ // Named in the result rather than silently omitted, which is what
470
+ // the by-name exclusion this replaces used to do.
471
+ expect(await resolveInBootstrap({ root, env: {} })).toEqual({
472
+ resolved: {},
473
+ deferred: [
474
+ {
475
+ name: 'NX_REMOTE_CACHE_TOKEN',
476
+ group: 'nx-cache',
477
+ guidance: expect.stringContaining('smoo secrets run nx-cache'),
478
+ },
479
+ ],
480
+ });
481
+ expect(ledger.invocations()).toBe(0);
482
+
483
+ // And a cowshed workspace does not refuse it either: the gateway
484
+ // rule is about registry credentials, and this is not one.
485
+ expect(await resolveInBootstrap({ root, env: { COWSHED_WORKSPACE_TOKEN: 'gateway-session' } })).toEqual({
486
+ resolved: {},
487
+ deferred: [
488
+ {
489
+ name: 'NX_REMOTE_CACHE_TOKEN',
490
+ group: 'nx-cache',
491
+ guidance: expect.stringContaining('smoo secrets run nx-cache'),
492
+ },
493
+ ],
494
+ });
495
+ expect(ledger.invocations()).toBe(0);
496
+ },
497
+ );
498
+ });
499
+ });
500
+
501
+ it('a deliberate nx-cache run resolves the cache token nothing else resolves', async () => {
502
+ await withProviderLedger(async (ledger) => {
503
+ await withFixture(
504
+ {
505
+ secrets: {
506
+ NX_REMOTE_CACHE_TOKEN: { command: emitAndRecord('cache-tok', ledger.path) },
507
+ SMOO_TOKEN: { command: emitAndRecord('shell-tok', ledger.path) },
508
+ },
509
+ remoteCache: { server: 'https://nx-cache.example.net', tokenSecret: 'NX_REMOTE_CACHE_TOKEN' },
510
+ },
511
+ async (root) => {
512
+ const outcome = await resolveInBootstrap({ root, env: {}, group: 'nx-cache' });
513
+
514
+ expect(outcome.resolved).toEqual({ NX_REMOTE_CACHE_TOKEN: 'cache-tok' });
515
+ expect(outcome.deferred).toEqual([
516
+ { name: 'SMOO_TOKEN', group: 'shell', guidance: expect.stringContaining('smoo secrets run shell') },
517
+ ]);
518
+ // One group, one provider invocation: the shell secret's command
519
+ // did not run for a cache-token request.
520
+ expect(ledger.invocations()).toBe(1);
521
+ },
522
+ );
523
+ });
524
+ });
525
+
278
526
  it('a present variable is not reported while another refuses in CI', async () => {
279
527
  await withFixture(
280
528
  {
@@ -291,7 +539,7 @@ describe('resolveSecretEnvironment', () => {
291
539
  );
292
540
  });
293
541
 
294
- it('cowshed workspaces exclude .npmrc-referenced variables from local resolution', async () => {
542
+ it('a cowshed workspace refuses a registry credential rather than deferring it', async () => {
295
543
  await withFixture(
296
544
  {
297
545
  npmrc: '//npm.example.net/:_authToken=${SMOO_NPM_TOKEN}\n',
@@ -315,11 +563,169 @@ describe('resolveSecretEnvironment', () => {
315
563
  async (root) => {
316
564
  expect(await resolveInBootstrap({ root, env: { COWSHED_WORKSPACE_TOKEN: 'gateway-session' } })).toEqual({
317
565
  resolved: { SMOO_GENERIC: 'generic-tok' },
566
+ deferred: [],
318
567
  });
319
568
  },
320
569
  );
321
570
  });
322
571
 
572
+ // A registry credential is the second instance of the rule the resolver's
573
+ // header states: shell entry never runs a provider command for a credential
574
+ // that only a network operation needs. The reference is what makes it one.
575
+ const REGISTRY_NPMRC = '@acme:registry=https://npm.example.net\n//npm.example.net/:_authToken=${SMOO_NPM_TOKEN}\n';
576
+
577
+ it('shell entry defers a .npmrc-referenced variable and never runs its provider', async () => {
578
+ await withProviderLedger(async (ledger) => {
579
+ await withFixture(
580
+ {
581
+ npmrc: REGISTRY_NPMRC,
582
+ secrets: { SMOO_NPM_TOKEN: { command: emitAndRecord('SHELL-ENTRY-RESOLVED-VALUE', ledger.path) } },
583
+ },
584
+ async (root) => {
585
+ const outcome = await resolveInBootstrap({ root, env: {}, group: 'shell' });
586
+
587
+ expect(outcome.resolved).toEqual({});
588
+ expect(outcome.error).toBeUndefined();
589
+ expect(ledger.invocations()).toBe(0);
590
+ // A failing install prints `- <name>: <guidance>`, so both halves
591
+ // have to be here: which variable, and the one command that
592
+ // supplies it.
593
+ expect(outcome.deferred).toEqual([
594
+ {
595
+ name: 'SMOO_NPM_TOKEN',
596
+ group: 'registry',
597
+ guidance: expect.stringContaining('smoo secrets run registry <command>'),
598
+ },
599
+ ]);
600
+ },
601
+ );
602
+ });
603
+ });
604
+
605
+ it('a declared variable .npmrc does not reference still resolves at shell entry', async () => {
606
+ await withProviderLedger(async (ledger) => {
607
+ await withFixture(
608
+ { npmrc: REGISTRY_NPMRC, secrets: { SMOO_GENERIC: { command: emitAndRecord('generic-tok', ledger.path) } } },
609
+ async (root) => {
610
+ // The same .npmrc, a variable it does not name: the deferral keys on
611
+ // the reference, not on a registry existing somewhere in the repo.
612
+ expect(await resolveInBootstrap({ root, env: {}, group: 'shell' })).toEqual({
613
+ resolved: { SMOO_GENERIC: 'generic-tok' },
614
+ deferred: [],
615
+ });
616
+ expect(ledger.invocations()).toBe(1);
617
+ },
618
+ );
619
+ });
620
+ });
621
+
622
+ it('a deliberate registry run resolves the variable shell entry deferred, and nothing else', async () => {
623
+ await withProviderLedger(async (ledger) => {
624
+ await withFixture(
625
+ {
626
+ npmrc: REGISTRY_NPMRC,
627
+ secrets: {
628
+ SMOO_NPM_TOKEN: { command: emitAndRecord('registry-tok', ledger.path) },
629
+ SMOO_TOKEN: { command: emitAndRecord('shell-tok', ledger.path) },
630
+ },
631
+ },
632
+ async (root) => {
633
+ const outcome = await resolveInBootstrap({ root, env: {}, group: 'registry' });
634
+
635
+ expect(outcome.resolved).toEqual({ SMOO_NPM_TOKEN: 'registry-tok' });
636
+ // The shell secret is deferred, not resolved: one group, one
637
+ // provider invocation. A wrapper that resolved every declared
638
+ // secret would have run two.
639
+ expect(outcome.deferred).toEqual([
640
+ { name: 'SMOO_TOKEN', group: 'shell', guidance: expect.stringContaining('smoo secrets run shell') },
641
+ ]);
642
+ expect(ledger.invocations()).toBe(1);
643
+ },
644
+ );
645
+ });
646
+ });
647
+
648
+ it('a declared group is what shell entry resolves, derivation notwithstanding', async () => {
649
+ await withProviderLedger(async (ledger) => {
650
+ await withFixture(
651
+ {
652
+ npmrc: REGISTRY_NPMRC,
653
+ secrets: { SMOO_NPM_TOKEN: { command: emitAndRecord('registry-tok', ledger.path), group: 'shell' } },
654
+ },
655
+ async (root) => {
656
+ // The repository says this credential belongs to the shell, and
657
+ // that is the whole of the rule: the override is not a hint.
658
+ expect(await resolveInBootstrap({ root, env: {}, group: 'shell' })).toEqual({
659
+ resolved: { SMOO_NPM_TOKEN: 'registry-tok' },
660
+ deferred: [],
661
+ });
662
+ expect(ledger.invocations()).toBe(1);
663
+ },
664
+ );
665
+ });
666
+ });
667
+
668
+ it('an exported registry credential wins over the deferral', async () => {
669
+ await withProviderLedger(async (ledger) => {
670
+ await withFixture(
671
+ { npmrc: REGISTRY_NPMRC, secrets: { SMOO_NPM_TOKEN: { command: emitAndRecord('x', ledger.path) } } },
672
+ async (root) => {
673
+ // Nothing to defer and nothing to run: the developer who exported it
674
+ // for this terminal already answered the question.
675
+ expect(await resolveInBootstrap({ root, env: { SMOO_NPM_TOKEN: 'exported' }, group: 'shell' })).toEqual({
676
+ resolved: {},
677
+ deferred: [],
678
+ });
679
+ expect(ledger.invocations()).toBe(0);
680
+ },
681
+ );
682
+ });
683
+ });
684
+
685
+ it('CI refuses a missing registry credential instead of deferring it', async () => {
686
+ await withProviderLedger(async (ledger) => {
687
+ await withFixture(
688
+ { npmrc: REGISTRY_NPMRC, secrets: { SMOO_NPM_TOKEN: { command: emitAndRecord('x', ledger.path) } } },
689
+ async (root) => {
690
+ // CI outranks the registry rule and is unchanged by it: a job that
691
+ // needs the credential says so at setup rather than discovering it
692
+ // at the first fetch, and there is no wrapper to run there.
693
+ const outcome = await resolveInBootstrap({ root, env: { CI: 'true' }, group: 'shell' });
694
+
695
+ expect(outcome.error).toContain('SMOO_NPM_TOKEN');
696
+ expect(outcome.error).toContain('inject');
697
+ expect(outcome.error).not.toContain('smoo secrets run');
698
+ expect(ledger.invocations()).toBe(0);
699
+ },
700
+ );
701
+ });
702
+ });
703
+
704
+ it('a cowshed workspace refuses a registry credential for a network operation too', async () => {
705
+ await withProviderLedger(async (ledger) => {
706
+ await withFixture(
707
+ {
708
+ npmrc: REGISTRY_NPMRC,
709
+ secrets: { SMOO_NPM_TOKEN: { command: emitAndRecord('LOCAL-RESOLVED-VALUE', ledger.path) } },
710
+ },
711
+ async (root) => {
712
+ // The gateway is the enrollment path there, so the wrapper does not
713
+ // become a way around it.
714
+ const outcome = await resolveInBootstrap({
715
+ root,
716
+ env: { COWSHED_WORKSPACE_TOKEN: 'gateway-session' },
717
+ group: 'registry',
718
+ });
719
+
720
+ expect(outcome.error).toContain('SMOO_NPM_TOKEN');
721
+ expect(outcome.error).toContain('cowshed gateway');
722
+ expect(outcome.error).not.toContain('LOCAL-RESOLVED-VALUE');
723
+ expect(ledger.invocations()).toBe(0);
724
+ },
725
+ );
726
+ });
727
+ });
728
+
323
729
  it('provider failures name the variable, the exit code, and nothing secret-shaped', async () => {
324
730
  await withFixture(
325
731
  {