@smoothbricks/cli 0.11.15 → 0.11.17

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 (70) hide show
  1. package/README.md +43 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +44 -0
  4. package/dist/github-ci/index.d.ts +1 -1
  5. package/dist/github-ci/index.d.ts.map +1 -1
  6. package/dist/github-ci/index.js +7 -10
  7. package/dist/lib/json.d.ts +9 -0
  8. package/dist/lib/json.d.ts.map +1 -1
  9. package/dist/lib/json.js +65 -53
  10. package/dist/lib/secret-names.d.ts +35 -0
  11. package/dist/lib/secret-names.d.ts.map +1 -0
  12. package/dist/lib/secret-names.js +61 -0
  13. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  14. package/dist/monorepo/ci-workflow.js +15 -6
  15. package/dist/monorepo/managed-files.d.ts +9 -0
  16. package/dist/monorepo/managed-files.d.ts.map +1 -1
  17. package/dist/monorepo/managed-files.js +28 -2
  18. package/dist/monorepo/publish-workflow.d.ts +10 -1
  19. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  20. package/dist/monorepo/publish-workflow.js +110 -22
  21. package/dist/release/index.d.ts +1 -1
  22. package/dist/release/index.d.ts.map +1 -1
  23. package/dist/release/index.js +26 -4
  24. package/dist/release/private-npm.d.ts +5 -0
  25. package/dist/release/private-npm.d.ts.map +1 -1
  26. package/dist/release/private-npm.js +26 -5
  27. package/dist/secrets/commands.d.ts +45 -0
  28. package/dist/secrets/commands.d.ts.map +1 -0
  29. package/dist/secrets/commands.js +189 -0
  30. package/dist/secrets/index.d.ts +83 -0
  31. package/dist/secrets/index.d.ts.map +1 -0
  32. package/dist/secrets/index.js +149 -0
  33. package/dist/wrangler/deploy-stage.d.ts +13 -2
  34. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  35. package/dist/wrangler/deploy-stage.js +39 -54
  36. package/dist/wrangler/deployed-version.d.ts +44 -0
  37. package/dist/wrangler/deployed-version.d.ts.map +1 -0
  38. package/dist/wrangler/deployed-version.js +87 -0
  39. package/dist/wrangler/live-version.d.ts +146 -0
  40. package/dist/wrangler/live-version.d.ts.map +1 -0
  41. package/dist/wrangler/live-version.js +326 -0
  42. package/managed/raw/tooling/direnv/devenv.smoo.nix +4 -2
  43. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +4 -1
  44. package/managed/raw/tooling/direnv/toolchain-stamp.ts +88 -0
  45. package/managed/templates/github/actions/setup-devenv/action.yml +1 -1
  46. package/package.json +2 -2
  47. package/src/cli.ts +46 -1
  48. package/src/github-ci/index.test.ts +92 -8
  49. package/src/github-ci/index.ts +7 -10
  50. package/src/lib/json.ts +9 -0
  51. package/src/lib/secret-names.ts +70 -0
  52. package/src/monorepo/__tests__/ci-workflow.test.ts +27 -2
  53. package/src/monorepo/__tests__/publish-workflow.test.ts +85 -13
  54. package/src/monorepo/ci-workflow.ts +17 -6
  55. package/src/monorepo/managed-files.test.ts +26 -0
  56. package/src/monorepo/managed-files.ts +36 -2
  57. package/src/monorepo/publish-workflow.ts +135 -21
  58. package/src/monorepo/secret-references.test.ts +1 -1
  59. package/src/release/__tests__/private-npm-status.test.ts +37 -0
  60. package/src/release/__tests__/private-npm-workflow.test.ts +20 -0
  61. package/src/release/index.ts +27 -2
  62. package/src/release/private-npm.ts +30 -5
  63. package/src/secrets/commands.ts +210 -0
  64. package/src/secrets/index.test.ts +92 -0
  65. package/src/secrets/index.ts +183 -0
  66. package/src/wrangler/deploy-stage.test.ts +190 -12
  67. package/src/wrangler/deploy-stage.ts +72 -45
  68. package/src/wrangler/deployed-version.test.ts +150 -0
  69. package/src/wrangler/deployed-version.ts +130 -0
  70. package/src/wrangler/live-version.ts +363 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * `smoo secrets` — the operator side of the reconciliation in ./index.ts.
3
+ *
4
+ * Values are handed to `gh` over stdin, never through argv (a process list is
5
+ * world-readable) and never printed. Reading one from the terminal disables
6
+ * echo, so a pasted key does not end up in a scrollback buffer or a screen
7
+ * share.
8
+ */
9
+
10
+ import { spawn, spawnSync } from 'node:child_process';
11
+ import { getWorkspacePackages } from '../lib/workspace.js';
12
+ import {
13
+ fetchLocalSecret,
14
+ localSecretCommandNames,
15
+ reconcileSecrets,
16
+ type SecretRow,
17
+ secretNameMapping,
18
+ unsatisfiedSecrets,
19
+ unwiredSecrets,
20
+ workerSecretNames,
21
+ workflowSecretNames,
22
+ } from './index.js';
23
+
24
+ /** Repository secret names GitHub currently holds, or a refusal naming what failed. */
25
+ export function readRepositorySecrets(
26
+ repo: string | undefined,
27
+ ): { ok: true; names: string[] } | { ok: false; reason: string } {
28
+ const args = ['secret', 'list', '--json', 'name'];
29
+ if (repo) args.push('--repo', repo);
30
+ const result = spawnSync('gh', args, { encoding: 'utf8' });
31
+ if (result.status !== 0) {
32
+ return {
33
+ ok: false,
34
+ reason: `gh ${args.join(' ')} exited ${result.status ?? 'without status'}: ${result.stderr.trim()}`,
35
+ };
36
+ }
37
+ const parsed: unknown = JSON.parse(result.stdout);
38
+ if (!Array.isArray(parsed)) {
39
+ return { ok: false, reason: 'gh secret list did not return a list' };
40
+ }
41
+ const names: string[] = [];
42
+ for (const row of parsed) {
43
+ if (typeof row !== 'object' || row === null || !('name' in row)) continue;
44
+ // `in` narrows the property to unknown, so the typeof check below is the
45
+ // only thing that admits it — no assertion about gh's output shape.
46
+ const name: unknown = row.name;
47
+ if (typeof name === 'string') names.push(name);
48
+ }
49
+ return { ok: true, names: names.sort((left, right) => left.localeCompare(right)) };
50
+ }
51
+
52
+ /** Write one secret, value over stdin so it never reaches argv. */
53
+ export async function writeRepositorySecret(
54
+ name: string,
55
+ value: string,
56
+ repo: string | undefined,
57
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
58
+ const args = ['secret', 'set', name];
59
+ if (repo) args.push('--repo', repo);
60
+ const child = spawn('gh', args, { stdio: ['pipe', 'inherit', 'inherit'] });
61
+ child.stdin.end(value);
62
+ const status = await new Promise<number | null>((resolvePromise) => {
63
+ child.on('close', (code) => resolvePromise(code));
64
+ });
65
+ return status === 0
66
+ ? { ok: true }
67
+ : { ok: false, reason: `gh ${args.join(' ')} exited ${status ?? 'without status'}` };
68
+ }
69
+
70
+ function collectSources(root: string, repositorySecrets: readonly string[]) {
71
+ const workspaceDirs = getWorkspacePackages(root).map((pkg) => pkg.path);
72
+ const workerSecrets = workerSecretNames(root, workspaceDirs);
73
+ const workflowSecrets = workflowSecretNames(root);
74
+ const localCommands = localSecretCommandNames(root);
75
+ const envNames = [
76
+ ...new Set([...Object.values(workerSecrets).flatMap((names) => [...names]), ...workflowSecrets, ...localCommands]),
77
+ ];
78
+ return {
79
+ workerSecrets,
80
+ workflowSecrets,
81
+ secretNames: secretNameMapping(root, envNames),
82
+ localCommands,
83
+ repositorySecrets,
84
+ };
85
+ }
86
+
87
+ function describe(row: SecretRow): string {
88
+ const where = row.declaredByWorkers.length > 0 ? row.declaredByWorkers.join(', ') : '—';
89
+ return [
90
+ row.onRepository ? 'set ' : 'ABSENT',
91
+ row.name.padEnd(32),
92
+ row.suppliedByWorkflow ? 'workflow' : ' ',
93
+ row.fetchableLocally ? 'local' : ' ',
94
+ where,
95
+ ].join(' ');
96
+ }
97
+
98
+ /**
99
+ * Print every known secret with its sources. Exit code 1 when a workflow
100
+ * promises a value the repository does not hold — that combination is the one
101
+ * that fails a deploy, and it fails talking about the value rather than the
102
+ * missing secret.
103
+ */
104
+ export function secretsStatus(root: string, options: { repo?: string }): number {
105
+ const repositorySecrets = readRepositorySecrets(options.repo);
106
+ if (!repositorySecrets.ok) {
107
+ console.error(repositorySecrets.reason);
108
+ return 1;
109
+ }
110
+ const rows = reconcileSecrets(collectSources(root, repositorySecrets.names));
111
+ console.log('state name workflow local declared by');
112
+ for (const row of rows) console.log(describe(row));
113
+
114
+ const unsatisfied = unsatisfiedSecrets(rows);
115
+ const unwired = unwiredSecrets(rows);
116
+ if (unwired.length > 0) {
117
+ console.log('');
118
+ for (const row of unwired) {
119
+ console.log(
120
+ `note: ${row.name} is declared by ${row.declaredByWorkers.join(', ')} but no managed workflow passes it; ` +
121
+ 'declare it in smoo.github.deploySecrets to have CI supply it.',
122
+ );
123
+ }
124
+ }
125
+ if (unsatisfied.length > 0) {
126
+ console.log('');
127
+ for (const row of unsatisfied) {
128
+ console.error(`missing: ${row.name} — a workflow passes secrets.${row.name} and the repository has no value.`);
129
+ console.error(
130
+ ` set it with: smoo secrets set ${row.name}${options.repo ? ` --repo ${options.repo}` : ''}`,
131
+ );
132
+ }
133
+ return 1;
134
+ }
135
+ return 0;
136
+ }
137
+
138
+ /** Read a value with echo disabled when stdin is a terminal; otherwise read piped input. */
139
+ async function readSecretValue(prompt: string): Promise<string> {
140
+ if (!process.stdin.isTTY) {
141
+ const chunks: Buffer[] = [];
142
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
143
+ return Buffer.concat(chunks).toString('utf8').replace(/\n$/, '');
144
+ }
145
+ process.stderr.write(prompt);
146
+ process.stdin.setRawMode(true);
147
+ let value = '';
148
+ try {
149
+ for await (const chunk of process.stdin) {
150
+ const text = Buffer.from(chunk).toString('utf8');
151
+ if (text === '\r' || text === '\n' || text === '\u0004') break;
152
+ if (text === '\u0003') throw new Error('cancelled');
153
+ if (text === '\u007f') {
154
+ value = value.slice(0, -1);
155
+ continue;
156
+ }
157
+ value += text;
158
+ }
159
+ } finally {
160
+ process.stdin.setRawMode(false);
161
+ process.stderr.write('\n');
162
+ }
163
+ return value;
164
+ }
165
+
166
+ /** Set one secret from a pasted value. */
167
+ export async function secretsSet(name: string, options: { repo?: string }): Promise<number> {
168
+ const value = await readSecretValue(`Value for ${name} (not echoed): `);
169
+ if (value.length === 0) {
170
+ console.error(`${name}: refusing to set an empty value.`);
171
+ return 1;
172
+ }
173
+ const written = await writeRepositorySecret(name, value, options.repo);
174
+ if (!written.ok) {
175
+ console.error(written.reason);
176
+ return 1;
177
+ }
178
+ console.log(`set ${name}`);
179
+ return 0;
180
+ }
181
+
182
+ /**
183
+ * Push every locally fetchable secret to the repository. Only names
184
+ * `smoo.secrets` declares a command for: everything else has no source here
185
+ * and must be pasted with `smoo secrets set`.
186
+ */
187
+ export async function secretsSync(root: string, options: { repo?: string }): Promise<number> {
188
+ const names = localSecretCommandNames(root);
189
+ if (names.length === 0) {
190
+ console.error('smoo.secrets declares no fetch commands; nothing to sync.');
191
+ return 1;
192
+ }
193
+ let failed = 0;
194
+ for (const name of names) {
195
+ const fetched = fetchLocalSecret(root, name);
196
+ if (!fetched.ok) {
197
+ console.error(`skip ${name}: ${fetched.reason}`);
198
+ failed += 1;
199
+ continue;
200
+ }
201
+ const written = await writeRepositorySecret(name, fetched.value, options.repo);
202
+ if (!written.ok) {
203
+ console.error(`fail ${name}: ${written.reason}`);
204
+ failed += 1;
205
+ continue;
206
+ }
207
+ console.log(`set ${name}`);
208
+ }
209
+ return failed === 0 ? 0 : 1;
210
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { repositorySecretMapping } from '../lib/secret-names.js';
3
+ import { reconcileSecrets, unsatisfiedSecrets, unwiredSecrets } from './index.js';
4
+
5
+ const sources = {
6
+ workerSecrets: {
7
+ 'targets/billing': ['STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY'],
8
+ 'targets/mail': ['MAIL_CAPTURE_CONTROL_TOKEN'],
9
+ },
10
+ workflowSecrets: ['MAIL_CAPTURE_CONTROL_TOKEN', 'STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY'],
11
+ localCommands: ['NPM_READ_TOKEN'],
12
+ secretNames: {
13
+ STRIPE_PUBLISHABLE_KEY: 'STRIPE_PUBLISHABLE_KEY',
14
+ STRIPE_SECRET_KEY: 'STRIPE_SECRET_KEY',
15
+ MAIL_CAPTURE_CONTROL_TOKEN: 'MAIL_CAPTURE_CONTROL_TOKEN',
16
+ NPM_READ_TOKEN: 'NPM_READ_TOKEN',
17
+ },
18
+ repositorySecrets: ['MAIL_CAPTURE_CONTROL_TOKEN', 'NPM_READ_TOKEN'],
19
+ };
20
+
21
+ describe('secret reconciliation', () => {
22
+ it('names the secret a workflow passes and the repository does not hold', () => {
23
+ // A preview stage refuses with `publishable_key_mismatch` when `ci.yml`
24
+ // passes `secrets.STRIPE_PUBLISHABLE_KEY` and the repository holds no such
25
+ // secret: the empty value reaches the payment library's validator, which
26
+ // talks about the key instead of the missing declaration.
27
+ const unsatisfied = unsatisfiedSecrets(reconcileSecrets(sources)).map((row) => row.name);
28
+
29
+ expect(unsatisfied).toEqual(['STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY']);
30
+ });
31
+
32
+ it('keeps "declared but no workflow passes it" separate from "no value"', () => {
33
+ // The remedies differ: one is `smoo secrets set`, the other is a
34
+ // smoo.github.deploySecrets entry. Reporting them as one list sends an
35
+ // operator to the wrong fix.
36
+ const rows = reconcileSecrets({
37
+ ...sources,
38
+ workflowSecrets: ['MAIL_CAPTURE_CONTROL_TOKEN'],
39
+ });
40
+
41
+ expect(unwiredSecrets(rows).map((row) => row.name)).toEqual(['STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY']);
42
+ expect(unsatisfiedSecrets(rows)).toEqual([]);
43
+ });
44
+
45
+ it('reports a repository secret no source declares, so a stale one is visible', () => {
46
+ const rows = reconcileSecrets({ ...sources, repositorySecrets: [...sources.repositorySecrets, 'RETIRED_TOKEN'] });
47
+ const retired = rows.find((row) => row.name === 'RETIRED_TOKEN');
48
+
49
+ expect(retired).toEqual({
50
+ name: 'RETIRED_TOKEN',
51
+ repositorySecret: 'RETIRED_TOKEN',
52
+ declaredByWorkers: [],
53
+ suppliedByWorkflow: false,
54
+ fetchableLocally: false,
55
+ onRepository: true,
56
+ });
57
+ });
58
+
59
+ it('attributes a shared secret to every worker that declares it', () => {
60
+ const rows = reconcileSecrets({
61
+ ...sources,
62
+ workerSecrets: { 'targets/a': ['SHARED'], 'targets/b': ['SHARED'] },
63
+ });
64
+
65
+ expect(rows.find((row) => row.name === 'SHARED')?.declaredByWorkers).toEqual(['targets/a', 'targets/b']);
66
+ });
67
+
68
+ it('reads a reserved env name from its owner-prefixed secret, with no declaration', () => {
69
+ // GitHub rejects `gh secret set GITHUB_*`, so the value cannot live under
70
+ // its own name. Operators write the owner-prefixed name by hand in the
71
+ // workflow; the convention derives exactly that from the repository owner.
72
+ const rows = reconcileSecrets({
73
+ workerSecrets: { 'targets/backend': ['GITHUB_CLIENT_SECRET'] },
74
+ workflowSecrets: ['GITHUB_CLIENT_SECRET'],
75
+ secretNames: repositorySecretMapping(['GITHUB_CLIENT_SECRET'], 'acme'),
76
+ localCommands: [],
77
+ repositorySecrets: ['ACME_GITHUB_CLIENT_SECRET'],
78
+ });
79
+
80
+ expect(rows).toEqual([
81
+ {
82
+ name: 'GITHUB_CLIENT_SECRET',
83
+ repositorySecret: 'ACME_GITHUB_CLIENT_SECRET',
84
+ declaredByWorkers: ['targets/backend'],
85
+ suppliedByWorkflow: true,
86
+ fetchableLocally: false,
87
+ onRepository: true,
88
+ },
89
+ ]);
90
+ expect(unsatisfiedSecrets(rows)).toEqual([]);
91
+ });
92
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Reconciles the three places a repository's secrets are described, all of
3
+ * which smoo already owns:
4
+ *
5
+ * 1. **Workers declare names.** `.dev.vars.example` per wrangler project — the
6
+ * file `wrangler types --env-file` reads and `prepare-env` prompts from.
7
+ * 2. **Workflows declare where values come from.** `smoo.github.deploySecrets`
8
+ * and `e2eSecrets` map an env name to a repository secret, and the managed
9
+ * workflows render exactly those into the job environment.
10
+ * 3. **A developer shell declares how to fetch one locally.** `smoo.secrets`
11
+ * names a command whose stdout is the value.
12
+ *
13
+ * Nothing joined them, so a Worker could declare a secret no workflow supplies
14
+ * and no repository holds. That fails at deploy time, on a stage, with a
15
+ * fail-closed message about the value rather than about the missing
16
+ * declaration — the shape that costs an afternoon: a Worker declares
17
+ * `STRIPE_PUBLISHABLE_KEY`, `ci.yml` passes `secrets.STRIPE_PUBLISHABLE_KEY`,
18
+ * the repository holds no such secret, and the empty value surfaces as the
19
+ * payment library's own `publishable_key_mismatch` refusal.
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { repositoryOwnerFromUrl, repositorySecretMapping } from '../lib/secret-names.js';
26
+ import { readPackageJsonObject, repositoryInfo } from '../lib/workspace.js';
27
+ import { parseDevVarsExample } from '../wrangler/prepare-env.js';
28
+
29
+ /** Where an env name is described, which repository secret carries it, and whether that exists. */
30
+ export interface SecretRow {
31
+ name: string;
32
+ /** The repository secret this env name reads from, by convention or declaration. */
33
+ repositorySecret: string;
34
+ /** Wrangler projects whose `.dev.vars.example` declares it. */
35
+ declaredByWorkers: string[];
36
+ /** True when a managed workflow renders `secrets.<name>` into a job. */
37
+ suppliedByWorkflow: boolean;
38
+ /** True when `smoo.secrets` can fetch it for a developer shell. */
39
+ fetchableLocally: boolean;
40
+ /** True when the repository holds a secret of this name. */
41
+ onRepository: boolean;
42
+ }
43
+
44
+ export interface SecretSources {
45
+ /** Worker label -> env names it declares. */
46
+ workerSecrets: Record<string, readonly string[]>;
47
+ /** Env names a managed workflow passes into a job. */
48
+ workflowSecrets: readonly string[];
49
+ /** Env name -> repository secret, by convention with declared exceptions. */
50
+ secretNames: Readonly<Record<string, string>>;
51
+ /** Env names `smoo.secrets` can fetch locally. */
52
+ localCommands: readonly string[];
53
+ /** Repository secret names GitHub currently holds. */
54
+ repositorySecrets: readonly string[];
55
+ }
56
+
57
+ /**
58
+ * One row per name known to any source, sorted, so a reader sees the whole
59
+ * picture rather than one source's view of it.
60
+ */
61
+ export function reconcileSecrets(sources: SecretSources): SecretRow[] {
62
+ const declaredByAnyWorker: string[] = Object.values(sources.workerSecrets).flatMap((names) => [...names]);
63
+ const names = new Set<string>([...declaredByAnyWorker, ...sources.workflowSecrets, ...sources.localCommands]);
64
+ // A repository secret that already carries a known env name is that name's
65
+ // row, not a row of its own: listing ACME_GITHUB_CLIENT_SECRET beside
66
+ // GITHUB_CLIENT_SECRET would report one value as two secrets, one of them
67
+ // permanently "declared by nothing".
68
+ const carriesKnownEnvName = new Set(
69
+ names.size > 0 ? [...names].map((name) => sources.secretNames[name] ?? name) : [],
70
+ );
71
+ for (const secret of sources.repositorySecrets) {
72
+ if (!carriesKnownEnvName.has(secret)) names.add(secret);
73
+ }
74
+ return [...names]
75
+ .sort((left, right) => left.localeCompare(right))
76
+ .map((name) => ({
77
+ name,
78
+ repositorySecret: sources.secretNames[name] ?? name,
79
+ declaredByWorkers: Object.entries(sources.workerSecrets)
80
+ .filter(([, declared]) => declared.includes(name))
81
+ .map(([label]) => label)
82
+ .sort((left, right) => left.localeCompare(right)),
83
+ suppliedByWorkflow: sources.workflowSecrets.includes(name),
84
+ fetchableLocally: sources.localCommands.includes(name),
85
+ onRepository: sources.repositorySecrets.includes(sources.secretNames[name] ?? name),
86
+ }));
87
+ }
88
+
89
+ /**
90
+ * The rows a CI deploy cannot satisfy: a Worker declares the name, a workflow
91
+ * promises to pass it, and the repository has no value to pass. Reported
92
+ * separately from "declared but not wired into any workflow", because the
93
+ * remedies differ — set a secret, versus declare it in `smoo.github`.
94
+ */
95
+ export function unsatisfiedSecrets(rows: readonly SecretRow[]): SecretRow[] {
96
+ return rows.filter((row) => row.suppliedByWorkflow && !row.onRepository);
97
+ }
98
+
99
+ /** Worker-declared names no managed workflow passes: a CI deploy will run without them. */
100
+ export function unwiredSecrets(rows: readonly SecretRow[]): SecretRow[] {
101
+ return rows.filter((row) => row.declaredByWorkers.length > 0 && !row.suppliedByWorkflow);
102
+ }
103
+
104
+ /** Env names the managed workflows pass into a job, from the declarations that render them. */
105
+ export function workflowSecretNames(root: string): string[] {
106
+ const manifest = readPackageJsonObject(join(root, 'package.json'));
107
+ const smoo = manifest?.smoo;
108
+ const github = smoo?.github;
109
+ const names = new Set<string>([
110
+ ...Object.keys(github?.deploySecrets ?? {}),
111
+ ...Object.keys(github?.e2eSecrets ?? {}),
112
+ ]);
113
+ if (smoo?.remoteCache?.tokenSecret) names.add(smoo.remoteCache.tokenSecret);
114
+ for (const origin of github?.cargoCredentials?.gitOrigins ?? []) {
115
+ if (origin.tokenEnv) names.add(origin.tokenEnv);
116
+ }
117
+ for (const source of github?.sourceCheckouts ?? []) {
118
+ if (source.tokenEnv) names.add(source.tokenEnv);
119
+ }
120
+ if (smoo?.privateNpm?.readTokenEnv) names.add(smoo.privateNpm.readTokenEnv);
121
+ if (smoo?.privateNpm?.publishTokenEnv) names.add(smoo.privateNpm.publishTokenEnv);
122
+ return [...names].sort((left, right) => left.localeCompare(right));
123
+ }
124
+
125
+ /**
126
+ * Env name -> repository secret for every name in play, following the naming
127
+ * convention and honouring the declared exceptions a repository still needs.
128
+ */
129
+ export function secretNameMapping(root: string, envNames: readonly string[]): Record<string, string> {
130
+ const manifest = readPackageJsonObject(join(root, 'package.json'));
131
+ const declared = { ...(manifest?.smoo?.github?.deploySecrets ?? {}), ...(manifest?.smoo?.github?.e2eSecrets ?? {}) };
132
+ const repository = manifest ? repositoryInfo(manifest) : null;
133
+ const owner = repository ? (repositoryOwnerFromUrl(repository.url) ?? '') : '';
134
+ return repositorySecretMapping(envNames, owner, declared);
135
+ }
136
+
137
+ /** Every wrangler project's declared secret names, keyed by the project directory. */
138
+ export function workerSecretNames(root: string, workspaceDirs: readonly string[]): Record<string, string[]> {
139
+ const byWorker: Record<string, string[]> = {};
140
+ for (const dir of workspaceDirs) {
141
+ const examplePath = join(root, dir, '.dev.vars.example');
142
+ if (!existsSync(examplePath)) continue;
143
+ const names = parseDevVarsExample(readFileSync(examplePath, 'utf8'));
144
+ if (names.length > 0) byWorker[dir] = names;
145
+ }
146
+ return byWorker;
147
+ }
148
+
149
+ /** Names `smoo.secrets` declares a fetch command for. */
150
+ export function localSecretCommandNames(root: string): string[] {
151
+ const manifest = readPackageJsonObject(join(root, 'package.json'));
152
+ return Object.keys(manifest?.smoo?.secrets ?? {}).sort((left, right) => left.localeCompare(right));
153
+ }
154
+
155
+ /**
156
+ * Resolve one declared secret through its command. The value is returned, never
157
+ * logged: callers hand it to `gh secret set` over stdin.
158
+ */
159
+ export function fetchLocalSecret(
160
+ root: string,
161
+ name: string,
162
+ ): { ok: true; value: string } | { ok: false; reason: string } {
163
+ const manifest = readPackageJsonObject(join(root, 'package.json'));
164
+ const declared = manifest?.smoo?.secrets?.[name];
165
+ if (!declared) {
166
+ return { ok: false, reason: `smoo.secrets declares no command for ${name}` };
167
+ }
168
+ const [command, ...args] = declared.command;
169
+ const result = spawnSync(command, args, { encoding: 'utf8' });
170
+ if (result.status !== 0) {
171
+ // Name the command, never its output: a failing secret fetch prints
172
+ // credentials often enough that it is worth refusing to.
173
+ return {
174
+ ok: false,
175
+ reason: `${name}: \`${declared.command.join(' ')}\` exited ${result.status ?? 'without status'}`,
176
+ };
177
+ }
178
+ const value = result.stdout.replace(/\n$/, '');
179
+ if (value.length === 0) {
180
+ return { ok: false, reason: `${name}: \`${declared.command.join(' ')}\` produced no value` };
181
+ }
182
+ return { ok: true, value };
183
+ }