@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,181 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ /**
9
+ * The measurement THE RULE exists for, taken against the real script.
10
+ *
11
+ * `tooling/direnv/setup-environment.ts` is what a direnv reload runs, and the
12
+ * cost the grouped rule removes is one provider invocation per shell entry
13
+ * per credential. Counting that needs the whole script — the resolver alone
14
+ * cannot show what shell entry does with it — so this builds a repository the
15
+ * way smoo manages one and runs the script in it, three times, with the
16
+ * declared provider commands pointed at a counter.
17
+ */
18
+
19
+ const MANAGED = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'managed', 'raw', 'tooling');
20
+
21
+ interface ShellEntry {
22
+ readonly exitCode: number;
23
+ readonly stderr: string;
24
+ }
25
+
26
+ interface Repository {
27
+ readonly root: string;
28
+ /** Runs the real setup-environment.ts exactly as the managed devenv shell does. */
29
+ readonly enterShell: () => Promise<ShellEntry>;
30
+ /** Provider invocations recorded so far, per declared variable. */
31
+ readonly invocations: (name: string) => number;
32
+ }
33
+
34
+ /**
35
+ * A managed repository on disk: the two managed direnv scripts, the git hooks
36
+ * they link, a `.npmrc` that interpolates one declared variable, and a fake
37
+ * TypeScript API package so the script's post-install pin check finds what it
38
+ * looks for. Nothing here is a stub of the code under test — only of the
39
+ * repository it runs in.
40
+ */
41
+ async function withManagedRepository(
42
+ options: { prepare?: string },
43
+ run: (repository: Repository) => Promise<void>,
44
+ ): Promise<void> {
45
+ const root = await mkdtemp(join(tmpdir(), 'smoo-shell-entry-'));
46
+ const ledgers = await mkdtemp(join(tmpdir(), 'smoo-shell-entry-ledger-'));
47
+ try {
48
+ // `.devenv/` is devenv's own state directory, and the setup lock is
49
+ // created inside it: a managed repository always has one by the time
50
+ // this script runs.
51
+ await mkdir(join(root, 'tooling', 'direnv', '.devenv'), { recursive: true });
52
+ await mkdir(join(root, 'tooling', 'git-hooks'), { recursive: true });
53
+ for (const name of ['setup-environment.ts', 'secret-references.ts']) {
54
+ await copyFile(join(MANAGED, 'direnv', name), join(root, 'tooling', 'direnv', name));
55
+ }
56
+ for (const hook of ['pre-commit', 'commit-msg', 'pre-push']) {
57
+ await writeFile(join(root, 'tooling', 'git-hooks', `${hook}.sh`), '#!/usr/bin/env bash\nexit 0\n');
58
+ }
59
+ await writeFile(
60
+ join(root, 'package.json'),
61
+ JSON.stringify({
62
+ name: 'fixture',
63
+ version: '0.0.0',
64
+ private: true,
65
+ ...(options.prepare === undefined ? {} : { scripts: { prepare: options.prepare } }),
66
+ smoo: {
67
+ secrets: {
68
+ SMOO_NPM_TOKEN: { command: counter('SMOO_NPM_TOKEN', ledgers) },
69
+ SMOO_TOKEN: { command: counter('SMOO_TOKEN', ledgers) },
70
+ },
71
+ },
72
+ }),
73
+ );
74
+ await writeFile(
75
+ join(root, '.npmrc'),
76
+ '@acme:registry=https://npm.example.net\n//npm.example.net/:_authToken=${SMOO_NPM_TOKEN}\n',
77
+ );
78
+ // The post-install TypeScript pin is not what this measures, and an
79
+ // install with no dependencies leaves nothing for it to find.
80
+ const api = join(root, 'node_modules', '.bun', 'typescript@6.0.3', 'node_modules', 'typescript');
81
+ await mkdir(api, { recursive: true });
82
+ await writeFile(
83
+ join(api, 'package.json'),
84
+ JSON.stringify({ name: 'typescript', version: '6.0.3', main: 'index.js' }),
85
+ );
86
+ await writeFile(join(api, 'index.js'), "module.exports = { version: '6.0.3', readConfigFile: () => ({}) };\n");
87
+ await git(root, ['init', '--quiet']);
88
+ await run({
89
+ root,
90
+ enterShell: async () => enterShell(root),
91
+ invocations: (name) => {
92
+ const path = join(ledgers, name);
93
+ return existsSync(path)
94
+ ? readFileSync(path, 'utf8')
95
+ .split('\n')
96
+ .filter((line) => line.length > 0).length
97
+ : 0;
98
+ },
99
+ });
100
+ } finally {
101
+ await rm(root, { recursive: true, force: true });
102
+ await rm(ledgers, { recursive: true, force: true });
103
+ }
104
+ }
105
+
106
+ /** A declared provider command that records each run and prints a value. */
107
+ function counter(name: string, ledgers: string): [string, ...string[]] {
108
+ return [
109
+ process.execPath,
110
+ '-e',
111
+ `const fs = require('node:fs');fs.appendFileSync(${JSON.stringify(join(ledgers, name))}, '1\\n');` +
112
+ `process.stdout.write(${JSON.stringify(`${name}-value`)})`,
113
+ ];
114
+ }
115
+
116
+ async function git(cwd: string, args: readonly string[]): Promise<void> {
117
+ const proc = Bun.spawn({ cmd: ['git', ...args], cwd, stdout: 'ignore', stderr: 'pipe', stdin: 'ignore' });
118
+ const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
119
+ if (exitCode !== 0) throw new Error(`git ${args.join(' ')} failed: ${stderr}`);
120
+ }
121
+
122
+ /**
123
+ * One shell entry: `bun "$DEVENV_ROOT/setup-environment.ts"`, which is
124
+ * verbatim what the managed devenv `enterShell` runs. The environment carries
125
+ * only what a developer machine has, so neither the CI branch nor the cowshed
126
+ * branch can decide this run.
127
+ */
128
+ async function enterShell(root: string): Promise<ShellEntry> {
129
+ const proc = Bun.spawn({
130
+ cmd: ['bun', join(root, 'tooling', 'direnv', 'setup-environment.ts')],
131
+ cwd: root,
132
+ env: {
133
+ PATH: process.env['PATH'],
134
+ HOME: process.env['HOME'],
135
+ DEVENV_ROOT: join(root, 'tooling', 'direnv'),
136
+ },
137
+ stdout: 'pipe',
138
+ stderr: 'pipe',
139
+ stdin: 'ignore',
140
+ });
141
+ const [stderr, exitCode] = await Promise.all([
142
+ new Response(proc.stderr).text(),
143
+ (async () => {
144
+ await new Response(proc.stdout).text();
145
+ return proc.exited;
146
+ })(),
147
+ ]);
148
+ return { exitCode, stderr };
149
+ }
150
+
151
+ describe('what shell entry costs, in provider invocations', () => {
152
+ it('runs no provider for a registry credential across three shell entries, and one per entry for a shell secret', async () => {
153
+ await withManagedRepository({}, async ({ enterShell: enter, invocations }) => {
154
+ for (let entry = 0; entry < 3; entry += 1) {
155
+ const { exitCode, stderr } = await enter();
156
+ expect({ entry, exitCode, stderr }).toEqual({ entry, exitCode: 0, stderr: '' });
157
+ }
158
+
159
+ // The whole point, as a count: the registry credential's provider is
160
+ // never asked, so there is no credential prompt on any reload.
161
+ expect(invocations('SMOO_NPM_TOKEN')).toBe(0);
162
+ // And nothing else changed: a shell secret still resolves once per
163
+ // shell entry, exactly as it did before groups existed.
164
+ expect(invocations('SMOO_TOKEN')).toBe(3);
165
+ });
166
+ });
167
+
168
+ it('names the exact grouped command when the install it ran fails', async () => {
169
+ // A failing root prepare script fails `bun install` locally without
170
+ // needing a registry: the degraded path is what prints the deferrals.
171
+ await withManagedRepository({ prepare: 'exit 1' }, async ({ enterShell: enter, invocations }) => {
172
+ const { exitCode, stderr } = await enter();
173
+
174
+ // A local failure must not take the shell down with it.
175
+ expect(exitCode).toBe(0);
176
+ expect(stderr).toContain('SMOO_NPM_TOKEN (registry)');
177
+ expect(stderr).toContain('smoo secrets run registry bun install');
178
+ expect(invocations('SMOO_NPM_TOKEN')).toBe(0);
179
+ });
180
+ });
181
+ });
@@ -109,7 +109,9 @@ describe('Nx helper output formatting', () => {
109
109
  targets: {
110
110
  test: {
111
111
  executor: '@smoothbricks/nx-plugin:bounded-exec',
112
+ cache: true,
112
113
  dependsOn: ['build'],
114
+ inputs: ['default', { runtime: 'echo version' }],
113
115
  options: { command: 'bun test', timeoutMs: 120_000 },
114
116
  },
115
117
  build: { outputs: ['{projectRoot}/dist'] },
@@ -125,8 +127,10 @@ describe('Nx helper output formatting', () => {
125
127
  root: 'packages/cli',
126
128
  targets: ['lint'],
127
129
  buildDependsOn: undefined,
130
+ targetCache: new Map(),
128
131
  targetDependencies: new Map(),
129
132
  targetExecutors: new Map(),
133
+ targetInputs: new Map(),
130
134
  targetOptions: new Map(),
131
135
  targetOutputs: new Map(),
132
136
  targetScripts: new Map(),
@@ -136,8 +140,12 @@ describe('Nx helper output formatting', () => {
136
140
  root: 'packages/web',
137
141
  targets: ['build', 'test'],
138
142
  buildDependsOn: undefined,
143
+ targetCache: new Map([['test', true]]),
139
144
  targetDependencies: new Map([['test', ['build']]]),
140
145
  targetExecutors: new Map([['test', '@smoothbricks/nx-plugin:bounded-exec']]),
146
+ // Object inputs carry no fileset a policy can read, so only the
147
+ // strings survive the projection.
148
+ targetInputs: new Map([['test', ['default']]]),
141
149
  targetOptions: new Map([['test', { command: 'bun test', timeoutMs: 120_000 }]]),
142
150
  targetOutputs: new Map([['build', ['{projectRoot}/dist']]]),
143
151
  targetScripts: new Map(),
package/src/nx/index.ts CHANGED
@@ -13,8 +13,12 @@ export interface ProjectTargets {
13
13
  root?: string;
14
14
  targets: string[];
15
15
  buildDependsOn?: string[];
16
+ /** Per-target `cache`, for policies that only govern what Nx can restore. */
17
+ targetCache?: Map<string, boolean>;
16
18
  targetDependencies?: Map<string, string[]>;
17
19
  targetExecutors?: Map<string, string>;
20
+ /** Per-target string inputs; object inputs (`runtime`, `json`, …) are dropped. */
21
+ targetInputs?: Map<string, string[]>;
18
22
  targetOptions?: Map<string, NxTargetOptions>;
19
23
  targetOutputs?: Map<string, string[]>;
20
24
  targetScripts?: Map<string, string>;
@@ -120,6 +124,20 @@ export function targetOutputsFromNxProjectJson(value: NxProjectJson | null | und
120
124
  return targetStringArraysFromNxProjectJson(value, 'outputs');
121
125
  }
122
126
 
127
+ export function targetInputsFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, string[]> {
128
+ return targetStringArraysFromNxProjectJson(value, 'inputs');
129
+ }
130
+
131
+ export function targetCacheFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, boolean> {
132
+ const cache = new Map<string, boolean>();
133
+ for (const [targetName, target] of Object.entries(value?.targets ?? {})) {
134
+ if (typeof target.cache === 'boolean') {
135
+ cache.set(targetName, target.cache);
136
+ }
137
+ }
138
+ return cache;
139
+ }
140
+
123
141
  export function targetScriptsFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, string> {
124
142
  const targets = value?.targets;
125
143
  const scripts = new Map<string, string>();
@@ -307,7 +325,9 @@ export function projectTargetsFromNxProjects(projects: NxProjects): ProjectTarge
307
325
  root: projectRootFromNxProjectJson(metadata),
308
326
  targets: targetNamesFromNxProjectJson(metadata),
309
327
  buildDependsOn: targetDependencies.get('build'),
328
+ targetCache: targetCacheFromNxProjectJson(metadata),
310
329
  targetDependencies,
330
+ targetInputs: targetInputsFromNxProjectJson(metadata),
311
331
  targetExecutors: targetExecutorsFromNxProjectJson(metadata),
312
332
  targetOptions: targetOptionsFromNxProjectJson(metadata),
313
333
  targetOutputs: targetOutputsFromNxProjectJson(metadata),
@@ -1,11 +1,14 @@
1
- import { describe, expect, it } from 'bun:test';
2
- import { secretsMissingInEnvironment } from './commands.js';
3
- import { reconcileSecrets } from './index.js';
1
+ import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { secretsMissingInEnvironment, secretsStatus } from './commands.js';
6
+ import { parseSecretsStatusDocument, reconcileSecrets } from './status.js';
4
7
 
5
8
  const sources = {
6
9
  workerSecrets: { 'targets/billing': ['PAYMENTS_PUBLISHABLE_KEY', 'PAYMENTS_SECRET_KEY'] },
7
10
  workflowSecrets: ['PAYMENTS_PUBLISHABLE_KEY', 'PAYMENTS_SECRET_KEY'],
8
- localCommands: [],
11
+ localSecrets: [],
9
12
  secretNames: {
10
13
  PAYMENTS_PUBLISHABLE_KEY: 'PAYMENTS_PUBLISHABLE_KEY',
11
14
  PAYMENTS_SECRET_KEY: 'PAYMENTS_SECRET_KEY',
@@ -26,3 +29,182 @@ describe('what `smoo secrets set --env` walks', () => {
26
29
  expect(walked.map((row) => row.name)).toEqual(['PAYMENTS_SECRET_KEY']);
27
30
  });
28
31
  });
32
+
33
+ /**
34
+ * A whole `smoo secrets status` run against a checkout on disk and a `gh` that
35
+ * answers from a script rather than the network. Anything less would test the
36
+ * projection twice and the command not at all: whether `--json` writes one
37
+ * document and nothing else, and whether it still refuses, are properties of
38
+ * this function's own output.
39
+ */
40
+ describe('a real `smoo secrets status` run', () => {
41
+ let root = '';
42
+ let path = '';
43
+
44
+ beforeAll(async () => {
45
+ root = await mkdtemp(join(tmpdir(), 'smoo-secrets-status-'));
46
+ await writeFile(
47
+ join(root, 'package.json'),
48
+ `${JSON.stringify(
49
+ {
50
+ name: 'acme-app',
51
+ private: true,
52
+ workspaces: ['targets/*'],
53
+ repository: { type: 'git', url: 'https://github.com/acme/app.git' },
54
+ smoo: {
55
+ github: {
56
+ deploySecrets: {
57
+ MAIL_CAPTURE_CONTROL_TOKEN: 'MAIL_CAPTURE_CONTROL_TOKEN',
58
+ STRIPE_PUBLISHABLE_KEY: 'STRIPE_PUBLISHABLE_KEY',
59
+ STRIPE_SECRET_KEY: 'STRIPE_SECRET_KEY',
60
+ },
61
+ },
62
+ secrets: { NPM_READ_TOKEN: { command: ['printf', 'unused'] } },
63
+ },
64
+ },
65
+ null,
66
+ 2,
67
+ )}\n`,
68
+ );
69
+ await mkdir(join(root, '.github', 'workflows'), { recursive: true });
70
+ await writeFile(
71
+ join(root, '.github', 'workflows', 'ci.yml'),
72
+ `name: CI
73
+ on: push
74
+ jobs:
75
+ validate:
76
+ runs-on: ubuntu-latest
77
+ environment: staging
78
+ steps:
79
+ - run: echo build
80
+ release:
81
+ runs-on: ubuntu-latest
82
+ environment: production
83
+ steps:
84
+ - run: echo deploy
85
+ `,
86
+ );
87
+ await mkdir(join(root, 'targets', 'billing'), { recursive: true });
88
+ await writeFile(
89
+ join(root, 'targets', 'billing', 'package.json'),
90
+ `${JSON.stringify(
91
+ {
92
+ name: '@acme/billing',
93
+ version: '0.0.0',
94
+ private: true,
95
+ smoo: { wrangler: { secretStages: { STRIPE_SECRET_KEY: ['production'], E2E_CONTROL_TOKEN: [] } } },
96
+ },
97
+ null,
98
+ 2,
99
+ )}\n`,
100
+ );
101
+ await writeFile(
102
+ join(root, 'targets', 'billing', '.dev.vars.example'),
103
+ 'STRIPE_SECRET_KEY=""\nSTRIPE_PUBLISHABLE_KEY=""\nE2E_CONTROL_TOKEN=""\n',
104
+ );
105
+ // `gh` from a script: the repository holds two secrets, staging holds one,
106
+ // and production refuses - the three answers the command has to tell apart.
107
+ const bin = join(root, 'bin');
108
+ await mkdir(bin, { recursive: true });
109
+ await writeFile(
110
+ join(bin, 'gh'),
111
+ `#!/bin/sh
112
+ case "$*" in
113
+ *"--env production"*) echo "HTTP 403: Resource not accessible" >&2; exit 1 ;;
114
+ *"--env staging"*) echo '[{"name":"MAIL_CAPTURE_CONTROL_TOKEN"}]' ;;
115
+ *) echo '[{"name":"NPM_READ_TOKEN"},{"name":"RETIRED_TOKEN"}]' ;;
116
+ esac
117
+ `,
118
+ { mode: 0o755 },
119
+ );
120
+ path = process.env.PATH ?? '';
121
+ process.env.PATH = `${bin}:${path}`;
122
+ });
123
+
124
+ afterAll(async () => {
125
+ process.env.PATH = path;
126
+ await rm(root, { recursive: true, force: true });
127
+ });
128
+
129
+ async function run(options: { repo?: string; env?: string; json?: boolean }): Promise<{
130
+ code: number;
131
+ out: string[];
132
+ errors: string[];
133
+ }> {
134
+ const out: string[] = [];
135
+ const errors: string[] = [];
136
+ const [log, error] = [console.log, console.error];
137
+ console.log = (...args: unknown[]) => out.push(args.map(String).join(' '));
138
+ console.error = (...args: unknown[]) => errors.push(args.map(String).join(' '));
139
+ try {
140
+ return { code: await secretsStatus(root, options), out, errors };
141
+ } finally {
142
+ [console.log, console.error] = [log, error];
143
+ }
144
+ }
145
+
146
+ it('writes one document to stdout and nothing else, and still refuses', async () => {
147
+ const { code, out } = await run({ repo: 'acme/app', json: true });
148
+
149
+ expect(out).toHaveLength(1);
150
+ const validated = parseSecretsStatusDocument(out[0] ?? '');
151
+ expect(validated.success).toBe(true);
152
+ if (!validated.success) throw new Error('`--json` wrote a document its own validator rejects');
153
+ expect(validated.data.repository).toEqual({ repo: 'acme/app', source: 'requested', secretCount: 2 });
154
+ expect(validated.data.environments).toEqual([
155
+ {
156
+ name: 'production',
157
+ bound: true,
158
+ readable: false,
159
+ reason:
160
+ 'gh secret list --json name --repo acme/app --env production exited 1: HTTP 403: Resource not accessible',
161
+ },
162
+ { name: 'staging', bound: true, readable: true, secretCount: 1 },
163
+ ]);
164
+ expect(validated.data.unsatisfied).toEqual([
165
+ { name: 'STRIPE_PUBLISHABLE_KEY', repositorySecret: 'STRIPE_PUBLISHABLE_KEY', missingIn: ['staging'] },
166
+ { name: 'STRIPE_SECRET_KEY', repositorySecret: 'STRIPE_SECRET_KEY', missingIn: ['staging'] },
167
+ ]);
168
+ // A machine-readable status nobody can gate on would be worse than none.
169
+ expect(code).toBe(1);
170
+ });
171
+
172
+ it('carries the stages `smoo.wrangler.secretStages` declares, per name', async () => {
173
+ const { out } = await run({ repo: 'acme/app', json: true });
174
+ const validated = parseSecretsStatusDocument(out[0] ?? '');
175
+ if (!validated.success) throw new Error('`--json` wrote a document its own validator rejects');
176
+ const stages = Object.fromEntries(validated.data.secrets.map((row) => [row.name, row.requiredByStages]));
177
+
178
+ expect(stages).toEqual({
179
+ // Scoped to production by the wrangler project.
180
+ STRIPE_SECRET_KEY: ['production'],
181
+ // Declared and absent from the map, so every stage requires it.
182
+ STRIPE_PUBLISHABLE_KEY: ['preview', 'production', 'staging'],
183
+ // Declared and mapped to []: a local-only value no stage requires.
184
+ E2E_CONTROL_TOKEN: [],
185
+ // Names no wrangler project declares have no stages to require them.
186
+ MAIL_CAPTURE_CONTROL_TOKEN: [],
187
+ NPM_READ_TOKEN: [],
188
+ RETIRED_TOKEN: [],
189
+ });
190
+ });
191
+
192
+ it('prints the same facts as a table without the flag', async () => {
193
+ const { code, out, errors } = await run({ repo: 'acme/app' });
194
+
195
+ expect(out[0]).toBe('repository acme/app (requested), holding 2 secrets');
196
+ expect(out[1]).toBe('environment staging, holding 1 secrets');
197
+ expect(out[2]).toBe(
198
+ 'environment production could not be read, so nothing below claims what it holds: ' +
199
+ 'gh secret list --json name --repo acme/app --env production exited 1: HTTP 403: Resource not accessible',
200
+ );
201
+ expect(out.find((line) => line.startsWith('ABSENT') && line.includes('STRIPE_SECRET_KEY'))).toMatch(
202
+ /^ABSENT +STRIPE_SECRET_KEY +workflow +targets\/billing$/,
203
+ );
204
+ expect(errors).toContain(
205
+ 'missing: STRIPE_SECRET_KEY — a workflow passes secrets.STRIPE_SECRET_KEY and no value exists in staging.',
206
+ );
207
+ expect(errors).toContain(' smoo secrets set STRIPE_SECRET_KEY -R acme/app --env staging');
208
+ expect(code).toBe(1);
209
+ });
210
+ });