@marcusrbrown/infra 0.13.0 → 0.13.2

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 (108) hide show
  1. package/dist/cli.js +4977 -0
  2. package/package.json +7 -3
  3. package/src/__snapshots__/cli.test.ts.snap +0 -219
  4. package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
  5. package/src/__test__/mcp-ctx-fixture.ts +0 -104
  6. package/src/cli.test.ts +0 -66
  7. package/src/cli.ts +0 -43
  8. package/src/commands/cliproxy/config.test.ts +0 -475
  9. package/src/commands/cliproxy/config.ts +0 -238
  10. package/src/commands/cliproxy/deploy.test.ts +0 -181
  11. package/src/commands/cliproxy/deploy.ts +0 -157
  12. package/src/commands/cliproxy/host.test.ts +0 -73
  13. package/src/commands/cliproxy/host.ts +0 -31
  14. package/src/commands/cliproxy/index.ts +0 -21
  15. package/src/commands/cliproxy/keys.test.ts +0 -326
  16. package/src/commands/cliproxy/keys.ts +0 -201
  17. package/src/commands/cliproxy/login.test.ts +0 -415
  18. package/src/commands/cliproxy/login.ts +0 -131
  19. package/src/commands/cliproxy/models.test.ts +0 -592
  20. package/src/commands/cliproxy/models.ts +0 -237
  21. package/src/commands/cliproxy/open.test.ts +0 -132
  22. package/src/commands/cliproxy/open.ts +0 -78
  23. package/src/commands/cliproxy/setup/gh.test.ts +0 -218
  24. package/src/commands/cliproxy/setup/gh.ts +0 -250
  25. package/src/commands/cliproxy/setup/preview.test.ts +0 -159
  26. package/src/commands/cliproxy/setup/preview.ts +0 -41
  27. package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
  28. package/src/commands/cliproxy/setup/prompts.ts +0 -99
  29. package/src/commands/cliproxy/setup/providers.test.ts +0 -245
  30. package/src/commands/cliproxy/setup/providers.ts +0 -136
  31. package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
  32. package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
  33. package/src/commands/cliproxy/setup/templates.test.ts +0 -357
  34. package/src/commands/cliproxy/setup/templates.ts +0 -145
  35. package/src/commands/cliproxy/setup/validation.test.ts +0 -567
  36. package/src/commands/cliproxy/setup/validation.ts +0 -209
  37. package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
  38. package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
  39. package/src/commands/cliproxy/setup.test.ts +0 -2366
  40. package/src/commands/cliproxy/setup.ts +0 -809
  41. package/src/commands/cliproxy/shared.test.ts +0 -118
  42. package/src/commands/cliproxy/shared.ts +0 -84
  43. package/src/commands/cliproxy/status.test.ts +0 -1157
  44. package/src/commands/cliproxy/status.ts +0 -597
  45. package/src/commands/dashboard/deploy.test.ts +0 -215
  46. package/src/commands/dashboard/deploy.ts +0 -135
  47. package/src/commands/dashboard/host.test.ts +0 -79
  48. package/src/commands/dashboard/host.ts +0 -33
  49. package/src/commands/dashboard/index.ts +0 -13
  50. package/src/commands/dashboard/logs.test.ts +0 -317
  51. package/src/commands/dashboard/logs.ts +0 -180
  52. package/src/commands/dashboard/status.test.ts +0 -708
  53. package/src/commands/dashboard/status.ts +0 -293
  54. package/src/commands/gateway/backup.test.ts +0 -426
  55. package/src/commands/gateway/backup.ts +0 -205
  56. package/src/commands/gateway/deploy.test.ts +0 -491
  57. package/src/commands/gateway/deploy.ts +0 -173
  58. package/src/commands/gateway/host.test.ts +0 -89
  59. package/src/commands/gateway/host.ts +0 -33
  60. package/src/commands/gateway/index.ts +0 -17
  61. package/src/commands/gateway/logs.test.ts +0 -263
  62. package/src/commands/gateway/logs.ts +0 -160
  63. package/src/commands/gateway/restore.test.ts +0 -650
  64. package/src/commands/gateway/restore.ts +0 -308
  65. package/src/commands/gateway/status.test.ts +0 -755
  66. package/src/commands/gateway/status.ts +0 -255
  67. package/src/commands/keeweb/deploy.test.ts +0 -177
  68. package/src/commands/keeweb/deploy.ts +0 -191
  69. package/src/commands/keeweb/index.ts +0 -11
  70. package/src/commands/keeweb/open.test.ts +0 -54
  71. package/src/commands/keeweb/open.ts +0 -30
  72. package/src/commands/keeweb/status.test.ts +0 -509
  73. package/src/commands/keeweb/status.ts +0 -359
  74. package/src/commands/mcp.test.ts +0 -260
  75. package/src/commands/mcp.ts +0 -58
  76. package/src/commands/status.test.ts +0 -222
  77. package/src/commands/status.ts +0 -161
  78. package/src/commands/umami/deploy.test.ts +0 -202
  79. package/src/commands/umami/deploy.ts +0 -132
  80. package/src/commands/umami/host.test.ts +0 -79
  81. package/src/commands/umami/host.ts +0 -33
  82. package/src/commands/umami/index.ts +0 -13
  83. package/src/commands/umami/logs.test.ts +0 -191
  84. package/src/commands/umami/logs.ts +0 -163
  85. package/src/commands/umami/status.test.ts +0 -633
  86. package/src/commands/umami/status.ts +0 -285
  87. package/src/commands/vpn/client.test.ts +0 -949
  88. package/src/commands/vpn/client.ts +0 -467
  89. package/src/commands/vpn/deploy.test.ts +0 -208
  90. package/src/commands/vpn/deploy.ts +0 -143
  91. package/src/commands/vpn/host.test.ts +0 -89
  92. package/src/commands/vpn/host.ts +0 -36
  93. package/src/commands/vpn/index.ts +0 -15
  94. package/src/commands/vpn/logs.test.ts +0 -234
  95. package/src/commands/vpn/logs.ts +0 -136
  96. package/src/commands/vpn/peers.test.ts +0 -478
  97. package/src/commands/vpn/shared.test.ts +0 -454
  98. package/src/commands/vpn/shared.ts +0 -170
  99. package/src/commands/vpn/status.test.ts +0 -142
  100. package/src/commands/vpn/status.ts +0 -78
  101. package/src/conventions.test.ts +0 -1180
  102. package/src/lib/action-ctx.ts +0 -25
  103. package/src/lib/known-hosts.test.ts +0 -192
  104. package/src/lib/known-hosts.ts +0 -76
  105. package/src/lib/redact.ts +0 -21
  106. package/src/lib/ssh-identity.test.ts +0 -134
  107. package/src/lib/ssh-identity.ts +0 -76
  108. /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
@@ -1,237 +0,0 @@
1
- import type {goke} from 'goke'
2
- import type {ActionCtx} from '../../lib/action-ctx'
3
-
4
- import {z} from 'zod'
5
-
6
- import {requestJson} from './shared'
7
-
8
- declare const process: {
9
- env: Record<string, string | undefined>
10
- }
11
-
12
- const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
13
-
14
- function stripTrailingSlash(value: string): string {
15
- return value.endsWith('/') ? value.slice(0, -1) : value
16
- }
17
-
18
- // Permissive schema: unknown fields preserved via passthrough().
19
- // owned_by is optional — CLIProxyAPI v7 may omit it; provider is inferred from id instead.
20
- const modelEntrySchema = z
21
- .object({
22
- id: z.string(),
23
- owned_by: z.string().optional(),
24
- created: z.number().optional(),
25
- })
26
- .passthrough()
27
-
28
- const modelsResponseSchema = z
29
- .object({
30
- data: z.array(modelEntrySchema),
31
- })
32
- .passthrough()
33
-
34
- type ModelEntry = z.infer<typeof modelEntrySchema>
35
-
36
- // Known bare-id patterns per provider, used only when owned_by is absent
37
- // (e.g. CLIProxyAPI v7 omits owned_by from /v1/models entries).
38
- const PROVIDER_ID_PATTERNS: Record<string, RegExp> = {
39
- openai: /^(?:gpt-|o1-|o3-|codex)/i,
40
- anthropic: /^claude-/i,
41
- }
42
-
43
- const VALID_PROVIDERS = ['anthropic', 'openai'] as const
44
- type ValidProvider = (typeof VALID_PROVIDERS)[number]
45
-
46
- function isValidProvider(value: string): value is ValidProvider {
47
- return (VALID_PROVIDERS as readonly string[]).includes(value)
48
- }
49
-
50
- /**
51
- * Derives the provider string for a model entry.
52
- * Prefers owned_by when present and non-empty; falls back to PROVIDER_ID_PATTERNS
53
- * matching against the bare id; falls back to 'unknown'.
54
- * Display/validation-only — never an auth or trust signal.
55
- */
56
- function deriveProvider(entry: ModelEntry): string {
57
- if (entry.owned_by !== undefined && entry.owned_by.trim() !== '') {
58
- return entry.owned_by
59
- }
60
- // Strip any existing provider/ prefix before pattern matching
61
- const bareId = stripProviderPrefix(entry.id)
62
- for (const [provider, pattern] of Object.entries(PROVIDER_ID_PATTERNS)) {
63
- if (pattern.test(bareId)) return provider
64
- }
65
- return 'unknown'
66
- }
67
-
68
- /**
69
- * Strips an existing `provider/` prefix from a raw id, if present.
70
- * Defensive: handles entries that already have a prefix so we don't double-prefix.
71
- */
72
- function stripProviderPrefix(id: string): string {
73
- const slashIdx = id.indexOf('/')
74
- if (slashIdx === -1) return id
75
- // Only strip if the prefix looks like a known provider or 'unknown'
76
- const prefix = id.slice(0, slashIdx)
77
- if (prefix === 'unknown' || isValidProvider(prefix)) {
78
- return id.slice(slashIdx + 1)
79
- }
80
- return id
81
- }
82
-
83
- /**
84
- * Decides whether a /v1/models entry belongs to a provider.
85
- * Prefers owned_by when present; falls back to id prefix / known bare-id
86
- * patterns when absent. Display/validation-only — never an auth or trust signal.
87
- */
88
- function entryMatchesProvider(entry: ModelEntry, provider: string): boolean {
89
- return deriveProvider(entry) === provider
90
- }
91
-
92
- /**
93
- * Sort entries: grouped by provider (ascending), within each group by created asc.
94
- * Entries with missing/non-number created sort last within their group.
95
- */
96
- function sortEntries(entries: ModelEntry[]): ModelEntry[] {
97
- // Annotate with derived provider for stable grouping
98
- const annotated = entries.map(e => ({entry: e, provider: deriveProvider(e)}))
99
-
100
- // Sort: provider asc, then created asc (missing created → Infinity → sorts last)
101
- annotated.sort((a, b) => {
102
- const providerCmp = a.provider.localeCompare(b.provider)
103
- if (providerCmp !== 0) return providerCmp
104
- const aCreated = typeof a.entry.created === 'number' ? a.entry.created : Infinity
105
- const bCreated = typeof b.entry.created === 'number' ? b.entry.created : Infinity
106
- return aCreated - bCreated
107
- })
108
-
109
- return annotated.map(a => a.entry)
110
- }
111
-
112
- /**
113
- * Formats a model entry for plain output: `provider/raw_id`.
114
- */
115
- function formatPlainLine(entry: ModelEntry): string {
116
- const provider = deriveProvider(entry)
117
- const rawId = stripProviderPrefix(entry.id)
118
- return `${provider}/${rawId}`
119
- }
120
-
121
- interface VerboseEntry {
122
- id: string
123
- provider: string
124
- raw_id: string
125
- created: string | null
126
- }
127
-
128
- /**
129
- * Formats a model entry for verbose JSON output.
130
- */
131
- function formatVerboseEntry(entry: ModelEntry): VerboseEntry {
132
- const provider = deriveProvider(entry)
133
- const rawId = stripProviderPrefix(entry.id)
134
- return {
135
- id: `${provider}/${rawId}`,
136
- provider,
137
- raw_id: rawId,
138
- created: typeof entry.created === 'number' ? new Date(entry.created * 1000).toISOString() : null,
139
- }
140
- }
141
-
142
- export interface ModelsOptions {
143
- url?: string
144
- key?: string
145
- verbose?: boolean
146
- provider?: string
147
- }
148
-
149
- export async function cliproxyModelsAction(options: ModelsOptions, ctx: ActionCtx): Promise<void> {
150
- try {
151
- const baseUrl = stripTrailingSlash(options.url ?? process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
152
-
153
- // Trusted URL: the canonical configured/default host, trailing-slash-normalized.
154
- // Ambient env keys must ONLY follow to this trusted destination to prevent
155
- // secret exfiltration when an agent passes an attacker-controlled --url.
156
- const trustedUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
157
- const urlIsExplicitlyOverridden = options.url !== undefined && baseUrl !== trustedUrl
158
-
159
- // An explicit --key is always honored (operator knows what they're doing).
160
- // An ambient env key is only forwarded when the resolved baseUrl is trusted.
161
- const key = options.key ?? (urlIsExplicitlyOverridden ? undefined : process.env.CLIPROXY_API_KEY)
162
-
163
- if (!key) {
164
- throw new Error('No API key. Provide --key or set CLIPROXY_API_KEY.')
165
- }
166
-
167
- // Validate provider before any HTTP call
168
- if (options.provider !== undefined && !isValidProvider(options.provider)) {
169
- throw new Error(`Unknown provider "${options.provider}". Use anthropic or openai.`)
170
- }
171
-
172
- const endpoint = `${baseUrl}/v1/models`
173
- const raw = await requestJson(endpoint, {
174
- headers: {Authorization: `Bearer ${key}`},
175
- })
176
-
177
- const parseResult = modelsResponseSchema.safeParse(raw)
178
- if (!parseResult.success) {
179
- const message = parseResult.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ')
180
- throw new Error(`Malformed /v1/models response: ${message}`)
181
- }
182
-
183
- let entries = parseResult.data.data
184
-
185
- if (options.provider !== undefined) {
186
- entries = entries.filter(e => entryMatchesProvider(e, options.provider as string))
187
- }
188
-
189
- // Sort: provider-grouped (asc), date-asc within each group
190
- entries = sortEntries(entries)
191
-
192
- if (options.verbose) {
193
- // --verbose: emit a single JSON array (pretty-printed) of the fields we have.
194
- // Always valid JSON — empty/zero-match → [].
195
- const verboseEntries = entries.map(formatVerboseEntry)
196
- ctx.console.log(JSON.stringify(verboseEntries, null, 2))
197
- return
198
- }
199
-
200
- if (entries.length === 0) {
201
- ctx.console.log(
202
- options.provider === undefined ? 'No models.' : `No models found for provider ${options.provider}.`,
203
- )
204
- return
205
- }
206
-
207
- for (const entry of entries) {
208
- ctx.console.log(formatPlainLine(entry))
209
- }
210
- } catch (error) {
211
- const message = error instanceof Error ? error.message : String(error)
212
- ctx.console.error(message)
213
- ctx.process.exit(1)
214
- }
215
- }
216
-
217
- export function registerCliproxyModels(cli: ReturnType<typeof goke>): void {
218
- cli
219
- .command('cliproxy models [provider]', 'List the models CLIProxyAPI serves.')
220
- .option(
221
- '--url [url]',
222
- z.string().describe('Base URL for CLIProxyAPI. Falls back to CLIPROXY_URL or https://cliproxy.fro.bot.'),
223
- )
224
- .option('--key [key]', z.string().describe('API key (bearer). Falls back to CLIPROXY_API_KEY when omitted.'))
225
- .option('--verbose', 'Show owned_by and created date for each model.')
226
- .example('# List all models served by CLIProxyAPI')
227
- .example('infra cliproxy models')
228
- .example('# Filter to Anthropic models only')
229
- .example('infra cliproxy models anthropic')
230
- .example('# Show verbose output with owned_by and created date')
231
- .example('infra cliproxy models --verbose')
232
- .example('# Filter to OpenAI models with verbose output')
233
- .example('infra cliproxy models openai --verbose')
234
- .action((provider, options, ctx) =>
235
- cliproxyModelsAction({...options, provider: provider as string | undefined}, ctx),
236
- )
237
- }
@@ -1,132 +0,0 @@
1
- import {resolve} from 'node:path'
2
- import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
3
-
4
- const cliDir = resolve(import.meta.dir, '../../..')
5
-
6
- const envKeys = ['CLIPROXY_DOMAIN', 'HOME', 'PATH'] as const
7
-
8
- type ManagedEnvKey = (typeof envKeys)[number]
9
-
10
- let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
11
-
12
- function restoreManagedEnv(): void {
13
- for (const key of envKeys) {
14
- const value = originalEnv[key]
15
-
16
- if (value === undefined) {
17
- delete process.env[key]
18
- continue
19
- }
20
-
21
- process.env[key] = value
22
- }
23
- }
24
-
25
- async function runOpenCommand(
26
- args: string[],
27
- envOverrides: Partial<Record<ManagedEnvKey, string | undefined>> = {},
28
- ): Promise<{stdout: string; stderr: string; exitCode: number}> {
29
- const env = {...process.env}
30
-
31
- for (const [key, value] of Object.entries(envOverrides)) {
32
- if (value === undefined) {
33
- delete env[key]
34
- continue
35
- }
36
-
37
- env[key] = value
38
- }
39
-
40
- const proc = Bun.spawn(['bun', 'src/cli.ts', 'cliproxy', 'open', ...args], {
41
- cwd: cliDir,
42
- env: {
43
- ...env,
44
- HOME: env.HOME ?? '/tmp/test-home',
45
- NO_COLOR: '1',
46
- PATH: env.PATH ?? '/usr/bin:/bin',
47
- },
48
- stdout: 'pipe',
49
- stderr: 'pipe',
50
- })
51
-
52
- const [stdout, stderr, exitCode] = await Promise.all([
53
- new Response(proc.stdout).text(),
54
- new Response(proc.stderr).text(),
55
- proc.exited,
56
- ])
57
-
58
- return {stdout, stderr, exitCode}
59
- }
60
-
61
- describe('cliproxy open', () => {
62
- beforeEach(() => {
63
- originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
64
- })
65
-
66
- afterEach(() => {
67
- restoreManagedEnv()
68
- })
69
-
70
- describe('validation', () => {
71
- it('requires interactive terminal', async () => {
72
- const {stderr, exitCode} = await runOpenCommand([])
73
- expect(exitCode).not.toBe(0)
74
- expect(stderr).toContain('interactive terminal')
75
- })
76
- })
77
-
78
- describe('host resolution', () => {
79
- it('uses --host flag', async () => {
80
- const {stderr, exitCode} = await runOpenCommand(['--host', 'custom.host.example'])
81
- expect(exitCode).not.toBe(0)
82
- expect(stderr).toContain('interactive terminal')
83
- })
84
-
85
- it('uses CLIPROXY_DOMAIN env var', async () => {
86
- const {stderr, exitCode} = await runOpenCommand([], {
87
- CLIPROXY_DOMAIN: 'custom.host.example',
88
- })
89
- expect(exitCode).not.toBe(0)
90
- expect(stderr).toContain('interactive terminal')
91
- })
92
- })
93
-
94
- describe('host validation', () => {
95
- it('rejects CLIPROXY_DOMAIN env with a leading dash (ProxyCommand injection vector)', async () => {
96
- const {stderr, exitCode} = await runOpenCommand([], {CLIPROXY_DOMAIN: '-oProxyCommand=evil'})
97
- expect(exitCode).not.toBe(0)
98
- expect(stderr).toContain('Invalid CLIPROXY_DOMAIN')
99
- })
100
-
101
- it('rejects CLIPROXY_DOMAIN env with shell metacharacters (semicolon)', async () => {
102
- const {stderr, exitCode} = await runOpenCommand([], {CLIPROXY_DOMAIN: 'gateway.example.com;rm -rf'})
103
- expect(exitCode).not.toBe(0)
104
- expect(stderr).toContain('Invalid CLIPROXY_DOMAIN')
105
- })
106
-
107
- it('rejects CLIPROXY_DOMAIN env with an at-sign', async () => {
108
- const {stderr, exitCode} = await runOpenCommand([], {CLIPROXY_DOMAIN: 'user@cliproxy.fro.bot'})
109
- expect(exitCode).not.toBe(0)
110
- expect(stderr).toContain('Invalid CLIPROXY_DOMAIN')
111
- })
112
- })
113
-
114
- describe('unit: resolveHost', () => {
115
- it('returns input when provided', async () => {
116
- const {resolveHost} = await import('./open')
117
- expect(resolveHost('test.example.com')).toBe('test.example.com')
118
- })
119
-
120
- it('falls back to CLIPROXY_DOMAIN', async () => {
121
- process.env.CLIPROXY_DOMAIN = 'env.example.com'
122
- const {resolveHost} = await import('./open')
123
- expect(resolveHost()).toBe('env.example.com')
124
- })
125
-
126
- it('falls back to default host', async () => {
127
- delete process.env.CLIPROXY_DOMAIN
128
- const {resolveHost} = await import('./open')
129
- expect(resolveHost()).toBe('cliproxy.fro.bot')
130
- })
131
- })
132
- })
@@ -1,78 +0,0 @@
1
- import type {goke} from 'goke'
2
-
3
- import {z} from 'zod'
4
-
5
- import {validateCliproxyHost} from './host'
6
-
7
- const DEFAULT_HOST = 'cliproxy.fro.bot'
8
- const DEFAULT_REMOTE_USER = 'root'
9
-
10
- export function resolveHost(input?: string): string {
11
- const host = input ?? process.env.CLIPROXY_DOMAIN ?? DEFAULT_HOST
12
-
13
- if (!host) {
14
- throw new Error('CLIPROXY_DOMAIN is required. Pass --host or set CLIPROXY_DOMAIN.')
15
- }
16
-
17
- return host
18
- }
19
-
20
- export function registerCliproxyOpen(cli: ReturnType<typeof goke>): void {
21
- cli
22
- .command('cliproxy open', 'Launch CLIProxyAPI TUI dashboard via SSH.')
23
- .option(
24
- '--host [host]',
25
- z.string().describe('SSH host for CLIProxyAPI server. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.'),
26
- )
27
- .example('# Open the TUI dashboard on the remote CLIProxyAPI instance')
28
- .example('infra cliproxy open')
29
- .example('# Open on a custom host')
30
- .example('infra cliproxy open --host custom.example.com')
31
- .action(async options => {
32
- const host = validateCliproxyHost(resolveHost(options.host))
33
-
34
- if (!process.stdin.isTTY) {
35
- throw new Error('cliproxy open requires an interactive terminal. Run from a shell with TTY attached.')
36
- }
37
-
38
- const path = process.env.PATH
39
- const home = process.env.HOME
40
-
41
- if (!path) {
42
- throw new Error('PATH is required to invoke ssh')
43
- }
44
-
45
- if (!home) {
46
- throw new Error('HOME is required to invoke ssh')
47
- }
48
-
49
- const remoteCommand = 'cd /opt/cliproxy && docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --tui'
50
-
51
- const child = Bun.spawn(
52
- [
53
- 'ssh',
54
- '-tt',
55
- '-o',
56
- 'BatchMode=yes',
57
- '-o',
58
- 'ConnectTimeout=10',
59
- `${DEFAULT_REMOTE_USER}@${host}`,
60
- remoteCommand,
61
- ],
62
- {
63
- env: {
64
- PATH: path,
65
- HOME: home,
66
- },
67
- stdin: 'inherit',
68
- stdout: 'inherit',
69
- stderr: 'inherit',
70
- },
71
- )
72
-
73
- const exitCode = await child.exited
74
- if (exitCode !== 0) {
75
- throw new Error(`Remote TUI command failed with exit code ${exitCode}`)
76
- }
77
- })
78
- }
@@ -1,218 +0,0 @@
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
- })