@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
@@ -0,0 +1,359 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import typia from 'typia';
7
+ import { runCli } from '../cli.js';
8
+ import { secretsRun } from './run.js';
9
+
10
+ /**
11
+ * `smoo secrets run` is tested against real child processes and a real
12
+ * repository on disk, because the two things it promises are exactly the
13
+ * things a stub cannot show: that ONE group's provider commands run, and that
14
+ * argv reaches the child the way it was typed.
15
+ */
16
+
17
+ /** A provider command that records every invocation, so "no prompt" is a count and not an adjective. */
18
+ function provider(name: string, vault: string, ledger: string): [string, ...string[]] {
19
+ // The value lives outside the repository under test, so "no secret value
20
+ // on disk here" is a statement about what the command did, not about what
21
+ // the fixture happened to write into its own manifest.
22
+ return [
23
+ process.execPath,
24
+ '-e',
25
+ `const fs = require('node:fs');fs.appendFileSync(${JSON.stringify(ledger)}, '1\\n');` +
26
+ `process.stdout.write(fs.readFileSync(${JSON.stringify(join(vault, name))}, 'utf8'))`,
27
+ ];
28
+ }
29
+
30
+ /** What a recorded child wrote down about itself: how it was called, and which declared variables it could see. */
31
+ interface ChildRecord {
32
+ argv: string[];
33
+ env: Record<string, string>;
34
+ }
35
+
36
+ const isChildRecord = typia.createIs<ChildRecord>();
37
+
38
+ interface Fixture {
39
+ root: string;
40
+ /** How many provider commands have run in this repository so far. */
41
+ invocations: () => number;
42
+ /** Whatever the last recorded child wrote down about itself. */
43
+ record: () => ChildRecord;
44
+ /** Every file the command left behind, as text. */
45
+ files: () => Promise<string[]>;
46
+ }
47
+
48
+ /**
49
+ * A repository declaring one secret in each derived group: a `.npmrc`
50
+ * reference (`registry`), the declared cache token (`nx-cache`), and one that
51
+ * is neither (`shell`).
52
+ */
53
+ async function withRepo(
54
+ run: (fixture: Fixture) => Promise<void>,
55
+ options: { group?: Record<string, string> } = {},
56
+ ): Promise<void> {
57
+ const root = await mkdtemp(join(tmpdir(), 'smoo-secrets-run-'));
58
+ const vault = await mkdtemp(join(tmpdir(), 'smoo-secrets-vault-'));
59
+ const ledger = join(vault, 'provider-ledger');
60
+ const recordPath = join(root, 'child-record.json');
61
+ // The context under test is a developer machine, the only one that runs a
62
+ // provider command at all. CI and a cowshed workspace are their own tests
63
+ // below, and the suite's own environment — bun test runs under CI=true —
64
+ // must not decide which of the three this is.
65
+ const ambient = { CI: process.env.CI, COWSHED_WORKSPACE_TOKEN: process.env.COWSHED_WORKSPACE_TOKEN };
66
+ delete process.env.CI;
67
+ delete process.env.COWSHED_WORKSPACE_TOKEN;
68
+ try {
69
+ await writeFile(join(vault, 'REGISTRY_TOKEN'), 'registry-value');
70
+ await writeFile(join(vault, 'NX_CACHE_TOKEN'), 'cache-value');
71
+ await writeFile(join(vault, 'SHELL_TOKEN'), 'shell-value');
72
+ await writeFile(
73
+ join(root, 'package.json'),
74
+ JSON.stringify({
75
+ name: 'fixture',
76
+ version: '0.0.0',
77
+ smoo: {
78
+ remoteCache: { server: 'https://nx-cache.example.net', tokenSecret: 'NX_CACHE_TOKEN' },
79
+ secrets: {
80
+ REGISTRY_TOKEN: {
81
+ command: provider('REGISTRY_TOKEN', vault, ledger),
82
+ ...groupOf('REGISTRY_TOKEN', options),
83
+ },
84
+ NX_CACHE_TOKEN: {
85
+ command: provider('NX_CACHE_TOKEN', vault, ledger),
86
+ ...groupOf('NX_CACHE_TOKEN', options),
87
+ },
88
+ SHELL_TOKEN: { command: provider('SHELL_TOKEN', vault, ledger), ...groupOf('SHELL_TOKEN', options) },
89
+ },
90
+ },
91
+ }),
92
+ );
93
+ await writeFile(
94
+ join(root, '.npmrc'),
95
+ '@acme:registry=https://npm.example.net\n//npm.example.net/:_authToken=${REGISTRY_TOKEN}\n',
96
+ );
97
+ // A child that writes down exactly what it was given: its own argv, and
98
+ // which of THIS fixture's three declared variables it can see. Named
99
+ // one by one on purpose — a pattern over the whole environment would
100
+ // copy whatever the developer running the suite happens to export.
101
+ await writeFile(
102
+ join(root, 'recorder.mjs'),
103
+ `import { writeFileSync } from 'node:fs';
104
+ const declared = ['REGISTRY_TOKEN', 'NX_CACHE_TOKEN', 'SHELL_TOKEN'];
105
+ writeFileSync(${JSON.stringify(recordPath)}, JSON.stringify({
106
+ argv: process.argv.slice(2),
107
+ env: Object.fromEntries(
108
+ declared.filter((name) => process.env[name] !== undefined).map((name) => [name, process.env[name]]),
109
+ ),
110
+ }));
111
+ writeFileSync(${JSON.stringify(join(root, 'child-output.txt'))}, 'the command wrote this\\n');
112
+ `,
113
+ );
114
+ await run({
115
+ root,
116
+ invocations: () =>
117
+ existsSync(ledger)
118
+ ? readFileSync(ledger, 'utf8')
119
+ .split('\n')
120
+ .filter((line) => line.length > 0).length
121
+ : 0,
122
+ record: () => {
123
+ const parsed: unknown = JSON.parse(readFileSync(recordPath, 'utf8'));
124
+ if (!isChildRecord(parsed)) {
125
+ throw new Error(`the recorded child wrote an unreadable record: ${readFileSync(recordPath, 'utf8')}`);
126
+ }
127
+ return parsed;
128
+ },
129
+ // Everything in the repository except the record the child was asked
130
+ // to write: that one exists to prove the value arrived in the child's
131
+ // environment, so it is the instrument, not evidence against it.
132
+ files: async () => {
133
+ const names = await readdir(root);
134
+ return names
135
+ .filter((name) => name !== 'node_modules' && name !== 'child-record.json')
136
+ .map((name) => readFileSync(join(root, name), 'utf8'));
137
+ },
138
+ });
139
+ } finally {
140
+ for (const [name, value] of Object.entries(ambient)) {
141
+ if (value === undefined) {
142
+ delete process.env[name];
143
+ continue;
144
+ }
145
+ process.env[name] = value;
146
+ }
147
+ await rm(root, { recursive: true, force: true });
148
+ await rm(vault, { recursive: true, force: true });
149
+ }
150
+ }
151
+
152
+ function groupOf(name: string, options: { group?: Record<string, string> }): { group?: string } {
153
+ const group = options.group?.[name];
154
+ return group === undefined ? {} : { group };
155
+ }
156
+
157
+ /** Everything the command said, and the code it returned. */
158
+ async function capture(run: () => Promise<number>): Promise<{ code: number; output: string }> {
159
+ const lines: string[] = [];
160
+ const { log, error } = console;
161
+ console.log = (...args: unknown[]) => lines.push(args.join(' '));
162
+ console.error = (...args: unknown[]) => lines.push(args.join(' '));
163
+ try {
164
+ return { code: await run(), output: lines.join('\n') };
165
+ } finally {
166
+ console.log = log;
167
+ console.error = error;
168
+ }
169
+ }
170
+
171
+ const recorder = (root: string, ...args: string[]): string[] => [process.execPath, join(root, 'recorder.mjs'), ...args];
172
+
173
+ describe('smoo secrets run', () => {
174
+ it('with no group, lists the groups this repository declares and runs nothing', async () => {
175
+ await withRepo(async ({ root, invocations }) => {
176
+ const { code, output } = await capture(async () => secretsRun(root, undefined, []));
177
+
178
+ expect(code).toBe(2);
179
+ // The refusal IS the answer: which groups exist here, and what is in
180
+ // each. Most people meet this command once, at a 401.
181
+ expect(output).toContain('nx-cache');
182
+ expect(output).toContain('NX_CACHE_TOKEN');
183
+ expect(output).toContain('registry');
184
+ expect(output).toContain('REGISTRY_TOKEN');
185
+ expect(output).toContain('shell');
186
+ expect(output).toContain('SHELL_TOKEN');
187
+ expect(invocations()).toBe(0);
188
+ });
189
+ });
190
+
191
+ it('names an unknown group and still lists what exists', async () => {
192
+ await withRepo(async ({ root, invocations }) => {
193
+ const { code, output } = await capture(async () => secretsRun(root, 'deploy', ['echo', 'hi']));
194
+
195
+ expect(code).toBe(2);
196
+ expect(output).toContain('deploy');
197
+ expect(output).toContain('registry');
198
+ expect(output).toContain('REGISTRY_TOKEN');
199
+ expect(invocations()).toBe(0);
200
+ });
201
+ });
202
+
203
+ it('refuses a group with no command, naming what it would have supplied', async () => {
204
+ await withRepo(async ({ root, invocations }) => {
205
+ const { code, output } = await capture(async () => secretsRun(root, 'registry', []));
206
+
207
+ expect(code).toBe(2);
208
+ expect(output).toContain('REGISTRY_TOKEN');
209
+ expect(invocations()).toBe(0);
210
+ });
211
+ });
212
+
213
+ it('resolves exactly the named group, and neither group resolves the other', async () => {
214
+ await withRepo(async ({ root, invocations, record }) => {
215
+ expect(await secretsRun(root, 'registry', recorder(root))).toBe(0);
216
+
217
+ // One declared secret in the group, one provider invocation: a wrapper
218
+ // that resolved everything declared would have run three.
219
+ expect(invocations()).toBe(1);
220
+ expect(record().env).toEqual({ REGISTRY_TOKEN: 'registry-value' });
221
+
222
+ expect(await secretsRun(root, 'nx-cache', recorder(root))).toBe(0);
223
+
224
+ expect(invocations()).toBe(2);
225
+ expect(record().env).toEqual({ NX_CACHE_TOKEN: 'cache-value' });
226
+ });
227
+ });
228
+
229
+ it('puts no secret value in the child argv or on disk', async () => {
230
+ await withRepo(async ({ root, record, files }) => {
231
+ expect(await secretsRun(root, 'registry', recorder(root, '--flag', 'positional'))).toBe(0);
232
+
233
+ const { argv, env } = record();
234
+ expect(env.REGISTRY_TOKEN).toBe('registry-value');
235
+ // argv is world-readable through `ps`; the value travels in the
236
+ // environment or nowhere.
237
+ expect(argv).toEqual(['--flag', 'positional']);
238
+ for (const contents of await files()) {
239
+ expect(contents).not.toContain('registry-value');
240
+ }
241
+ });
242
+ });
243
+
244
+ it('reproduces the child exit status, including death by signal as 128+signum', async () => {
245
+ await withRepo(async ({ root }) => {
246
+ expect(await secretsRun(root, 'registry', [process.execPath, '-e', 'process.exit(17)'])).toBe(17);
247
+ expect(await secretsRun(root, 'registry', [process.execPath, '-e', 'process.kill(process.pid, "SIGTERM")'])).toBe(
248
+ 143,
249
+ );
250
+ });
251
+ });
252
+
253
+ it('refuses a command it cannot start the way a shell does', async () => {
254
+ await withRepo(async ({ root }) => {
255
+ const { code, output } = await capture(async () =>
256
+ secretsRun(root, 'registry', ['definitely-not-a-real-smoo-binary-xyz']),
257
+ );
258
+
259
+ expect(code).toBe(127);
260
+ expect(output).toContain('definitely-not-a-real-smoo-binary-xyz');
261
+ });
262
+ });
263
+
264
+ it('states a declared group that overrides the derivation', async () => {
265
+ await withRepo(
266
+ async ({ root }) => {
267
+ const { output } = await capture(async () => secretsRun(root, undefined, []));
268
+
269
+ // The reference in .npmrc derives `registry`; the entry says `shell`.
270
+ // A silent override is how the next reader loses an hour.
271
+ expect(output).toContain('REGISTRY_TOKEN declares group `shell`');
272
+ expect(output).toContain('overriding the `registry`');
273
+ },
274
+ { group: { REGISTRY_TOKEN: 'shell' } },
275
+ );
276
+ });
277
+
278
+ it('resolves an overridden group instead of the derived one', async () => {
279
+ await withRepo(
280
+ async ({ root, record, invocations }) => {
281
+ expect(await secretsRun(root, 'shell', recorder(root))).toBe(0);
282
+
283
+ expect(record().env).toEqual({ REGISTRY_TOKEN: 'registry-value', SHELL_TOKEN: 'shell-value' });
284
+ expect(invocations()).toBe(2);
285
+ },
286
+ { group: { REGISTRY_TOKEN: 'shell' } },
287
+ );
288
+ });
289
+
290
+ it('in CI, refuses with injected-secret guidance and runs no provider command', async () => {
291
+ await withRepo(async ({ root, invocations }) => {
292
+ process.env.CI = 'true';
293
+
294
+ const { code, output } = await capture(async () => secretsRun(root, 'registry', recorder(root)));
295
+
296
+ expect(code).toBe(1);
297
+ expect(output).toContain('REGISTRY_TOKEN');
298
+ expect(output).toContain('inject');
299
+ // The wrapper is not a way around an injected-secret store, so it is
300
+ // not offered as one.
301
+ expect(output).not.toContain('smoo secrets run');
302
+ expect(invocations()).toBe(0);
303
+ });
304
+ });
305
+
306
+ it('in a cowshed workspace, refuses a registry credential with gateway-enrollment guidance', async () => {
307
+ await withRepo(async ({ root, invocations }) => {
308
+ process.env.COWSHED_WORKSPACE_TOKEN = 'gateway-session';
309
+
310
+ const { code, output } = await capture(async () => secretsRun(root, 'registry', recorder(root)));
311
+
312
+ expect(code).toBe(1);
313
+ expect(output).toContain('REGISTRY_TOKEN');
314
+ expect(output).toContain('cowshed gateway');
315
+ expect(invocations()).toBe(0);
316
+ // The gateway rule is about registry credentials. A group the install
317
+ // does not read still resolves through its provider in the same
318
+ // workspace — `shell` would not, because it carries the same
319
+ // `registry` dependency shell entry does.
320
+ expect(await secretsRun(root, 'nx-cache', recorder(root))).toBe(0);
321
+ expect(invocations()).toBe(1);
322
+ });
323
+ });
324
+ });
325
+
326
+ /**
327
+ * Commander is the part of this that can silently misbehave: an option after
328
+ * the group belongs to the child, and a parser that ate `-d` would only show
329
+ * up in someone else's afternoon. This drives the real program, not
330
+ * `secretsRun`, because the parser is what is under test.
331
+ */
332
+ describe('argv passthrough through the real command line', () => {
333
+ it('hands every argument after the group to the child verbatim, flags included', async () => {
334
+ await withRepo(async ({ root, record }) => {
335
+ const cwd = process.cwd();
336
+ const exitCode = process.exitCode;
337
+ process.chdir(root);
338
+ try {
339
+ await runCli([
340
+ 'secrets',
341
+ 'run',
342
+ 'registry',
343
+ process.execPath,
344
+ join(root, 'recorder.mjs'),
345
+ 'add',
346
+ '-d',
347
+ '@acme/x',
348
+ ]);
349
+ expect(process.exitCode).toBe(0);
350
+ } finally {
351
+ process.chdir(cwd);
352
+ process.exitCode = exitCode;
353
+ }
354
+
355
+ expect(record().argv).toEqual(['add', '-d', '@acme/x']);
356
+ expect(record().env).toEqual({ REGISTRY_TOKEN: 'registry-value' });
357
+ });
358
+ });
359
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `smoo secrets run <group> <command...>` — the half of THE RULE that shell
3
+ * entry is not.
4
+ *
5
+ * The rule lives in tooling/direnv/secret-references.ts: shell entry resolves
6
+ * the `shell` group and nothing else, because a provider authorises per
7
+ * requesting process lineage and every direnv reload is a new one. Every
8
+ * other group belongs to the command that needs it, and this is that command.
9
+ * One deliberate `smoo secrets run registry bun install` instead of a
10
+ * credential prompt on every shell.
11
+ *
12
+ * Exactly one group is resolved. A repository that declares a registry
13
+ * credential and a cache token runs one provider command here, not two: a
14
+ * wrapper that resolved everything declared would fire provider commands for
15
+ * credentials the command never touches, which is the prompt this exists to
16
+ * remove.
17
+ *
18
+ * The value reaches the child the only way that leaves no trace: its
19
+ * environment. Never a file, never argv — argv is world-readable through
20
+ * `ps` — and never this process's stdout.
21
+ */
22
+
23
+ import { spawn } from 'node:child_process';
24
+ import { constants } from 'node:os';
25
+ import { type GroupedSecret, readSecretGroups, resolveSecretGroup } from './resolver.js';
26
+
27
+ /**
28
+ * Nothing ran, and the listing is the answer: no group named, a group this
29
+ * repository does not declare, or a group with no command to run. Distinct
30
+ * from 1, which is a resolution that refused, and from any other code, which
31
+ * is the child's own.
32
+ */
33
+ const REFUSED = 2;
34
+
35
+ /** A command that could not be started at all, as a shell reports it. */
36
+ const NOT_EXECUTABLE = 127;
37
+
38
+ export async function secretsRun(root: string, group: string | undefined, command: readonly string[]): Promise<number> {
39
+ let declared: GroupedSecret[];
40
+ try {
41
+ declared = await readSecretGroups(root);
42
+ } catch (error) {
43
+ console.error(error instanceof Error ? error.message : String(error));
44
+ return 1;
45
+ }
46
+
47
+ if (group === undefined) {
48
+ console.error(
49
+ 'smoo secrets run <group> <command...> runs one command with one group of secrets in its environment.',
50
+ );
51
+ printGroups(declared);
52
+ return REFUSED;
53
+ }
54
+
55
+ const members = declared.filter((secret) => secret.group === group);
56
+ if (members.length === 0) {
57
+ console.error(`smoo secrets run: no declared secret is in group \`${group}\`.`);
58
+ printGroups(declared);
59
+ return REFUSED;
60
+ }
61
+
62
+ const [program, ...args] = command;
63
+ if (program === undefined) {
64
+ console.error(
65
+ `smoo secrets run ${group} <command...>: name the command to run with ` +
66
+ `${members.map((secret) => secret.name).join(', ')} in its environment.`,
67
+ );
68
+ return REFUSED;
69
+ }
70
+
71
+ let values: Readonly<Record<string, string>>;
72
+ try {
73
+ ({ values } = await resolveSecretGroup(root, group));
74
+ } catch (error) {
75
+ console.error(error instanceof Error ? error.message : String(error));
76
+ return 1;
77
+ }
78
+
79
+ return await runWithSecrets(program, args, values);
80
+ }
81
+
82
+ /**
83
+ * The child's exit status, reproduced: a code as itself, death by signal as
84
+ * 128+signum, and a program that could not be started as 127. It shares this
85
+ * process group, so terminal signals reach it directly; it is spawned rather
86
+ * than exec'd only because neither Bun nor Node exposes `execve`.
87
+ */
88
+ async function runWithSecrets(
89
+ program: string,
90
+ args: readonly string[],
91
+ values: Readonly<Record<string, string>>,
92
+ ): Promise<number> {
93
+ return await new Promise<number>((resolve) => {
94
+ const child = spawn(program, [...args], { stdio: 'inherit', env: { ...process.env, ...values } });
95
+ child.on('error', (error: Error) => {
96
+ console.error(`smoo secrets run: cannot run \`${program}\`: ${error.message}`);
97
+ resolve(NOT_EXECUTABLE);
98
+ });
99
+ child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
100
+ if (signal !== null) {
101
+ resolve(128 + constants.signals[signal]);
102
+ return;
103
+ }
104
+ resolve(code ?? 1);
105
+ });
106
+ });
107
+ }
108
+
109
+ /**
110
+ * The groups this repository declares and the secrets in each. Most people
111
+ * meet this command exactly once, at a 401, after reading a message that
112
+ * named it — so the refusal answers "which group?" from this checkout's own
113
+ * declarations rather than printing a usage line that sends them to the
114
+ * manifest to work it out.
115
+ */
116
+ function printGroups(declared: readonly GroupedSecret[]): void {
117
+ if (declared.length === 0) {
118
+ console.error('This repository declares no smoo.secrets, so there is no group to run.');
119
+ return;
120
+ }
121
+ const members = new Map<string, string[]>();
122
+ for (const secret of declared) {
123
+ const named = members.get(secret.group);
124
+ if (named === undefined) {
125
+ members.set(secret.group, [secret.name]);
126
+ continue;
127
+ }
128
+ named.push(secret.name);
129
+ }
130
+ const groups = [...members.keys()].sort((left, right) => left.localeCompare(right));
131
+ const width = Math.max(...groups.map((group) => group.length));
132
+ console.error('');
133
+ console.error('Groups this repository declares:');
134
+ for (const group of groups) {
135
+ console.error(` ${group.padEnd(width)} ${(members.get(group) ?? []).join(', ')}`);
136
+ }
137
+ // An override is stated, never left for the next reader to discover by
138
+ // wondering why a `.npmrc` credential resolves at shell entry.
139
+ for (const secret of declared) {
140
+ if (secret.group === secret.derivedGroup) continue;
141
+ console.error(
142
+ ` note: ${secret.name} declares group \`${secret.group}\`, overriding the \`${secret.derivedGroup}\` ` +
143
+ "this repository's declarations derive.",
144
+ );
145
+ }
146
+ console.error('');
147
+ console.error(` smoo secrets run ${groups[0] ?? '<group>'} <command...>`);
148
+ }
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import {
3
+ parseSecretsStatusDocument,
4
+ projectSecretsStatus,
5
+ reconcileSecrets,
6
+ type SecretsStatusFacts,
7
+ stringifySecretsStatusDocument,
8
+ } from './status.js';
9
+
10
+ const rows = reconcileSecrets({
11
+ workerSecrets: { 'targets/billing': ['STRIPE_SECRET_KEY', 'STRIPE_PUBLISHABLE_KEY', 'E2E_CONTROL_TOKEN'] },
12
+ workflowSecrets: ['STRIPE_SECRET_KEY', 'STRIPE_PUBLISHABLE_KEY'],
13
+ secretNames: {
14
+ STRIPE_SECRET_KEY: 'STRIPE_SECRET_KEY',
15
+ STRIPE_PUBLISHABLE_KEY: 'STRIPE_PUBLISHABLE_KEY',
16
+ E2E_CONTROL_TOKEN: 'E2E_CONTROL_TOKEN',
17
+ },
18
+ localSecrets: [],
19
+ repositorySecrets: ['RETIRED_TOKEN'],
20
+ environmentSecrets: { staging: ['STRIPE_SECRET_KEY'] },
21
+ });
22
+
23
+ const facts: SecretsStatusFacts = {
24
+ repository: { repo: 'acme/app', source: 'upstream of the current branch (private)', secretCount: 1 },
25
+ environments: [
26
+ { name: 'staging', bound: true, readable: true, secretCount: 1 },
27
+ { name: 'production', bound: true, readable: false, reason: 'gh secret list --env production exited 1: HTTP 403' },
28
+ ],
29
+ rows,
30
+ stageScopes: {
31
+ 'targets/billing': { STRIPE_SECRET_KEY: ['production'], E2E_CONTROL_TOKEN: [] },
32
+ },
33
+ };
34
+
35
+ /** The stages one name ends up requiring, which is the whole point of the projection. */
36
+ function stagesFor(name: string): string[] {
37
+ const row = projectSecretsStatus(facts).secrets.find((candidate) => candidate.name === name);
38
+ if (!row) throw new Error(`no row for ${name}`);
39
+ return row.requiredByStages;
40
+ }
41
+
42
+ describe('which stages a secret is required by', () => {
43
+ it('takes the stages a wrangler project scoped it to', () => {
44
+ expect(stagesFor('STRIPE_SECRET_KEY')).toEqual(['production']);
45
+ });
46
+
47
+ it('requires a declared name absent from secretStages everywhere, because the map is an exception list', () => {
48
+ // The dangerous direction: reading "unscoped" as "needed nowhere" would
49
+ // let a production deploy sail past a secret it cannot run without.
50
+ expect(stagesFor('STRIPE_PUBLISHABLE_KEY')).toEqual(['preview', 'production', 'staging']);
51
+ });
52
+
53
+ it('requires a name mapped to [] by no stage at all', () => {
54
+ // A local-development-only value: declared, deliberately scoped to
55
+ // nothing, and no stage may refuse to deploy over it.
56
+ expect(stagesFor('E2E_CONTROL_TOKEN')).toEqual([]);
57
+ });
58
+
59
+ it('scopes nothing to a name no wrangler project declares', () => {
60
+ // Empty here means "not a Worker secret at all", which is what
61
+ // `declaredByWorkers` tells apart from "declared and scoped to nothing".
62
+ const row = projectSecretsStatus(facts).secrets.find((candidate) => candidate.name === 'RETIRED_TOKEN');
63
+
64
+ expect(row?.requiredByStages).toEqual([]);
65
+ expect(row?.declaredByWorkers).toEqual([]);
66
+ });
67
+
68
+ it('unions the scopes of every project declaring the same name', () => {
69
+ // One name, two Workers, different scopes: a value must exist wherever
70
+ // either of them deploys, so the requirement is the union and not
71
+ // whichever declaration was read last.
72
+ const shared = projectSecretsStatus({
73
+ ...facts,
74
+ stageScopes: {
75
+ 'targets/billing': { STRIPE_SECRET_KEY: ['production'] },
76
+ 'targets/mail': { STRIPE_SECRET_KEY: ['staging'] },
77
+ },
78
+ rows: reconcileSecrets({
79
+ workerSecrets: { 'targets/billing': ['STRIPE_SECRET_KEY'], 'targets/mail': ['STRIPE_SECRET_KEY'] },
80
+ workflowSecrets: [],
81
+ secretNames: { STRIPE_SECRET_KEY: 'STRIPE_SECRET_KEY' },
82
+ localSecrets: [],
83
+ repositorySecrets: [],
84
+ }),
85
+ });
86
+
87
+ expect(shared.secrets[0]?.requiredByStages).toEqual(['production', 'staging']);
88
+ });
89
+ });
90
+
91
+ describe('what the document says about satisfaction', () => {
92
+ it('counts only a bound environment that could be read as a scope a job reads', () => {
93
+ // production is bound and unreadable. Counting it as empty would refuse
94
+ // over STRIPE_SECRET_KEY, whose value may well be set there; counting it
95
+ // as holding a value would hide a real gap. It counts as neither, so
96
+ // staging alone decides - and staging does hold STRIPE_SECRET_KEY.
97
+ const document = projectSecretsStatus(facts);
98
+
99
+ expect(document.unsatisfied).toEqual([
100
+ { name: 'STRIPE_PUBLISHABLE_KEY', repositorySecret: 'STRIPE_PUBLISHABLE_KEY', missingIn: ['staging'] },
101
+ ]);
102
+ });
103
+
104
+ it('leaves missingIn empty when the repository itself is the scope to set the value in', () => {
105
+ // No environment bound: the repository is the only scope a job reads, and
106
+ // naming environments here would print commands that write nowhere useful.
107
+ const noEnvironments = projectSecretsStatus({ ...facts, environments: [] });
108
+
109
+ expect(noEnvironments.unsatisfied.map((row) => [row.name, row.missingIn])).toEqual([
110
+ ['STRIPE_PUBLISHABLE_KEY', []],
111
+ ['STRIPE_SECRET_KEY', []],
112
+ ]);
113
+ });
114
+ });
115
+
116
+ describe('the document as a wire format', () => {
117
+ it('round-trips through its own validator, stages and all', () => {
118
+ // What the CLI writes is exactly what `parseSecretsStatusDocument`
119
+ // accepts, so a repository's own operations tool validates rather than
120
+ // trusts a shape it scraped.
121
+ const validated = parseSecretsStatusDocument(stringifySecretsStatusDocument(projectSecretsStatus(facts)));
122
+
123
+ expect(validated.success).toBe(true);
124
+ if (!validated.success) throw new Error('the document its own emitter produced did not validate');
125
+ expect(validated.data.version).toBe(1);
126
+ expect(validated.data.repository).toEqual({
127
+ repo: 'acme/app',
128
+ source: 'upstream of the current branch (private)',
129
+ secretCount: 1,
130
+ });
131
+ expect(validated.data.environments).toEqual([
132
+ { name: 'staging', bound: true, readable: true, secretCount: 1 },
133
+ {
134
+ name: 'production',
135
+ bound: true,
136
+ readable: false,
137
+ reason: 'gh secret list --env production exited 1: HTTP 403',
138
+ },
139
+ ]);
140
+ expect(validated.data.secrets.find((row) => row.name === 'STRIPE_SECRET_KEY')).toEqual({
141
+ name: 'STRIPE_SECRET_KEY',
142
+ repositorySecret: 'STRIPE_SECRET_KEY',
143
+ declaredByWorkers: ['targets/billing'],
144
+ suppliedByWorkflow: true,
145
+ fetchableLocally: false,
146
+ onRepository: false,
147
+ heldByEnvironment: ['staging'],
148
+ requiredByStages: ['production'],
149
+ });
150
+ });
151
+
152
+ it('refuses to serialise a document missing a field, naming the field', () => {
153
+ // The emitter's boundary earns its place here: a projection that dropped a
154
+ // field would otherwise reach a consumer as an unexplained validation
155
+ // failure in someone else's repository, with nothing pointing back here.
156
+ const dropped = JSON.parse(
157
+ JSON.stringify(projectSecretsStatus(facts), (key, value: unknown) =>
158
+ key === 'requiredByStages' ? undefined : value,
159
+ ),
160
+ );
161
+
162
+ expect(() => stringifySecretsStatusDocument(dropped)).toThrow(/requiredByStages/);
163
+ });
164
+ });