@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,415 +0,0 @@
1
- import type {SpawnFn} from './login'
2
-
3
- import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
4
-
5
- const envKeys = ['CLIPROXY_DOMAIN', 'HOME', 'PATH', 'SSH_AUTH_SOCK'] as const
6
-
7
- type ManagedEnvKey = (typeof envKeys)[number]
8
-
9
- let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
10
- let originalIsTTY: boolean | 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
- /** Minimal SpawnFn mock for inherit-stdio calls — records invocations and resolves with exitCode. */
26
- function makeSpawnOk(exitCode = 0): {spawnFn: SpawnFn; calls: {cmd: string[]; opts: unknown}[]} {
27
- const calls: {cmd: string[]; opts: unknown}[] = []
28
- const spawnFn: SpawnFn = (cmd, opts) => {
29
- calls.push({cmd, opts})
30
- return {exited: Promise.resolve(exitCode)}
31
- }
32
-
33
- return {spawnFn, calls}
34
- }
35
-
36
- // Helper: a SpawnFn that must never be called (proves provider check fires before spawn)
37
- const neverSpawn: SpawnFn = () => {
38
- throw new Error('spawn must not be called for invalid provider')
39
- }
40
-
41
- // Helper: set up a valid TTY + env so spawn-path tests can reach the spawn call
42
- function setValidEnv(): void {
43
- Object.defineProperty(process.stdin, 'isTTY', {value: true, configurable: true})
44
- process.env.SSH_AUTH_SOCK = '/tmp/test-agent.sock'
45
- process.env.PATH = process.env.PATH ?? '/usr/bin'
46
- process.env.HOME = process.env.HOME ?? '/root'
47
- }
48
-
49
- describe('cliproxy login', () => {
50
- beforeEach(() => {
51
- originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
52
- originalIsTTY = process.stdin.isTTY
53
- })
54
-
55
- afterEach(() => {
56
- restoreManagedEnv()
57
- Object.defineProperty(process.stdin, 'isTTY', {value: originalIsTTY, configurable: true})
58
- })
59
-
60
- describe('validation', () => {
61
- it('rejects unsupported providers', async () => {
62
- const {cliproxyLoginAction} = await import('./login')
63
- const {spawnFn} = makeSpawnOk()
64
-
65
- // Provider check happens before TTY check — no need to set isTTY
66
- await expect(cliproxyLoginAction('openai', {}, spawnFn)).rejects.toThrow('Unsupported provider')
67
- await expect(cliproxyLoginAction('openai', {}, spawnFn)).rejects.toThrow('Supported: claude, codex.')
68
- })
69
-
70
- it('requires interactive terminal (checked before SSH_AUTH_SOCK)', async () => {
71
- const {cliproxyLoginAction} = await import('./login')
72
- const {spawnFn} = makeSpawnOk()
73
-
74
- // Simulate non-TTY environment (as subprocess tests did)
75
- Object.defineProperty(process.stdin, 'isTTY', {value: false, configurable: true})
76
-
77
- await expect(cliproxyLoginAction('claude', {}, spawnFn)).rejects.toThrow('interactive terminal')
78
- })
79
- })
80
-
81
- describe('host resolution', () => {
82
- it('uses CLIPROXY_DOMAIN env var', async () => {
83
- const {cliproxyLoginAction} = await import('./login')
84
- const {spawnFn} = makeSpawnOk()
85
-
86
- Object.defineProperty(process.stdin, 'isTTY', {value: false, configurable: true})
87
- process.env.CLIPROXY_DOMAIN = 'custom.host.example'
88
-
89
- // TTY check fires before host resolution — error is about TTY
90
- await expect(cliproxyLoginAction('claude', {}, spawnFn)).rejects.toThrow('interactive terminal')
91
- })
92
-
93
- it('uses --host flag', async () => {
94
- const {cliproxyLoginAction} = await import('./login')
95
- const {spawnFn} = makeSpawnOk()
96
-
97
- Object.defineProperty(process.stdin, 'isTTY', {value: false, configurable: true})
98
-
99
- // TTY check fires before host resolution — error is about TTY
100
- await expect(cliproxyLoginAction('claude', {host: 'custom.host.example'}, spawnFn)).rejects.toThrow(
101
- 'interactive terminal',
102
- )
103
- })
104
- })
105
-
106
- describe('unit: resolveHost', () => {
107
- it('returns input when provided', async () => {
108
- const {resolveHost} = await import('./login')
109
- expect(resolveHost('test.example.com')).toBe('test.example.com')
110
- })
111
-
112
- it('falls back to CLIPROXY_DOMAIN', async () => {
113
- process.env.CLIPROXY_DOMAIN = 'env.example.com'
114
- const {resolveHost} = await import('./login')
115
- expect(resolveHost()).toBe('env.example.com')
116
- })
117
-
118
- it('falls back to default host', async () => {
119
- delete process.env.CLIPROXY_DOMAIN
120
- const {resolveHost} = await import('./login')
121
- expect(resolveHost()).toBe('cliproxy.fro.bot')
122
- })
123
- })
124
-
125
- describe('unit: requireSshAuthSock', () => {
126
- it('returns SSH_AUTH_SOCK when set', async () => {
127
- process.env.SSH_AUTH_SOCK = '/tmp/test-agent.sock'
128
- const {requireSshAuthSock} = await import('./login')
129
- expect(requireSshAuthSock()).toBe('/tmp/test-agent.sock')
130
- })
131
-
132
- it('throws when SSH_AUTH_SOCK is not set', async () => {
133
- delete process.env.SSH_AUTH_SOCK
134
- const {requireSshAuthSock} = await import('./login')
135
- expect(() => requireSshAuthSock()).toThrow('SSH_AUTH_SOCK is required')
136
- })
137
- })
138
-
139
- describe('provider flags', () => {
140
- it('happy path — codex: spawn args contain --codex-device-login and --no-browser', async () => {
141
- const {cliproxyLoginAction} = await import('./login')
142
- const {spawnFn, calls} = makeSpawnOk(0)
143
- setValidEnv()
144
-
145
- await cliproxyLoginAction('codex', {}, spawnFn)
146
-
147
- const cmd = calls[0]!.cmd
148
- expect(cmd.join(' ')).toContain('--codex-device-login')
149
- expect(cmd.join(' ')).toContain('--no-browser')
150
- expect(cmd.join(' ')).not.toContain('--codex-login ')
151
- })
152
-
153
- it('happy path — claude regression: spawn args contain --claude-login and --no-browser', async () => {
154
- const {cliproxyLoginAction} = await import('./login')
155
- const {spawnFn, calls} = makeSpawnOk(0)
156
- setValidEnv()
157
-
158
- await cliproxyLoginAction('claude', {}, spawnFn)
159
-
160
- const cmd = calls[0]!.cmd
161
- expect(cmd.join(' ')).toContain('--claude-login')
162
- expect(cmd.join(' ')).toContain('--no-browser')
163
- })
164
-
165
- it('error path — unknown provider "chatgpt": rejects with correct message, no spawn', async () => {
166
- const {cliproxyLoginAction} = await import('./login')
167
-
168
- await expect(cliproxyLoginAction('chatgpt', {}, neverSpawn)).rejects.toThrow(
169
- 'Unsupported provider "chatgpt". Supported: claude, codex.',
170
- )
171
- })
172
-
173
- it('error path — empty provider: rejects with correct message, no spawn', async () => {
174
- const {cliproxyLoginAction} = await import('./login')
175
-
176
- await expect(cliproxyLoginAction('', {}, neverSpawn)).rejects.toThrow(
177
- 'Unsupported provider "". Supported: claude, codex.',
178
- )
179
- })
180
-
181
- it('error path — malformed provider path traversal: rejects with correct message, no spawn', async () => {
182
- const {cliproxyLoginAction} = await import('./login')
183
-
184
- await expect(cliproxyLoginAction('../../../etc/passwd', {}, neverSpawn)).rejects.toThrow(
185
- 'Unsupported provider "../../../etc/passwd". Supported: claude, codex.',
186
- )
187
- })
188
-
189
- it('error path — prototype-chain bypass "__proto__": rejects with correct message, no spawn', async () => {
190
- const {cliproxyLoginAction} = await import('./login')
191
-
192
- await expect(cliproxyLoginAction('__proto__', {}, neverSpawn)).rejects.toThrow(
193
- 'Unsupported provider "__proto__". Supported: claude, codex.',
194
- )
195
- })
196
-
197
- it('error path — prototype-chain bypass "constructor": rejects with correct message, no spawn', async () => {
198
- const {cliproxyLoginAction} = await import('./login')
199
-
200
- await expect(cliproxyLoginAction('constructor', {}, neverSpawn)).rejects.toThrow(
201
- 'Unsupported provider "constructor". Supported: claude, codex.',
202
- )
203
- })
204
-
205
- it('error path — prototype-chain bypass "hasOwnProperty": rejects with correct message, no spawn', async () => {
206
- const {cliproxyLoginAction} = await import('./login')
207
-
208
- await expect(cliproxyLoginAction('hasOwnProperty', {}, neverSpawn)).rejects.toThrow(
209
- 'Unsupported provider "hasOwnProperty". Supported: claude, codex.',
210
- )
211
- })
212
- })
213
-
214
- describe('host validation', () => {
215
- it('rejects --host with a leading dash (ProxyCommand injection vector), no spawn', async () => {
216
- const {cliproxyLoginAction} = await import('./login')
217
- setValidEnv()
218
-
219
- await expect(cliproxyLoginAction('codex', {host: '-oProxyCommand=evil'}, neverSpawn)).rejects.toThrow(
220
- 'Invalid CLIPROXY_DOMAIN',
221
- )
222
- })
223
-
224
- it('rejects CLIPROXY_DOMAIN env with a leading dash, no spawn', async () => {
225
- const {cliproxyLoginAction} = await import('./login')
226
- setValidEnv()
227
- process.env.CLIPROXY_DOMAIN = '-oProxyCommand=evil'
228
-
229
- await expect(cliproxyLoginAction('codex', {}, neverSpawn)).rejects.toThrow('Invalid CLIPROXY_DOMAIN')
230
- })
231
-
232
- it('rejects --host with shell metacharacters (semicolon), no spawn', async () => {
233
- const {cliproxyLoginAction} = await import('./login')
234
- setValidEnv()
235
-
236
- await expect(cliproxyLoginAction('codex', {host: 'gateway.example.com;rm -rf'}, neverSpawn)).rejects.toThrow(
237
- 'Invalid CLIPROXY_DOMAIN',
238
- )
239
- })
240
- })
241
-
242
- describe('host forwarding into ssh argv', () => {
243
- it('forwards resolved host from CLIPROXY_DOMAIN env var into ssh argv', async () => {
244
- const {cliproxyLoginAction} = await import('./login')
245
- const {spawnFn, calls} = makeSpawnOk(0)
246
- setValidEnv()
247
- process.env.CLIPROXY_DOMAIN = 'env-host.example.com'
248
-
249
- await cliproxyLoginAction('claude', {}, spawnFn)
250
-
251
- expect(calls).toHaveLength(1)
252
- expect(calls[0]!.cmd).toContain('root@env-host.example.com')
253
- })
254
-
255
- it('forwards resolved host from --host option into ssh argv', async () => {
256
- const {cliproxyLoginAction} = await import('./login')
257
- const {spawnFn, calls} = makeSpawnOk(0)
258
- setValidEnv()
259
- delete process.env.CLIPROXY_DOMAIN
260
-
261
- await cliproxyLoginAction('claude', {host: 'flag-host.example.com'}, spawnFn)
262
-
263
- expect(calls).toHaveLength(1)
264
- expect(calls[0]!.cmd).toContain('root@flag-host.example.com')
265
- })
266
- })
267
-
268
- describe('non-zero exit propagation', () => {
269
- it('throws with exit code in message when remote ssh exits non-zero', async () => {
270
- const {cliproxyLoginAction} = await import('./login')
271
- const {spawnFn, calls} = makeSpawnOk(17)
272
- setValidEnv()
273
-
274
- await expect(cliproxyLoginAction('claude', {}, spawnFn)).rejects.toThrow(
275
- 'Remote login command failed with exit code 17',
276
- )
277
- expect(calls).toHaveLength(1)
278
- })
279
-
280
- it('codex non-zero exit: error message includes exit code and v6.10.9 version requirement', async () => {
281
- const {cliproxyLoginAction} = await import('./login')
282
- const {spawnFn} = makeSpawnOk(1)
283
- setValidEnv()
284
-
285
- const error = await cliproxyLoginAction('codex', {}, spawnFn).catch((error_: unknown) => error_)
286
- expect(error).toBeInstanceOf(Error)
287
- const message = (error as Error).message
288
- expect(message).toMatch(/exit code 1/)
289
- expect(message).toMatch(/v6\.10\.9/)
290
- expect(message).toMatch(/cliproxy-provider-version-skew\.md/)
291
- })
292
-
293
- it('codex non-zero exit: error message does not bleed into claude non-zero exit', async () => {
294
- const {cliproxyLoginAction} = await import('./login')
295
- const {spawnFn} = makeSpawnOk(2)
296
- setValidEnv()
297
-
298
- const error = await cliproxyLoginAction('claude', {}, spawnFn).catch((error_: unknown) => error_)
299
- expect(error).toBeInstanceOf(Error)
300
- const message = (error as Error).message
301
- expect(message).toMatch(/Remote login command failed with exit code 2/)
302
- expect(message).not.toMatch(/v6\.10\.9/)
303
- expect(message).not.toMatch(/cliproxy-provider-version-skew/)
304
- })
305
- })
306
-
307
- describe('ssh spawn contract', () => {
308
- it('happy path — claude: spawn argv and options match ssh boundary contract', async () => {
309
- const {cliproxyLoginAction} = await import('./login')
310
- const {spawnFn, calls} = makeSpawnOk(0)
311
- setValidEnv()
312
- process.env.SSH_AUTH_SOCK = '/tmp/contract-test.sock'
313
- const capturedPath = process.env.PATH!
314
- const capturedHome = process.env.HOME!
315
-
316
- await cliproxyLoginAction('claude', {}, spawnFn)
317
-
318
- expect(calls).toHaveLength(1)
319
- const {cmd, opts} = calls[0]!
320
- const spawnOpts = opts as Parameters<SpawnFn>[1]
321
-
322
- // SSH flags
323
- expect(cmd).toContain('-tt')
324
- expect(cmd).toContain('BatchMode=yes')
325
- expect(cmd).toContain('ConnectTimeout=10')
326
-
327
- // stdio inheritance
328
- expect(spawnOpts.stdin).toBe('inherit')
329
- expect(spawnOpts.stdout).toBe('inherit')
330
- expect(spawnOpts.stderr).toBe('inherit')
331
-
332
- // forwarded env vars
333
- expect(spawnOpts.env.PATH).toBe(capturedPath)
334
- expect(spawnOpts.env.HOME).toBe(capturedHome)
335
- expect(spawnOpts.env.SSH_AUTH_SOCK).toBe('/tmp/contract-test.sock')
336
- })
337
-
338
- it('happy path — codex: spawn argv and options match ssh boundary contract', async () => {
339
- const {cliproxyLoginAction} = await import('./login')
340
- const {spawnFn, calls} = makeSpawnOk(0)
341
- setValidEnv()
342
- process.env.SSH_AUTH_SOCK = '/tmp/contract-test.sock'
343
- const capturedPath = process.env.PATH!
344
- const capturedHome = process.env.HOME!
345
-
346
- await cliproxyLoginAction('codex', {}, spawnFn)
347
-
348
- expect(calls).toHaveLength(1)
349
- const {cmd, opts} = calls[0]!
350
- const spawnOpts = opts as Parameters<SpawnFn>[1]
351
-
352
- // SSH flags
353
- expect(cmd).toContain('-tt')
354
- expect(cmd).toContain('BatchMode=yes')
355
- expect(cmd).toContain('ConnectTimeout=10')
356
-
357
- // stdio inheritance
358
- expect(spawnOpts.stdin).toBe('inherit')
359
- expect(spawnOpts.stdout).toBe('inherit')
360
- expect(spawnOpts.stderr).toBe('inherit')
361
-
362
- // forwarded env vars
363
- expect(spawnOpts.env.PATH).toBe(capturedPath)
364
- expect(spawnOpts.env.HOME).toBe(capturedHome)
365
- expect(spawnOpts.env.SSH_AUTH_SOCK).toBe('/tmp/contract-test.sock')
366
- })
367
- })
368
-
369
- describe('anti-phishing notice', () => {
370
- let logLines: string[]
371
- let originalLog: typeof console.log
372
-
373
- beforeEach(() => {
374
- logLines = []
375
- originalLog = console.log
376
- console.log = (...args: unknown[]) => {
377
- logLines.push(args.map(String).join(' '))
378
- }
379
- })
380
-
381
- afterEach(() => {
382
- console.log = originalLog
383
- })
384
-
385
- it('codex: anti-phishing notice appears before spawn', async () => {
386
- const {cliproxyLoginAction} = await import('./login')
387
- const linesBeforeSpawn: string[] = []
388
- let spawnCalled = false
389
-
390
- const trackingSpawn: SpawnFn = (_cmd, _opts) => {
391
- linesBeforeSpawn.push(...logLines)
392
- spawnCalled = true
393
- return {exited: Promise.resolve(0)}
394
- }
395
-
396
- setValidEnv()
397
- await cliproxyLoginAction('codex', {}, trackingSpawn)
398
-
399
- expect(spawnCalled).toBe(true)
400
- const allOutput = linesBeforeSpawn.join('\n')
401
- expect(allOutput).toMatch(/openai\.com/i)
402
- })
403
-
404
- it('claude: anti-phishing notice does NOT appear', async () => {
405
- const {cliproxyLoginAction} = await import('./login')
406
- const {spawnFn} = makeSpawnOk(0)
407
- setValidEnv()
408
-
409
- await cliproxyLoginAction('claude', {}, spawnFn)
410
-
411
- const allOutput = logLines.join('\n')
412
- expect(allOutput).not.toMatch(/openai\.com/)
413
- })
414
- })
415
- })
@@ -1,131 +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
- const PROVIDER_FLAGS = {
11
- claude: '--claude-login',
12
- codex: '--codex-device-login',
13
- } as const satisfies Record<string, string>
14
-
15
- const SUPPORTED_PROVIDERS_DISPLAY = Object.keys(PROVIDER_FLAGS).join(', ')
16
-
17
- export type SpawnFn = (
18
- cmd: string[],
19
- opts: {env: Record<string, string>; stdin: 'inherit'; stdout: 'inherit'; stderr: 'inherit'},
20
- ) => {exited: Promise<number>}
21
-
22
- export interface LoginOptions {
23
- host?: string
24
- }
25
-
26
- export function resolveHost(input?: string): string {
27
- const host = input ?? process.env.CLIPROXY_DOMAIN ?? DEFAULT_HOST
28
-
29
- if (!host) {
30
- throw new Error('CLIPROXY_DOMAIN is required. Pass --host or set CLIPROXY_DOMAIN.')
31
- }
32
-
33
- return host
34
- }
35
-
36
- export function requireSshAuthSock(): string {
37
- const sshAuthSock = process.env.SSH_AUTH_SOCK
38
- if (!sshAuthSock) {
39
- throw new Error('SSH_AUTH_SOCK is required. Start ssh-agent and load your SSH key before running cliproxy login.')
40
- }
41
-
42
- return sshAuthSock
43
- }
44
-
45
- export async function cliproxyLoginAction(
46
- provider: string,
47
- options: LoginOptions,
48
- spawnFn: SpawnFn = Bun.spawn,
49
- ): Promise<void> {
50
- if (!Object.prototype.hasOwnProperty.call(PROVIDER_FLAGS, provider)) {
51
- throw new Error(`Unsupported provider "${provider}". Supported: ${SUPPORTED_PROVIDERS_DISPLAY}.`)
52
- }
53
- const providerFlag = PROVIDER_FLAGS[provider as keyof typeof PROVIDER_FLAGS]
54
-
55
- if (!process.stdin.isTTY) {
56
- throw new Error('cliproxy login requires an interactive terminal. Run from a shell with TTY attached.')
57
- }
58
-
59
- const host = validateCliproxyHost(resolveHost(options.host))
60
- const sshAuthSock = requireSshAuthSock()
61
- const path = process.env.PATH
62
- const home = process.env.HOME
63
-
64
- if (!path) {
65
- throw new Error('PATH is required to invoke ssh')
66
- }
67
-
68
- if (!home) {
69
- throw new Error('HOME is required to invoke ssh')
70
- }
71
-
72
- if (provider === 'codex') {
73
- console.log()
74
- console.log(' Verify the URL')
75
- console.log(' ─────────────')
76
- console.log(" Codex login uses OpenAI's device-code flow. The droplet will print a code")
77
- console.log(' and a URL. Before entering the code, verify the URL points to openai.com —')
78
- console.log(' only complete the flow on the official OpenAI domain.')
79
- console.log()
80
- }
81
-
82
- const remoteCommand = `cd /opt/cliproxy && docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --no-browser ${providerFlag}`
83
-
84
- const child = spawnFn(
85
- ['ssh', '-tt', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', `${DEFAULT_REMOTE_USER}@${host}`, remoteCommand],
86
- {
87
- env: {
88
- PATH: path,
89
- HOME: home,
90
- SSH_AUTH_SOCK: sshAuthSock,
91
- },
92
- stdin: 'inherit',
93
- stdout: 'inherit',
94
- stderr: 'inherit',
95
- },
96
- )
97
-
98
- const exitCode = await child.exited
99
- if (exitCode !== 0) {
100
- if (provider === 'codex') {
101
- throw new Error(
102
- `Remote login command failed with exit code ${exitCode}. ` +
103
- `The most likely cause is that the droplet's CLIProxyAPI binary predates --codex-device-login support (requires v6.10.9+). ` +
104
- `See docs/runbooks/cliproxy-provider-version-skew.md for diagnosis and remediation steps.`,
105
- )
106
- }
107
-
108
- throw new Error(`Remote login command failed with exit code ${exitCode}`)
109
- }
110
-
111
- console.log('If an OAuth URL was printed above, open it in your browser to complete login.')
112
- }
113
-
114
- export function registerCliproxyLogin(cli: ReturnType<typeof goke>): void {
115
- cli
116
- .command(
117
- 'cliproxy login <provider>',
118
- `Run provider login on the remote CLIProxyAPI host. Supported providers: ${SUPPORTED_PROVIDERS_DISPLAY}.`,
119
- )
120
- .option(
121
- '--host [host]',
122
- z
123
- .string()
124
- .describe('CLIProxyAPI droplet host for SSH execution. Falls back to CLIPROXY_DOMAIN or cliproxy.fro.bot.'),
125
- )
126
- .example('# Start Claude login flow on remote CLIProxyAPI instance')
127
- .example('infra cliproxy login claude')
128
- .example('# Start ChatGPT Pro login flow via device-code')
129
- .example('infra cliproxy login codex')
130
- .action((provider, options) => cliproxyLoginAction(provider, options))
131
- }