@marcusrbrown/infra 0.7.0 → 0.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -86,11 +86,12 @@ Commands:
86
86
  --key [key] Existing CLIProxyAPI API key value. When provided, setup skips key creation and reuses this key for GitHub secrets.
87
87
  --repo [repo] Target GitHub repository in owner/repo format. Skips the repository prompt when provided.
88
88
  --harness [harness] Harness template to configure. Choose opencode, claude-code, or generic. Generic remains interactive-only.
89
- --providers [providers] Comma-separated list of providers to enable. Supported values: anthropic, openai. Example: --providers anthropic,openai
90
- --model [model] Override the default model. Must be provider-prefixed and lowercase. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o
89
+ --providers [providers] Comma-separated list of providers to enable. Default: anthropic. Supported values: anthropic, openai. Example: --providers anthropic,openai
90
+ --model [model] Override the default model. Must be provider-prefixed and lowercase. Required when multiple providers selected. Examples: anthropic/claude-sonnet-4-6, openai/gpt-4o
91
91
  --force Overwrite existing GitHub secrets and variables without prompting.
92
92
  --dry-run Print the plan without applying any changes.
93
93
  --verify-smoke Run a smoke test against the proxy after setup completes.
94
+ --ack-key-reuse Acknowledge that --key matches the bearer token inside the existing OPENCODE_AUTH_JSON. Required in non-interactive mode when --key is supplied for a repo with existing OPENCODE_AUTH_JSON. (default: false)
94
95
 
95
96
 
96
97
  gateway status Show operational health of the gateway deployment via docker compose ps.
@@ -5,11 +5,12 @@ import {chmodSync} from 'node:fs'
5
5
 
6
6
  import {z} from 'zod'
7
7
 
8
+ import {managementHeaders, requestJson} from './shared'
9
+
8
10
  /** Minimal ctx surface consumed by cliproxy config actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
9
11
  // ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
10
12
 
11
13
  const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
12
- const HTTP_TIMEOUT_MS = 10_000
13
14
 
14
15
  function stripTrailingSlash(value: string): string {
15
16
  return value.endsWith('/') ? value.slice(0, -1) : value
@@ -29,31 +30,6 @@ export function resolveManagementKey(input?: string): string {
29
30
  return key
30
31
  }
31
32
 
32
- function managementHeaders(key: string): Headers {
33
- const headers = new Headers()
34
- headers.set('x-management-key', key)
35
- headers.set('content-type', 'application/json')
36
- return headers
37
- }
38
-
39
- async function requestJson(endpoint: string, init: RequestInit): Promise<unknown> {
40
- const response = await fetch(endpoint, {
41
- ...init,
42
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
43
- })
44
-
45
- if (!response.ok) {
46
- const body = await response.text()
47
- throw new Error(`${init.method ?? 'GET'} ${endpoint} failed with HTTP ${response.status}: ${body}`)
48
- }
49
-
50
- try {
51
- return await response.json()
52
- } catch {
53
- return null
54
- }
55
- }
56
-
57
33
  export function parseBoolean(value: string): boolean {
58
34
  const normalized = value.toLowerCase()
59
35
  if (normalized === 'true') {
@@ -4,11 +4,14 @@ import type {ActionCtx} from '../../lib/action-ctx'
4
4
 
5
5
  import {z} from 'zod'
6
6
 
7
+ import {managementHeaders, parseManagementKeyList, requestJson, toStringArray} from './shared'
8
+
9
+ export {toStringArray} from './shared'
10
+
7
11
  /** Minimal ctx surface consumed by cliproxy keys actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
8
12
  // ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
9
13
 
10
14
  const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
11
- const HTTP_TIMEOUT_MS = 10_000
12
15
 
13
16
  function stripTrailingSlash(value: string): string {
14
17
  return value.endsWith('/') ? value.slice(0, -1) : value
@@ -28,47 +31,6 @@ function resolveManagementKey(input?: string): string {
28
31
  return key
29
32
  }
30
33
 
31
- function managementHeaders(key: string): Headers {
32
- const headers = new Headers()
33
- headers.set('x-management-key', key)
34
- headers.set('content-type', 'application/json')
35
- return headers
36
- }
37
-
38
- async function requestJson(endpoint: string, init: RequestInit): Promise<unknown> {
39
- const response = await fetch(endpoint, {
40
- ...init,
41
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
42
- })
43
-
44
- if (!response.ok) {
45
- const body = await response.text()
46
- throw new Error(`${init.method ?? 'GET'} ${endpoint} failed with HTTP ${response.status}: ${body}`)
47
- }
48
-
49
- try {
50
- return await response.json()
51
- } catch {
52
- return null
53
- }
54
- }
55
-
56
- export function toStringArray(payload: unknown): string[] {
57
- if (Array.isArray(payload)) {
58
- return payload.filter(item => typeof item === 'string')
59
- }
60
-
61
- if (payload && typeof payload === 'object') {
62
- const obj = payload as Record<string, unknown>
63
- const value = obj['api-keys'] ?? obj.api_keys
64
- if (Array.isArray(value)) {
65
- return value.filter(item => typeof item === 'string')
66
- }
67
- }
68
-
69
- return []
70
- }
71
-
72
34
  export interface KeysListOptions {
73
35
  url?: string
74
36
  key?: string
@@ -126,7 +88,10 @@ export async function cliproxyKeysAddAction(
126
88
  method: 'GET',
127
89
  headers: managementHeaders(managementKey),
128
90
  })
129
- const currentKeys = toStringArray(currentPayload)
91
+ // Strict parse: a malformed or unexpected GET response must fail closed before the
92
+ // destructive PUT replaces the entire key list. Permissive parsing would collapse
93
+ // unknown shapes to [] and overwrite existing keys.
94
+ const currentKeys = parseManagementKeyList(currentPayload)
130
95
 
131
96
  if (currentKeys.includes(apiKeyToAdd)) {
132
97
  ctx.console.log('Key already present; no update required.')
@@ -0,0 +1,218 @@
1
+ /// <reference types="bun" />
2
+
3
+ import {afterEach, describe, expect, it, mock, spyOn} from 'bun:test'
4
+
5
+ import {isGhRateLimitError, withGhRetry} from './gh'
6
+
7
+ // ─── isGhRateLimitError ──────────────────────────────────────────────────────
8
+
9
+ describe('isGhRateLimitError', () => {
10
+ it('returns true when text contains "rate limit"', () => {
11
+ expect(isGhRateLimitError('API rate limit exceeded')).toBe(true)
12
+ })
13
+
14
+ it('is case-insensitive', () => {
15
+ expect(isGhRateLimitError('You have exceeded a secondary RATE LIMIT')).toBe(true)
16
+ })
17
+
18
+ it('returns false for unrelated error messages', () => {
19
+ expect(isGhRateLimitError('Not Found (HTTP 404)')).toBe(false)
20
+ })
21
+
22
+ it('returns false for an empty string', () => {
23
+ expect(isGhRateLimitError('')).toBe(false)
24
+ })
25
+
26
+ it('returns false for a connection timeout', () => {
27
+ expect(isGhRateLimitError('connection timeout')).toBe(false)
28
+ })
29
+ })
30
+
31
+ // ─── withGhRetry ─────────────────────────────────────────────────────────────
32
+
33
+ describe('withGhRetry', () => {
34
+ it('returns the value when fn succeeds immediately', async () => {
35
+ const result = await withGhRetry('test label', async () => 'ok', false)
36
+
37
+ expect(result).toBe('ok')
38
+ })
39
+
40
+ it('re-throws non-rate-limit errors without querying the reset time', async () => {
41
+ const queryReset = async (): Promise<string> => {
42
+ throw new Error('queryReset should not have been called')
43
+ }
44
+ const err = new Error('some other error')
45
+
46
+ await expect(withGhRetry('test label', async () => Promise.reject(err), false, queryReset)).rejects.toThrow(
47
+ 'some other error',
48
+ )
49
+ })
50
+
51
+ it('re-throws with reset time appended in non-interactive mode on rate limit', async () => {
52
+ const queryReset = async (): Promise<string> => '2:30 PM'
53
+
54
+ await expect(
55
+ withGhRetry(
56
+ 'test label',
57
+ async () => {
58
+ throw new Error('API rate limit exceeded for url')
59
+ },
60
+ false,
61
+ queryReset,
62
+ ),
63
+ ).rejects.toThrow('resets at 2:30 PM')
64
+ })
65
+ })
66
+
67
+ // ─── applyGhValue stdin-pipe-not-body invariant (PR #102) ────────────────────
68
+
69
+ describe('applyGhValue', () => {
70
+ afterEach(() => {
71
+ mock.restore()
72
+ })
73
+
74
+ it('pipes secret value via stdin — never uses --body flag', async () => {
75
+ const {applyGhValue} = await import('./gh')
76
+
77
+ let capturedArgs: string[] = []
78
+ let capturedStdin: ReadableStream<Uint8Array> | undefined
79
+
80
+ const spawnSpy = spyOn(Bun, 'spawn').mockImplementation(((cmds: string[], opts?: {stdin?: unknown}) => {
81
+ capturedArgs = cmds
82
+ capturedStdin = opts?.stdin as ReadableStream<Uint8Array> | undefined
83
+ // Return a minimal fake child process
84
+ return {
85
+ stdout: new Response('').body,
86
+ stderr: new Response('').body,
87
+ exited: Promise.resolve(0),
88
+ stdin: null,
89
+ pid: 0,
90
+ killed: false,
91
+ exitCode: 0,
92
+ signalCode: null,
93
+ kill: () => {},
94
+ ref: () => {},
95
+ unref: () => {},
96
+ readable: new Response('').body,
97
+ }
98
+ }) as unknown as typeof Bun.spawn)
99
+
100
+ await applyGhValue('secret', 'MY_SECRET', 'owner/repo', 'my-value')
101
+
102
+ expect(capturedArgs).toContain('gh')
103
+ expect(capturedArgs).toContain('secret')
104
+ expect(capturedArgs).toContain('set')
105
+ expect(capturedArgs).toContain('MY_SECRET')
106
+ expect(capturedArgs).toContain('--repo')
107
+ expect(capturedArgs).toContain('owner/repo')
108
+ // The critical invariant: --body must NOT be in the args
109
+ expect(capturedArgs).not.toContain('--body')
110
+ // stdin must be provided (value piped via stdin)
111
+ expect(capturedStdin).toBeDefined()
112
+
113
+ spawnSpy.mockRestore()
114
+ })
115
+ })
116
+
117
+ // ─── createManagementApiKey / deleteManagementApiKey toStringArray parity ─────
118
+
119
+ describe('management API key helpers — response shape handling', () => {
120
+ afterEach(() => {
121
+ mock.restore()
122
+ })
123
+
124
+ it('createManagementApiKey handles top-level array response from GET', async () => {
125
+ const {createManagementApiKey} = await import('./gh')
126
+
127
+ const calls: {method: string; body?: string}[] = []
128
+ globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
129
+ const method = init?.method ?? 'GET'
130
+ calls.push({method, body: init?.body as string | undefined})
131
+ if (method === 'GET') {
132
+ // Top-level array response
133
+ return new Response(JSON.stringify(['existing-key']), {status: 200})
134
+ }
135
+ return new Response('{}', {status: 200})
136
+ }) as unknown as typeof fetch
137
+
138
+ await createManagementApiKey('https://cliproxy.fro.bot', 'mgmt-key', 'new-key')
139
+
140
+ const putCall = calls.find(c => c.method === 'PUT')
141
+ expect(putCall).toBeDefined()
142
+ const body = JSON.parse(putCall?.body ?? '[]') as string[]
143
+ expect(body).toContain('existing-key')
144
+ expect(body).toContain('new-key')
145
+ })
146
+
147
+ it('createManagementApiKey handles object-shaped {api-keys:[...]} response from GET', async () => {
148
+ const {createManagementApiKey} = await import('./gh')
149
+
150
+ const calls: {method: string; body?: string}[] = []
151
+ globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
152
+ const method = init?.method ?? 'GET'
153
+ calls.push({method, body: init?.body as string | undefined})
154
+ if (method === 'GET') {
155
+ // Object-shaped response — the form CLIProxyAPI actually returns
156
+ return new Response(JSON.stringify({'api-keys': ['existing-key']}), {status: 200})
157
+ }
158
+ return new Response('{}', {status: 200})
159
+ }) as unknown as typeof fetch
160
+
161
+ await createManagementApiKey('https://cliproxy.fro.bot', 'mgmt-key', 'new-key')
162
+
163
+ const putCall = calls.find(c => c.method === 'PUT')
164
+ expect(putCall).toBeDefined()
165
+ const body = JSON.parse(putCall?.body ?? '[]') as string[]
166
+ expect(body).toContain('existing-key')
167
+ expect(body).toContain('new-key')
168
+ })
169
+
170
+ it('createManagementApiKey THROWS without making a destructive PUT when GET returns unexpected payload shape', async () => {
171
+ const {createManagementApiKey} = await import('./gh')
172
+
173
+ // Mock fetch: GET returns 200 with payload `null` (valid JSON but unexpected shape).
174
+ // Pre-fix silent-failure path: requestJson would have returned null, toStringArray(null)
175
+ // would have collapsed to [], the PUT would have replaced the entire key list with
176
+ // just the new key — deleting all existing repo keys.
177
+ // Post-fix: parseManagementKeyList throws on null, the PUT is never made.
178
+ let putCallCount = 0
179
+ globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
180
+ const method = init?.method ?? 'GET'
181
+ if (method === 'PUT') putCallCount++
182
+ if (method === 'GET') {
183
+ return new Response(JSON.stringify(null), {
184
+ status: 200,
185
+ headers: {'content-type': 'application/json'},
186
+ })
187
+ }
188
+ return new Response('{}', {status: 200})
189
+ }) as unknown as typeof fetch
190
+
191
+ await expect(createManagementApiKey('https://cliproxy.fro.bot', 'mgmt-key', 'new-key')).rejects.toThrow(
192
+ /Unexpected management key-list shape/,
193
+ )
194
+ expect(putCallCount).toBe(0)
195
+ })
196
+
197
+ it('createManagementApiKey THROWS without PUT when GET returns malformed JSON', async () => {
198
+ const {createManagementApiKey} = await import('./gh')
199
+
200
+ let putCallCount = 0
201
+ globalThis.fetch = mock(async (_url: string, init?: RequestInit) => {
202
+ const method = init?.method ?? 'GET'
203
+ if (method === 'PUT') putCallCount++
204
+ if (method === 'GET') {
205
+ return new Response('not-json-content', {
206
+ status: 200,
207
+ headers: {'content-type': 'text/plain'},
208
+ })
209
+ }
210
+ return new Response('{}', {status: 200})
211
+ }) as unknown as typeof fetch
212
+
213
+ await expect(createManagementApiKey('https://cliproxy.fro.bot', 'mgmt-key', 'new-key')).rejects.toThrow(
214
+ /returned malformed JSON/,
215
+ )
216
+ expect(putCallCount).toBe(0)
217
+ })
218
+ })
@@ -0,0 +1,250 @@
1
+ /// <reference types="bun" />
2
+
3
+ import type {SpinnerResult} from '@clack/prompts'
4
+
5
+ import {confirm, log, spinner} from '@clack/prompts'
6
+
7
+ import {managementHeaders, parseManagementKeyList, requestJson} from '../shared'
8
+ import {cancelAndExit, promptValue} from './prompts'
9
+
10
+ // ─── Types ────────────────────────────────────────────────────────────────────
11
+
12
+ export interface CommandResult {
13
+ stdout: string
14
+ stderr: string
15
+ exitCode: number
16
+ }
17
+
18
+ // ─── Local helpers ────────────────────────────────────────────────────────────
19
+
20
+ /** Local copy — avoids a circular import with setup.ts (gh → setup → gh). */
21
+ function extractErrorMessage(error: unknown): string {
22
+ return error instanceof Error ? error.message : String(error)
23
+ }
24
+
25
+ // ─── Spawn helpers ────────────────────────────────────────────────────────────
26
+
27
+ export async function withSpinner<T>(message: string, run: (spinnerInstance: SpinnerResult) => Promise<T>): Promise<T> {
28
+ const spinnerInstance = spinner()
29
+ spinnerInstance.start(message)
30
+
31
+ try {
32
+ const result = await run(spinnerInstance)
33
+ spinnerInstance.stop(message)
34
+ return result
35
+ } catch (error) {
36
+ spinnerInstance.error(`${message} failed`)
37
+ throw error
38
+ }
39
+ }
40
+
41
+ async function runCommand(command: string, args: string[]): Promise<CommandResult> {
42
+ const child = Bun.spawn([command, ...args], {
43
+ stdout: 'pipe',
44
+ stderr: 'pipe',
45
+ env: process.env,
46
+ })
47
+
48
+ const [stdout, stderr, exitCode] = await Promise.all([
49
+ new Response(child.stdout).text(),
50
+ new Response(child.stderr).text(),
51
+ child.exited,
52
+ ])
53
+
54
+ return {stdout, stderr, exitCode}
55
+ }
56
+
57
+ export async function runGh(args: string[]): Promise<CommandResult> {
58
+ return runCommand('gh', args)
59
+ }
60
+
61
+ // ─── Rate-limit helpers ───────────────────────────────────────────────────────
62
+
63
+ export function isGhRateLimitError(text: string): boolean {
64
+ return /rate limit/i.test(text)
65
+ }
66
+
67
+ /**
68
+ * Query the GitHub API rate limit reset time. The `rate_limit` endpoint is
69
+ * exempt from rate limiting itself, so this should succeed even when the
70
+ * primary GraphQL limit is exhausted. Returns a formatted local time string
71
+ * or a fallback phrase when the endpoint is unreachable.
72
+ */
73
+ async function queryRateLimitReset(): Promise<string> {
74
+ try {
75
+ const result = await runGh(['api', 'rate_limit'])
76
+ if (result.exitCode === 0) {
77
+ const parsed = JSON.parse(result.stdout) as {
78
+ resources?: {graphql?: {reset?: number}; core?: {reset?: number}}
79
+ }
80
+ const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset
81
+ if (reset) {
82
+ return new Date(reset * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
83
+ }
84
+ }
85
+ } catch {
86
+ // Fall through to generic phrase
87
+ }
88
+ return 'an unknown time'
89
+ }
90
+
91
+ /**
92
+ * Run a GitHub API operation wrapped in a spinner, retrying indefinitely on
93
+ * rate-limit errors when in interactive mode. In non-interactive mode the
94
+ * error is re-thrown with the reset time appended so the caller can surface
95
+ * it without prompting.
96
+ */
97
+ export async function withGhRetry<T>(
98
+ label: string,
99
+ fn: (spinnerInstance: SpinnerResult) => Promise<T>,
100
+ interactive: boolean,
101
+ queryReset: () => Promise<string> = queryRateLimitReset,
102
+ ): Promise<T> {
103
+ for (;;) {
104
+ try {
105
+ return await withSpinner(label, fn)
106
+ } catch (error) {
107
+ const message = extractErrorMessage(error)
108
+ if (!isGhRateLimitError(message)) {
109
+ throw error
110
+ }
111
+ const reset = await queryReset()
112
+ if (!interactive) {
113
+ throw new Error(`${message} — GitHub API rate limit resets at ${reset}. Re-run when ready.`)
114
+ }
115
+ log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`)
116
+ const retry = await promptValue(
117
+ confirm({
118
+ message: 'Retry this step when ready?',
119
+ active: 'retry',
120
+ inactive: 'abort',
121
+ initialValue: true,
122
+ }),
123
+ 'Setup aborted after rate limit.',
124
+ )
125
+ if (!retry) {
126
+ cancelAndExit('Setup aborted after GitHub API rate limit.')
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ // ─── Preflight assertions ─────────────────────────────────────────────────────
133
+
134
+ export async function assertGhInstalled(): Promise<void> {
135
+ if (!Bun.which('gh')) {
136
+ throw new Error('GitHub CLI is required for cliproxy setup. Install gh first: https://cli.github.com/')
137
+ }
138
+ }
139
+
140
+ export async function assertGhAuthenticated(): Promise<void> {
141
+ const result = await runGh(['auth', 'status'])
142
+ if (result.exitCode !== 0) {
143
+ throw new Error(`GitHub CLI is not authenticated. Run "gh auth login" first. ${result.stderr.trim()}`.trim())
144
+ }
145
+ }
146
+
147
+ export async function assertRepoAccess(repo: string): Promise<void> {
148
+ const {z} = await import('zod')
149
+ const ghRepoViewSchema = z.object({
150
+ nameWithOwner: z.string(),
151
+ viewerPermission: z.string(),
152
+ })
153
+
154
+ const result = await runGh(['repo', 'view', repo, '--json', 'nameWithOwner,viewerPermission'])
155
+ if (result.exitCode !== 0) {
156
+ throw new Error(`Unable to access ${repo}. ${result.stderr.trim()}`.trim())
157
+ }
158
+
159
+ const parsed = ghRepoViewSchema.parse(JSON.parse(result.stdout))
160
+ const writePermissions = new Set(['ADMIN', 'MAINTAIN', 'WRITE'])
161
+
162
+ if (!writePermissions.has(parsed.viewerPermission)) {
163
+ throw new Error(
164
+ `GitHub CLI does not have write access to ${parsed.nameWithOwner}. Current permission: ${parsed.viewerPermission}.`,
165
+ )
166
+ }
167
+ }
168
+
169
+ export async function listExistingGhNames(repo: string, kind: 'secret' | 'variable'): Promise<string[]> {
170
+ const {z} = await import('zod')
171
+ const ghNameListSchema = z.array(z.object({name: z.string()}))
172
+
173
+ const result = await runGh([kind, 'list', '--repo', repo, '--json', 'name'])
174
+ if (result.exitCode !== 0) {
175
+ throw new Error(`Unable to list existing GitHub ${kind}s for ${repo}. ${result.stderr.trim()}`.trim())
176
+ }
177
+
178
+ return ghNameListSchema.parse(JSON.parse(result.stdout)).map(entry => entry.name)
179
+ }
180
+
181
+ // ─── Management API key helpers ───────────────────────────────────────────────
182
+
183
+ export async function createManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
184
+ const endpoint = `${baseUrl}/v0/management/api-keys`
185
+ const currentPayload = await requestJson(endpoint, {
186
+ method: 'GET',
187
+ headers: managementHeaders(managementKey),
188
+ })
189
+ const currentKeys = parseManagementKeyList(currentPayload)
190
+
191
+ if (currentKeys.includes(keyValue)) {
192
+ return
193
+ }
194
+
195
+ await requestJson(endpoint, {
196
+ method: 'PUT',
197
+ headers: managementHeaders(managementKey),
198
+ body: JSON.stringify([...currentKeys, keyValue]),
199
+ })
200
+ }
201
+
202
+ export async function deleteManagementApiKey(baseUrl: string, managementKey: string, keyValue: string): Promise<void> {
203
+ const endpoint = `${baseUrl}/v0/management/api-keys`
204
+ const currentPayload = await requestJson(endpoint, {
205
+ method: 'GET',
206
+ headers: managementHeaders(managementKey),
207
+ })
208
+ const currentKeys = parseManagementKeyList(currentPayload)
209
+ const filtered = currentKeys.filter(k => k !== keyValue)
210
+
211
+ if (filtered.length === currentKeys.length) {
212
+ return
213
+ }
214
+
215
+ await requestJson(endpoint, {
216
+ method: 'PUT',
217
+ headers: managementHeaders(managementKey),
218
+ body: JSON.stringify(filtered),
219
+ })
220
+ }
221
+
222
+ // ─── GitHub value application ─────────────────────────────────────────────────
223
+
224
+ export async function applyGhValue(
225
+ kind: 'secret' | 'variable',
226
+ name: string,
227
+ repo: string,
228
+ value: string,
229
+ ): Promise<void> {
230
+ if (kind === 'secret') {
231
+ const child = Bun.spawn(['gh', 'secret', 'set', name, '--repo', repo], {
232
+ stdin: new Blob([value]).stream(),
233
+ stdout: 'pipe',
234
+ stderr: 'pipe',
235
+ env: process.env,
236
+ })
237
+
238
+ const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited])
239
+
240
+ if (exitCode !== 0) {
241
+ throw new Error(`gh secret set ${name} failed: ${stderr.trim()}`.trim())
242
+ }
243
+ return
244
+ }
245
+
246
+ const result = await runGh([kind, 'set', name, '--repo', repo, '--body', value])
247
+ if (result.exitCode !== 0) {
248
+ throw new Error(`gh ${kind} set ${name} failed: ${result.stderr.trim()}`.trim())
249
+ }
250
+ }