@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
@@ -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'] },
@@ -124,9 +126,10 @@ describe('Nx helper output formatting', () => {
124
126
  project: 'cli',
125
127
  root: 'packages/cli',
126
128
  targets: ['lint'],
127
- buildDependsOn: undefined,
129
+ targetCache: new Map(),
128
130
  targetDependencies: new Map(),
129
131
  targetExecutors: new Map(),
132
+ targetInputs: new Map(),
130
133
  targetOptions: new Map(),
131
134
  targetOutputs: new Map(),
132
135
  targetScripts: new Map(),
@@ -135,9 +138,12 @@ describe('Nx helper output formatting', () => {
135
138
  project: 'web',
136
139
  root: 'packages/web',
137
140
  targets: ['build', 'test'],
138
- buildDependsOn: undefined,
141
+ targetCache: new Map([['test', true]]),
139
142
  targetDependencies: new Map([['test', ['build']]]),
140
143
  targetExecutors: new Map([['test', '@smoothbricks/nx-plugin:bounded-exec']]),
144
+ // Object inputs carry no fileset a policy can read, so only the
145
+ // strings survive the projection.
146
+ targetInputs: new Map([['test', ['default']]]),
141
147
  targetOptions: new Map([['test', { command: 'bun test', timeoutMs: 120_000 }]]),
142
148
  targetOutputs: new Map([['build', ['{projectRoot}/dist']]]),
143
149
  targetScripts: new Map(),
package/src/nx/index.ts CHANGED
@@ -12,9 +12,12 @@ export interface ProjectTargets {
12
12
  project: string;
13
13
  root?: string;
14
14
  targets: string[];
15
- buildDependsOn?: string[];
15
+ /** Per-target `cache`, for policies that only govern what Nx can restore. */
16
+ targetCache?: Map<string, boolean>;
16
17
  targetDependencies?: Map<string, string[]>;
17
18
  targetExecutors?: Map<string, string>;
19
+ /** Per-target string inputs; object inputs (`runtime`, `json`, …) are dropped. */
20
+ targetInputs?: Map<string, string[]>;
18
21
  targetOptions?: Map<string, NxTargetOptions>;
19
22
  targetOutputs?: Map<string, string[]>;
20
23
  targetScripts?: Map<string, string>;
@@ -120,6 +123,20 @@ export function targetOutputsFromNxProjectJson(value: NxProjectJson | null | und
120
123
  return targetStringArraysFromNxProjectJson(value, 'outputs');
121
124
  }
122
125
 
126
+ export function targetInputsFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, string[]> {
127
+ return targetStringArraysFromNxProjectJson(value, 'inputs');
128
+ }
129
+
130
+ export function targetCacheFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, boolean> {
131
+ const cache = new Map<string, boolean>();
132
+ for (const [targetName, target] of Object.entries(value?.targets ?? {})) {
133
+ if (typeof target.cache === 'boolean') {
134
+ cache.set(targetName, target.cache);
135
+ }
136
+ }
137
+ return cache;
138
+ }
139
+
123
140
  export function targetScriptsFromNxProjectJson(value: NxProjectJson | null | undefined): Map<string, string> {
124
141
  const targets = value?.targets;
125
142
  const scripts = new Map<string, string>();
@@ -306,8 +323,9 @@ export function projectTargetsFromNxProjects(projects: NxProjects): ProjectTarge
306
323
  project,
307
324
  root: projectRootFromNxProjectJson(metadata),
308
325
  targets: targetNamesFromNxProjectJson(metadata),
309
- buildDependsOn: targetDependencies.get('build'),
326
+ targetCache: targetCacheFromNxProjectJson(metadata),
310
327
  targetDependencies,
328
+ targetInputs: targetInputsFromNxProjectJson(metadata),
311
329
  targetExecutors: targetExecutorsFromNxProjectJson(metadata),
312
330
  targetOptions: targetOptionsFromNxProjectJson(metadata),
313
331
  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
+ });