@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.
- package/README.md +68 -5
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +28 -4
- package/dist/lib/json.d.ts +22 -1
- package/dist/lib/json.d.ts.map +1 -1
- package/dist/lib/json.js +15 -3
- package/dist/monorepo/cargo-policy.d.ts +17 -0
- package/dist/monorepo/cargo-policy.d.ts.map +1 -1
- package/dist/monorepo/cargo-policy.js +73 -1
- package/dist/monorepo/index.d.ts.map +1 -1
- package/dist/monorepo/index.js +4 -2
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +6 -1
- package/dist/nx/index.d.ts +6 -0
- package/dist/nx/index.d.ts.map +1 -1
- package/dist/nx/index.js +14 -0
- package/dist/secrets/commands.d.ts +9 -5
- package/dist/secrets/commands.d.ts.map +1 -1
- package/dist/secrets/commands.js +122 -49
- package/dist/secrets/index.d.ts +42 -56
- package/dist/secrets/index.d.ts.map +1 -1
- package/dist/secrets/index.js +52 -79
- package/dist/secrets/resolver.d.ts +59 -0
- package/dist/secrets/resolver.d.ts.map +1 -0
- package/dist/secrets/resolver.js +100 -0
- package/dist/secrets/run.d.ts +23 -0
- package/dist/secrets/run.d.ts.map +1 -0
- package/dist/secrets/run.js +130 -0
- package/dist/secrets/status.d.ts +177 -0
- package/dist/secrets/status.d.ts.map +1 -0
- package/dist/secrets/status.js +724 -0
- package/dist/wrangler/deploy-stage.d.ts +1 -1
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +22 -20
- package/dist/wrangler/deployed-version.d.ts.map +1 -1
- package/dist/wrangler/deployed-version.js +7 -12
- package/dist/wrangler/flat-config.d.ts +1 -4
- package/dist/wrangler/flat-config.d.ts.map +1 -1
- package/dist/wrangler/flat-config.js +98 -44
- package/dist/wrangler/prepare-env.d.ts +4 -3
- package/dist/wrangler/prepare-env.d.ts.map +1 -1
- package/dist/wrangler/prepare-env.js +7 -5
- package/dist/wrangler/source-config.d.ts +24 -0
- package/dist/wrangler/source-config.d.ts.map +1 -0
- package/dist/wrangler/source-config.js +110 -0
- package/dist/wrangler/stage.d.ts +55 -23
- package/dist/wrangler/stage.d.ts.map +1 -1
- package/dist/wrangler/stage.js +81 -162
- package/managed/raw/tooling/direnv/devenv.smoo.nix +19 -3
- package/managed/raw/tooling/direnv/secret-references.ts +280 -76
- package/managed/raw/tooling/direnv/setup-environment.ts +58 -2
- package/managed/raw/tsconfig.lib.json +28 -0
- package/package.json +7 -2
- package/src/cli.ts +34 -5
- package/src/lib/json.ts +23 -1
- package/src/monorepo/cargo-policy.test.ts +91 -1
- package/src/monorepo/cargo-policy.ts +87 -1
- package/src/monorepo/index.ts +8 -2
- package/src/monorepo/packs/index.ts +10 -1
- package/src/monorepo/secret-references.test.ts +412 -6
- package/src/monorepo/setup-environment.test.ts +181 -0
- package/src/nx/index.test.ts +8 -0
- package/src/nx/index.ts +20 -0
- package/src/secrets/commands.test.ts +186 -4
- package/src/secrets/commands.ts +142 -51
- package/src/secrets/index.test.ts +4 -10
- package/src/secrets/index.ts +54 -115
- package/src/secrets/resolver.ts +130 -0
- package/src/secrets/run.test.ts +359 -0
- package/src/secrets/run.ts +148 -0
- package/src/secrets/status.test.ts +164 -0
- package/src/secrets/status.ts +297 -0
- package/src/wrangler/deploy-stage.test.ts +95 -2
- package/src/wrangler/deploy-stage.ts +26 -23
- package/src/wrangler/deployed-version.ts +7 -11
- package/src/wrangler/flat-config.test.ts +9 -4
- package/src/wrangler/flat-config.ts +1 -20
- package/src/wrangler/format-parity.test.ts +433 -0
- package/src/wrangler/prepare-env.ts +7 -5
- package/src/wrangler/source-config.test.ts +102 -0
- package/src/wrangler/source-config.ts +110 -0
- package/src/wrangler/stage.test.ts +61 -32
- 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
|
+
});
|
package/src/nx/index.test.ts
CHANGED
|
@@ -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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
+
});
|