@smoothbricks/cli 0.11.17 → 0.11.19

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 (60) hide show
  1. package/README.md +45 -10
  2. package/dist/cli.js +10 -7
  3. package/dist/github-ci/index.d.ts.map +1 -1
  4. package/dist/github-ci/index.js +6 -20
  5. package/dist/lib/deploy-tags.d.ts +17 -3
  6. package/dist/lib/deploy-tags.d.ts.map +1 -1
  7. package/dist/lib/deploy-tags.js +23 -4
  8. package/dist/monorepo/ci-workflow.d.ts +56 -0
  9. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  10. package/dist/monorepo/ci-workflow.js +199 -2
  11. package/dist/monorepo/managed-files.d.ts +28 -2
  12. package/dist/monorepo/managed-files.d.ts.map +1 -1
  13. package/dist/monorepo/managed-files.js +74 -22
  14. package/dist/release/github-release.d.ts +10 -0
  15. package/dist/release/github-release.d.ts.map +1 -1
  16. package/dist/release/github-release.js +11 -5
  17. package/dist/release/index.d.ts.map +1 -1
  18. package/dist/release/index.js +4 -5
  19. package/dist/secrets/commands.d.ts +52 -13
  20. package/dist/secrets/commands.d.ts.map +1 -1
  21. package/dist/secrets/commands.js +220 -34
  22. package/dist/secrets/index.d.ts +21 -1
  23. package/dist/secrets/index.d.ts.map +1 -1
  24. package/dist/secrets/index.js +110 -4
  25. package/dist/secrets/repository.d.ts +60 -0
  26. package/dist/secrets/repository.d.ts.map +1 -0
  27. package/dist/secrets/repository.js +145 -0
  28. package/dist/wrangler/cloudflare.d.ts +7 -0
  29. package/dist/wrangler/cloudflare.d.ts.map +1 -1
  30. package/dist/wrangler/cloudflare.js +10 -0
  31. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  32. package/dist/wrangler/deploy-stage.js +50 -24
  33. package/dist/wrangler/stage-secrets.d.ts +57 -0
  34. package/dist/wrangler/stage-secrets.d.ts.map +1 -0
  35. package/dist/wrangler/stage-secrets.js +178 -0
  36. package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
  37. package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
  38. package/package.json +2 -2
  39. package/src/cli.ts +33 -10
  40. package/src/github-ci/index.test.ts +6 -7
  41. package/src/github-ci/index.ts +11 -19
  42. package/src/lib/deploy-tags.ts +22 -4
  43. package/src/monorepo/__tests__/ci-workflow.test.ts +161 -0
  44. package/src/monorepo/ci-workflow.ts +277 -2
  45. package/src/monorepo/managed-files.test.ts +96 -37
  46. package/src/monorepo/managed-files.ts +94 -23
  47. package/src/monorepo/package-policy.test.ts +1 -1
  48. package/src/release/__tests__/github-release.test.ts +8 -4
  49. package/src/release/github-release.ts +13 -5
  50. package/src/release/index.ts +4 -4
  51. package/src/secrets/commands.test.ts +28 -0
  52. package/src/secrets/commands.ts +237 -35
  53. package/src/secrets/index.test.ts +118 -1
  54. package/src/secrets/index.ts +108 -4
  55. package/src/secrets/repository.test.ts +98 -0
  56. package/src/secrets/repository.ts +164 -0
  57. package/src/wrangler/cloudflare.ts +18 -0
  58. package/src/wrangler/deploy-stage.test.ts +258 -11
  59. package/src/wrangler/deploy-stage.ts +70 -38
  60. package/src/wrangler/stage-secrets.ts +146 -0
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import { spawnSync } from 'node:child_process';
23
- import { existsSync, readFileSync } from 'node:fs';
23
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
24
24
  import { join } from 'node:path';
25
25
  import { repositoryOwnerFromUrl, repositorySecretMapping } from '../lib/secret-names.js';
26
26
  import { readPackageJsonObject, repositoryInfo } from '../lib/workspace.js';
@@ -39,6 +39,13 @@ export interface SecretRow {
39
39
  fetchableLocally: boolean;
40
40
  /** True when the repository holds a secret of this name. */
41
41
  onRepository: boolean;
42
+ /**
43
+ * GitHub Environments holding their own value for this name. A job bound to
44
+ * an environment reads that value in preference to the repository's, which
45
+ * is how one name carries test credentials on a preview stage and live ones
46
+ * in production.
47
+ */
48
+ heldByEnvironment: string[];
42
49
  }
43
50
 
44
51
  export interface SecretSources {
@@ -52,6 +59,8 @@ export interface SecretSources {
52
59
  localCommands: readonly string[];
53
60
  /** Repository secret names GitHub currently holds. */
54
61
  repositorySecrets: readonly string[];
62
+ /** Environment name -> secret names that environment holds. */
63
+ environmentSecrets?: Readonly<Record<string, readonly string[]>>;
55
64
  }
56
65
 
57
66
  /**
@@ -68,8 +77,14 @@ export function reconcileSecrets(sources: SecretSources): SecretRow[] {
68
77
  const carriesKnownEnvName = new Set(
69
78
  names.size > 0 ? [...names].map((name) => sources.secretNames[name] ?? name) : [],
70
79
  );
71
- for (const secret of sources.repositorySecrets) {
80
+ // A value only an environment holds is still a value: leaving it out of the
81
+ // rows is how an operator ends up hunting for a secret that is already set.
82
+ const addUndeclared = (secret: string): void => {
72
83
  if (!carriesKnownEnvName.has(secret)) names.add(secret);
84
+ };
85
+ for (const secret of sources.repositorySecrets) addUndeclared(secret);
86
+ for (const held of Object.values(sources.environmentSecrets ?? {})) {
87
+ for (const secret of held) addUndeclared(secret);
73
88
  }
74
89
  return [...names]
75
90
  .sort((left, right) => left.localeCompare(right))
@@ -83,6 +98,10 @@ export function reconcileSecrets(sources: SecretSources): SecretRow[] {
83
98
  suppliedByWorkflow: sources.workflowSecrets.includes(name),
84
99
  fetchableLocally: sources.localCommands.includes(name),
85
100
  onRepository: sources.repositorySecrets.includes(sources.secretNames[name] ?? name),
101
+ heldByEnvironment: Object.entries(sources.environmentSecrets ?? {})
102
+ .filter(([, held]) => held.includes(sources.secretNames[name] ?? name))
103
+ .map(([environment]) => environment)
104
+ .sort((left, right) => left.localeCompare(right)),
86
105
  }));
87
106
  }
88
107
 
@@ -92,8 +111,22 @@ export function reconcileSecrets(sources: SecretSources): SecretRow[] {
92
111
  * separately from "declared but not wired into any workflow", because the
93
112
  * remedies differ — set a secret, versus declare it in `smoo.github`.
94
113
  */
95
- export function unsatisfiedSecrets(rows: readonly SecretRow[]): SecretRow[] {
96
- return rows.filter((row) => row.suppliedByWorkflow && !row.onRepository);
114
+ export function unsatisfiedSecrets(rows: readonly SecretRow[], boundEnvironments: readonly string[] = []): SecretRow[] {
115
+ return rows.filter((row) => {
116
+ if (!row.suppliedByWorkflow) return false;
117
+ if (row.onRepository) return false;
118
+ // With no environment bound, the repository is the only scope a job reads.
119
+ // With environments bound, each one can carry the value instead - so the
120
+ // name is satisfied only when every bound environment holds it.
121
+ if (boundEnvironments.length === 0) return true;
122
+ return !boundEnvironments.every((environment) => row.heldByEnvironment.includes(environment));
123
+ });
124
+ }
125
+
126
+ /** Bound environments that lack their own value and cannot fall back to the repository. */
127
+ export function environmentsMissing(row: SecretRow, boundEnvironments: readonly string[]): string[] {
128
+ if (row.onRepository) return [];
129
+ return boundEnvironments.filter((environment) => !row.heldByEnvironment.includes(environment));
97
130
  }
98
131
 
99
132
  /** Worker-declared names no managed workflow passes: a CI deploy will run without them. */
@@ -122,6 +155,77 @@ export function workflowSecretNames(root: string): string[] {
122
155
  return [...names].sort((left, right) => left.localeCompare(right));
123
156
  }
124
157
 
158
+ /**
159
+ * GitHub Environments the rendered workflows bind, read from the workflow
160
+ * files rather than from config: a binding may be hand-authored inside a
161
+ * `smoo-local` block, and the file is what GitHub executes either way. A job
162
+ * bound to an environment resolves `secrets.X` from that environment first and
163
+ * from the repository second, so these names are the scopes a value can live
164
+ * in. Expressions are skipped - a computed environment names no fixed scope.
165
+ */
166
+ export function workflowEnvironments(root: string): string[] {
167
+ const directory = join(root, '.github', 'workflows');
168
+ if (!existsSync(directory)) return [];
169
+ const environments = new Set<string>();
170
+ for (const entry of readdirSync(directory)) {
171
+ if (!entry.endsWith('.yml') && !entry.endsWith('.yaml')) continue;
172
+ for (const bound of environmentBindings(readFileSync(join(directory, entry), 'utf8'))) {
173
+ environments.add(bound);
174
+ }
175
+ }
176
+ return [...environments].sort((left, right) => left.localeCompare(right));
177
+ }
178
+
179
+ /**
180
+ * The environments one workflow file binds, in both spellings GitHub accepts:
181
+ * `environment: staging`, and the block form whose `name:` sits under it when
182
+ * the job also records a deployment URL. Only a `name:` indented inside an
183
+ * `environment:` block is a binding - a workflow's, a job's and a step's are
184
+ * not, and reading those as environments would send an operator to set
185
+ * secrets in scopes that do not exist.
186
+ */
187
+ function environmentBindings(text: string): string[] {
188
+ const lines = text.split('\n');
189
+ const bound: string[] = [];
190
+ for (let index = 0; index < lines.length; index += 1) {
191
+ const binding = /^(\s*)environment:(.*)$/.exec(lines[index] ?? '');
192
+ if (!binding) continue;
193
+ const indent = (binding[1] ?? '').length;
194
+ const value = binding[2] ?? '';
195
+ // Nothing but a comment after the colon is the block form; anything else
196
+ // is the value itself.
197
+ if (!/^\s*(?:#.*)?$/.test(value)) {
198
+ const name = literalEnvironmentName(value);
199
+ if (name !== null) bound.push(name);
200
+ continue;
201
+ }
202
+ for (let next = index + 1; next < lines.length; next += 1) {
203
+ const line = lines[next] ?? '';
204
+ const content = line.trimStart();
205
+ if (content.length === 0 || content.startsWith('#')) continue;
206
+ if (line.length - content.length <= indent) break;
207
+ const named = /^name:(.*)$/.exec(content);
208
+ if (!named) continue;
209
+ const name = literalEnvironmentName(named[1] ?? '');
210
+ if (name !== null) bound.push(name);
211
+ break;
212
+ }
213
+ }
214
+ return bound;
215
+ }
216
+
217
+ /**
218
+ * The fixed name a YAML value denotes, or null when it denotes no fixed scope:
219
+ * an expression is computed per run, and a flow mapping or a list is not a
220
+ * name. Names may contain spaces, which is why the renderer quotes them.
221
+ */
222
+ function literalEnvironmentName(value: string): string | null {
223
+ const scalar = value.replace(/\s+#.*$/, '').trim();
224
+ if (scalar.length === 0 || scalar.includes('${{')) return null;
225
+ const unquoted = /^(['"])(.*)\1$/.exec(scalar)?.[2] ?? scalar;
226
+ return /^[A-Za-z0-9._-][A-Za-z0-9._ -]*$/.test(unquoted) ? unquoted : null;
227
+ }
228
+
125
229
  /**
126
230
  * Env name -> repository secret for every name in play, following the naming
127
231
  * convention and honouring the declared exceptions a repository still needs.
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { chooseRepository, repositorySlugFromUrl } from './repository.js';
3
+
4
+ const mirror = new Map([
5
+ ['private-repo', 'https://github.com/acme/private.git'],
6
+ ['public-repo', 'https://github.com/acme/acme.git'],
7
+ ]);
8
+
9
+ describe('secrets repository resolution', () => {
10
+ it("reads the repository from the current branch's upstream when several remotes exist", () => {
11
+ // `gh` refuses outright here ("multiple remotes detected"), which is the
12
+ // public/private mirror layout. The branch already records which side it
13
+ // pushes to, and that is the side holding its secrets.
14
+ const resolved = chooseRepository({ remotes: mirror, upstreamRemote: 'private-repo', requested: undefined });
15
+
16
+ expect(resolved).toEqual({
17
+ ok: true,
18
+ choice: { repo: 'acme/private', source: 'upstream of the current branch (private-repo)' },
19
+ });
20
+ });
21
+
22
+ it('accepts a remote name where an operator would type one', () => {
23
+ const resolved = chooseRepository({ remotes: mirror, upstreamRemote: null, requested: 'private-repo' });
24
+
25
+ expect(resolved).toEqual({ ok: true, choice: { repo: 'acme/private', source: 'remote private-repo' } });
26
+ });
27
+
28
+ it('uses the one remote that already carries this branch when nothing tracks it', () => {
29
+ // A branch pushed by explicit refspec has no upstream, which is how the
30
+ // mirror layout is driven; its remote-tracking ref still records where it
31
+ // went, and one carrier is an answer.
32
+ const resolved = chooseRepository({
33
+ remotes: mirror,
34
+ upstreamRemote: null,
35
+ remotesCarryingBranch: ['private-repo'],
36
+ branch: 'billing',
37
+ requested: undefined,
38
+ });
39
+
40
+ expect(resolved).toEqual({
41
+ ok: true,
42
+ choice: { repo: 'acme/private', source: 'the only remote carrying this branch (private-repo)' },
43
+ });
44
+ });
45
+
46
+ it('refuses when two remotes carry the branch, naming both', () => {
47
+ const resolved = chooseRepository({
48
+ remotes: mirror,
49
+ upstreamRemote: null,
50
+ remotesCarryingBranch: ['private-repo', 'public-repo'],
51
+ branch: 'main',
52
+ requested: undefined,
53
+ });
54
+
55
+ expect(resolved.ok).toBe(false);
56
+ if (resolved.ok) throw new Error('expected a refusal');
57
+ expect(resolved.reason).toContain('branch main has no upstream and no remote carries it');
58
+ });
59
+
60
+ it('names the candidates instead of guessing when nothing decides', () => {
61
+ const resolved = chooseRepository({ remotes: mirror, upstreamRemote: null, requested: undefined });
62
+
63
+ expect(resolved.ok).toBe(false);
64
+ if (resolved.ok) throw new Error('expected a refusal');
65
+ expect(resolved.reason).toContain('private-repo -> acme/private');
66
+ expect(resolved.reason).toContain('public-repo -> acme/acme');
67
+ });
68
+
69
+ it('refuses a name that is neither a remote nor a slug, listing the remotes', () => {
70
+ const resolved = chooseRepository({ remotes: mirror, upstreamRemote: null, requested: 'privaterepo' });
71
+
72
+ expect(resolved.ok).toBe(false);
73
+ if (resolved.ok) throw new Error('expected a refusal');
74
+ expect(resolved.reason).toContain('private-repo, public-repo');
75
+ });
76
+
77
+ it('falls back to origin, then to a sole remote', () => {
78
+ const withOrigin = new Map([...mirror, ['origin', 'git@github.com:acme/app.git']]);
79
+ expect(chooseRepository({ remotes: withOrigin, upstreamRemote: null, requested: undefined })).toEqual({
80
+ ok: true,
81
+ choice: { repo: 'acme/app', source: 'remote origin' },
82
+ });
83
+
84
+ const single = new Map([['forge', 'ssh://forge.example.net:2223/acme/widgets.git']]);
85
+ expect(chooseRepository({ remotes: single, upstreamRemote: null, requested: undefined })).toEqual({
86
+ ok: true,
87
+ choice: { repo: 'acme/widgets', source: 'the only remote (forge)' },
88
+ });
89
+ });
90
+
91
+ it('parses every remote URL form git writes', () => {
92
+ expect(repositorySlugFromUrl('https://github.com/acme/app.git')).toBe('acme/app');
93
+ expect(repositorySlugFromUrl('git@github.com:acme/app.git')).toBe('acme/app');
94
+ expect(repositorySlugFromUrl('ssh://git@forge.example.net:2223/acme/app')).toBe('acme/app');
95
+ expect(repositorySlugFromUrl('https://user:token@github.com/acme/app')).toBe('acme/app');
96
+ expect(repositorySlugFromUrl('/srv/git/bare.git')).toBeNull();
97
+ });
98
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Which GitHub repository `smoo secrets` reads and writes.
3
+ *
4
+ * `gh` refuses on a checkout with more than one remote ("multiple remotes
5
+ * detected"), which is exactly the public/private mirror layout this CLI's own
6
+ * users run — so the repository has to be resolved here, from the checkout,
7
+ * before `gh` is spawned. The current branch's upstream decides it: a branch
8
+ * that pushes to the private mirror is a branch whose secrets live there. That
9
+ * is a fact the checkout already records, not a preference to configure.
10
+ */
11
+
12
+ import { spawnSync } from 'node:child_process';
13
+
14
+ export interface RepositoryChoice {
15
+ /** `owner/name`, the form `gh --repo` takes. */
16
+ readonly repo: string;
17
+ /** Why this repository, for the operator to read back. */
18
+ readonly source: string;
19
+ }
20
+
21
+ /** `owner/name` from any git remote URL form, or null when it names no repository. */
22
+ export function repositorySlugFromUrl(url: string): string | null {
23
+ const withoutProtocol = url
24
+ .trim()
25
+ .replace(/^[a-z+]+:\/\//i, '')
26
+ .replace(/^[^@/]+@/, '');
27
+ // A remainder starting with `/` or `.` carries no host, so it is a path on
28
+ // this machine - a valid git remote, never a GitHub repository.
29
+ if (withoutProtocol.startsWith('/') || withoutProtocol.startsWith('.')) return null;
30
+ const path = withoutProtocol.replace(/^[^/:]+(?::\d+)?[/:]/, '');
31
+ const segments = path
32
+ .replace(/\.git$/i, '')
33
+ .split('/')
34
+ .filter((segment) => segment.length > 0);
35
+ if (segments.length < 2) return null;
36
+ return `${segments[segments.length - 2]}/${segments[segments.length - 1]}`;
37
+ }
38
+
39
+ /**
40
+ * Pure resolution, so the precedence is testable without a checkout.
41
+ *
42
+ * A requested value is either a remote name or an `owner/name` slug; naming a
43
+ * remote is what an operator reaches for first (`-R private-repo`), and
44
+ * refusing it because it lacks a slash would be pedantry.
45
+ *
46
+ * With no request and no upstream - a branch pushed by explicit refspec, which
47
+ * is how the mirror layout is driven - the remote-tracking refs still record
48
+ * where this branch has been pushed. One remote carrying it is an answer; two
49
+ * is a genuine ambiguity worth refusing.
50
+ */
51
+ export function chooseRepository(inputs: {
52
+ readonly remotes: ReadonlyMap<string, string>;
53
+ readonly upstreamRemote: string | null;
54
+ /** Remotes that already carry the current branch, from its remote-tracking refs. */
55
+ readonly remotesCarryingBranch?: readonly string[];
56
+ /** Current branch name, for the refusal to say which branch decided nothing. */
57
+ readonly branch?: string | null;
58
+ readonly requested: string | undefined;
59
+ }): { ok: true; choice: RepositoryChoice } | { ok: false; reason: string } {
60
+ const { remotes, upstreamRemote, requested } = inputs;
61
+ const remotesCarryingBranch = inputs.remotesCarryingBranch ?? [];
62
+ if (requested !== undefined && requested.length > 0) {
63
+ const url = remotes.get(requested);
64
+ if (url !== undefined) {
65
+ const slug = repositorySlugFromUrl(url);
66
+ if (slug === null) return { ok: false, reason: `remote ${requested} (${url}) names no owner/name` };
67
+ return { ok: true, choice: { repo: slug, source: `remote ${requested}` } };
68
+ }
69
+ if (requested.includes('/')) return { ok: true, choice: { repo: requested, source: 'requested' } };
70
+ return {
71
+ ok: false,
72
+ reason:
73
+ `${requested} is neither a remote of this checkout nor an owner/name. ` +
74
+ `Remotes: ${[...remotes.keys()].join(', ') || 'none'}`,
75
+ };
76
+ }
77
+ if (upstreamRemote !== null) {
78
+ const url = remotes.get(upstreamRemote);
79
+ const slug = url === undefined ? null : repositorySlugFromUrl(url);
80
+ if (slug !== null) {
81
+ return { ok: true, choice: { repo: slug, source: `upstream of the current branch (${upstreamRemote})` } };
82
+ }
83
+ }
84
+ if (remotesCarryingBranch.length === 1) {
85
+ const name = remotesCarryingBranch[0] ?? '';
86
+ const url = remotes.get(name);
87
+ const slug = url === undefined ? null : repositorySlugFromUrl(url);
88
+ if (slug !== null) {
89
+ return { ok: true, choice: { repo: slug, source: `the only remote carrying this branch (${name})` } };
90
+ }
91
+ }
92
+ if (remotes.size === 1) {
93
+ const [name, url] = [...remotes][0];
94
+ const slug = repositorySlugFromUrl(url);
95
+ if (slug !== null) return { ok: true, choice: { repo: slug, source: `the only remote (${name})` } };
96
+ }
97
+ const origin = remotes.get('origin');
98
+ const originSlug = origin === undefined ? null : repositorySlugFromUrl(origin);
99
+ if (originSlug !== null) return { ok: true, choice: { repo: originSlug, source: 'remote origin' } };
100
+ const candidates = [...remotes].map(([name, url]) => `${name} -> ${repositorySlugFromUrl(url) ?? url}`).join(', ');
101
+ const branchClause =
102
+ inputs.branch === undefined || inputs.branch === null
103
+ ? 'this checkout has no current branch'
104
+ : `branch ${inputs.branch} has no upstream and no remote carries it`;
105
+ return {
106
+ ok: false,
107
+ reason:
108
+ `${branchClause}, and there is no origin, so the repository is ambiguous. ` +
109
+ `Pass -R with one of: ${candidates || 'no remotes at all'}`,
110
+ };
111
+ }
112
+
113
+ function git(root: string, args: readonly string[]): string | null {
114
+ const result = spawnSync('git', [...args], { cwd: root, encoding: 'utf8' });
115
+ return result.status === 0 ? result.stdout.trim() : null;
116
+ }
117
+
118
+ /** Remote name -> URL, in config order. */
119
+ export function readRemotes(root: string): Map<string, string> {
120
+ const remotes = new Map<string, string>();
121
+ const config = git(root, ['config', '--get-regexp', '^remote\\..*\\.url']);
122
+ if (config === null) return remotes;
123
+ for (const line of config.split('\n')) {
124
+ const match = /^remote\.(.+)\.url\s+(.+)$/.exec(line.trim());
125
+ if (match !== null && match[1] !== undefined && match[2] !== undefined) remotes.set(match[1], match[2]);
126
+ }
127
+ return remotes;
128
+ }
129
+
130
+ /** Remotes with a remote-tracking ref for the current branch: where it has been pushed. */
131
+ export function readRemotesCarryingBranch(root: string, remotes: ReadonlyMap<string, string>): string[] {
132
+ const branch = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
133
+ if (branch === null || branch === 'HEAD') return [];
134
+ const carrying: string[] = [];
135
+ for (const name of remotes.keys()) {
136
+ if (git(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${name}/${branch}`]) !== null) {
137
+ carrying.push(name);
138
+ }
139
+ }
140
+ return carrying;
141
+ }
142
+
143
+ /** The remote the current branch tracks, or null when it tracks nothing. */
144
+ export function readUpstreamRemote(root: string): string | null {
145
+ const branch = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
146
+ if (branch === null || branch === 'HEAD') return null;
147
+ return git(root, ['config', '--get', `branch.${branch}.remote`]);
148
+ }
149
+
150
+ /** The repository to operate on, resolved from the checkout. */
151
+ export function resolveRepository(
152
+ root: string,
153
+ requested: string | undefined,
154
+ ): { ok: true; choice: RepositoryChoice } | { ok: false; reason: string } {
155
+ const remotes = readRemotes(root);
156
+ const branch = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
157
+ return chooseRepository({
158
+ remotes,
159
+ upstreamRemote: readUpstreamRemote(root),
160
+ remotesCarryingBranch: readRemotesCarryingBranch(root, remotes),
161
+ branch: branch === 'HEAD' ? null : branch,
162
+ requested,
163
+ });
164
+ }
@@ -9,6 +9,11 @@ export interface WorkerScript {
9
9
  id: string;
10
10
  }
11
11
 
12
+ /** One secret bound to a Worker. Cloudflare answers names and types only; a value is never readable. */
13
+ export interface WorkerSecret {
14
+ name: string;
15
+ }
16
+
12
17
  export interface WorkerRoute {
13
18
  id: string;
14
19
  pattern: string;
@@ -49,6 +54,8 @@ export interface CloudflareClient {
49
54
  deleteR2Object(bucket: string, key: string): Promise<void>;
50
55
  deleteR2Bucket(name: string): Promise<void>;
51
56
  listWorkerScripts(): Promise<WorkerScript[]>;
57
+ /** Names only; the Worker's stored values are write-only from outside. */
58
+ listWorkerSecrets(workerName: string): Promise<string[]>;
52
59
  deleteWorkerScript(name: string): Promise<void>;
53
60
  listWorkerDomains(): Promise<WorkerDomain[]>;
54
61
  createWorkerDomain(hostname: string, workerName: string, zoneId: string): Promise<void>;
@@ -97,6 +104,7 @@ const MAX_LIST_PAGES = 1000;
97
104
  const isKvNamespaces = typia.createIs<LiveKvNamespace[]>();
98
105
  const isR2Buckets = typia.createIs<R2Bucket[]>();
99
106
  const isWorkerScripts = typia.createIs<WorkerScript[]>();
107
+ const isWorkerSecrets = typia.createIs<WorkerSecret[]>();
100
108
  const isWorkerDomains = typia.createIs<WorkerDomain[]>();
101
109
  const isCloudflareZones = typia.createIs<CloudflareZone[]>();
102
110
  const isWorkerRoutes = typia.createIs<WorkerRoute[]>();
@@ -222,6 +230,16 @@ export class CloudflareRestClient implements CloudflareClient {
222
230
  return this.listOnce(`${this.accountPath}/workers/scripts`, isWorkerScripts);
223
231
  }
224
232
 
233
+ async listWorkerSecrets(workerName: string): Promise<string[]> {
234
+ // Unpaginated, like the script listing itself. A Worker that does not exist answers 404 rather
235
+ // than an empty list, so the caller must establish that the Worker is there before asking.
236
+ const secrets = await this.listOnce(
237
+ `${this.accountPath}/workers/scripts/${encodeURIComponent(workerName)}/secrets`,
238
+ isWorkerSecrets,
239
+ );
240
+ return secrets.map((secret) => secret.name);
241
+ }
242
+
225
243
  deleteWorkerScript(name: string): Promise<void> {
226
244
  return this.mutate(`${this.accountPath}/workers/scripts/${encodeURIComponent(name)}`, { method: 'DELETE' });
227
245
  }