@marcusrbrown/infra 0.4.11 → 0.6.0

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.
@@ -1,7 +1,12 @@
1
1
  import type {goke} from 'goke'
2
2
 
3
+ import type {ActionCtx} from '../../lib/action-ctx'
4
+
3
5
  import {z} from 'zod'
4
6
 
7
+ /** Minimal ctx surface consumed by cliproxy keys actions. Satisfied by both GokeExecutionContext and CapturedCtx. */
8
+ // ActionCtx imported from lib/action-ctx — single source of truth for action ctx shape
9
+
5
10
  const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
6
11
  const HTTP_TIMEOUT_MS = 10_000
7
12
 
@@ -64,6 +69,114 @@ export function toStringArray(payload: unknown): string[] {
64
69
  return []
65
70
  }
66
71
 
72
+ export interface KeysListOptions {
73
+ url?: string
74
+ key?: string
75
+ json?: boolean
76
+ }
77
+
78
+ export async function cliproxyKeysListAction(options: KeysListOptions, ctx: ActionCtx): Promise<string[]> {
79
+ try {
80
+ const baseUrl = resolveBaseUrl(options.url)
81
+ const managementKey = resolveManagementKey(options.key)
82
+ const endpoint = `${baseUrl}/v0/management/api-keys`
83
+ const payload = await requestJson(endpoint, {
84
+ method: 'GET',
85
+ headers: managementHeaders(managementKey),
86
+ })
87
+
88
+ const keys = toStringArray(payload)
89
+ ctx.console.error('⚠️ Output contains API keys — avoid logging or storing in shared locations')
90
+
91
+ if (options.json) {
92
+ ctx.console.log(JSON.stringify(keys, null, 2))
93
+ } else if (keys.length === 0) {
94
+ ctx.console.log('No API keys configured')
95
+ } else {
96
+ for (const [index, apiKey] of keys.entries()) {
97
+ ctx.console.log(`${index + 1}. ${apiKey}`)
98
+ }
99
+ }
100
+
101
+ return keys
102
+ } catch (error) {
103
+ const message = error instanceof Error ? error.message : String(error)
104
+ ctx.console.error(message)
105
+ ctx.process.exit(1)
106
+ return [] // unreachable; satisfies TS that all paths return
107
+ }
108
+ }
109
+
110
+ export interface KeysAddOptions {
111
+ url?: string
112
+ key?: string
113
+ }
114
+
115
+ export async function cliproxyKeysAddAction(
116
+ apiKeyToAdd: string,
117
+ options: KeysAddOptions,
118
+ ctx: ActionCtx,
119
+ ): Promise<void> {
120
+ try {
121
+ const baseUrl = resolveBaseUrl(options.url)
122
+ const managementKey = resolveManagementKey(options.key)
123
+ const endpoint = `${baseUrl}/v0/management/api-keys`
124
+
125
+ const currentPayload = await requestJson(endpoint, {
126
+ method: 'GET',
127
+ headers: managementHeaders(managementKey),
128
+ })
129
+ const currentKeys = toStringArray(currentPayload)
130
+
131
+ if (currentKeys.includes(apiKeyToAdd)) {
132
+ ctx.console.log('Key already present; no update required.')
133
+ return
134
+ }
135
+
136
+ const nextKeys = [...currentKeys, apiKeyToAdd]
137
+ await requestJson(endpoint, {
138
+ method: 'PUT',
139
+ headers: managementHeaders(managementKey),
140
+ body: JSON.stringify(nextKeys),
141
+ })
142
+
143
+ ctx.console.log(`Added key "${apiKeyToAdd}". Current key count: ${nextKeys.length}.`)
144
+ } catch (error) {
145
+ const message = error instanceof Error ? error.message : String(error)
146
+ ctx.console.error(message)
147
+ ctx.process.exit(1)
148
+ }
149
+ }
150
+
151
+ export interface KeysRemoveOptions {
152
+ url?: string
153
+ key?: string
154
+ }
155
+
156
+ export async function cliproxyKeysRemoveAction(
157
+ apiKeyToRemove: string,
158
+ options: KeysRemoveOptions,
159
+ ctx: ActionCtx,
160
+ ): Promise<void> {
161
+ try {
162
+ const baseUrl = resolveBaseUrl(options.url)
163
+ const managementKey = resolveManagementKey(options.key)
164
+ const params = new URLSearchParams({value: apiKeyToRemove})
165
+ const endpoint = `${baseUrl}/v0/management/api-keys?${params.toString()}`
166
+
167
+ const payload = await requestJson(endpoint, {
168
+ method: 'DELETE',
169
+ headers: managementHeaders(managementKey),
170
+ })
171
+
172
+ ctx.console.log(JSON.stringify(payload, null, 2))
173
+ } catch (error) {
174
+ const message = error instanceof Error ? error.message : String(error)
175
+ ctx.console.error(message)
176
+ ctx.process.exit(1)
177
+ }
178
+ }
179
+
67
180
  export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
68
181
  cli
69
182
  .command('cliproxy keys list', 'List CLIProxyAPI API keys from the management API.')
@@ -80,28 +193,7 @@ export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
80
193
  z.string().describe('Management API key. Falls back to CLIPROXY_MANAGEMENT_KEY when omitted.'),
81
194
  )
82
195
  .option('--json', 'Output raw JSON array instead of a numbered list.')
83
- .action(async options => {
84
- const baseUrl = resolveBaseUrl(options.url)
85
- const managementKey = resolveManagementKey(options.key)
86
- const endpoint = `${baseUrl}/v0/management/api-keys`
87
- const payload = await requestJson(endpoint, {
88
- method: 'GET',
89
- headers: managementHeaders(managementKey),
90
- })
91
-
92
- const keys = toStringArray(payload)
93
- console.error('⚠️ Output contains API keys — avoid logging or storing in shared locations')
94
-
95
- if (options.json) {
96
- console.log(JSON.stringify(keys, null, 2))
97
- } else if (keys.length === 0) {
98
- console.log('No API keys configured')
99
- } else {
100
- for (const [index, key] of keys.entries()) {
101
- console.log(`${index + 1}. ${key}`)
102
- }
103
- }
104
- })
196
+ .action(cliproxyKeysListAction)
105
197
 
106
198
  cli
107
199
  .command(
@@ -122,31 +214,7 @@ export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
122
214
  )
123
215
  .example('# Add a new API key to the current key set')
124
216
  .example('infra cliproxy keys add sk-live-123')
125
- .action(async (apiKeyToAdd, options) => {
126
- const baseUrl = resolveBaseUrl(options.url)
127
- const managementKey = resolveManagementKey(options.key)
128
- const endpoint = `${baseUrl}/v0/management/api-keys`
129
-
130
- const currentPayload = await requestJson(endpoint, {
131
- method: 'GET',
132
- headers: managementHeaders(managementKey),
133
- })
134
- const currentKeys = toStringArray(currentPayload)
135
-
136
- if (currentKeys.includes(apiKeyToAdd)) {
137
- console.log('Key already present; no update required.')
138
- return
139
- }
140
-
141
- const nextKeys = [...currentKeys, apiKeyToAdd]
142
- await requestJson(endpoint, {
143
- method: 'PUT',
144
- headers: managementHeaders(managementKey),
145
- body: JSON.stringify(nextKeys),
146
- })
147
-
148
- console.log(`Added key "${apiKeyToAdd}". Current key count: ${nextKeys.length}.`)
149
- })
217
+ .action(cliproxyKeysAddAction)
150
218
 
151
219
  cli
152
220
  .command('cliproxy keys remove <key>', 'Remove an API key via management API endpoint query parameter.')
@@ -164,17 +232,5 @@ export function registerCliproxyKeys(cli: ReturnType<typeof goke>): void {
164
232
  )
165
233
  .example('# Remove an API key from CLIProxyAPI')
166
234
  .example('infra cliproxy keys remove sk-live-123')
167
- .action(async (apiKeyToRemove, options) => {
168
- const baseUrl = resolveBaseUrl(options.url)
169
- const managementKey = resolveManagementKey(options.key)
170
- const params = new URLSearchParams({value: apiKeyToRemove})
171
- const endpoint = `${baseUrl}/v0/management/api-keys?${params.toString()}`
172
-
173
- const payload = await requestJson(endpoint, {
174
- method: 'DELETE',
175
- headers: managementHeaders(managementKey),
176
- })
177
-
178
- console.log(JSON.stringify(payload, null, 2))
179
- })
235
+ .action(cliproxyKeysRemoveAction)
180
236
  }
@@ -1,13 +1,13 @@
1
- import {resolve} from 'node:path'
2
- import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
1
+ import type {SpawnFn} from './login'
3
2
 
4
- const cliDir = resolve(import.meta.dir, '../../..')
3
+ import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
5
4
 
6
5
  const envKeys = ['CLIPROXY_DOMAIN', 'HOME', 'PATH', 'SSH_AUTH_SOCK'] as const
7
6
 
8
7
  type ManagedEnvKey = (typeof envKeys)[number]
9
8
 
10
9
  let originalEnv: Partial<Record<ManagedEnvKey, string | undefined>>
10
+ let originalIsTTY: boolean | undefined
11
11
 
12
12
  function restoreManagedEnv(): void {
13
13
  for (const key of envKeys) {
@@ -22,80 +22,84 @@ function restoreManagedEnv(): void {
22
22
  }
23
23
  }
24
24
 
25
- async function runLoginCommand(
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
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)}
38
31
  }
39
32
 
40
- const proc = Bun.spawn(['bun', 'src/cli.ts', 'cliproxy', 'login', ...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
- SSH_AUTH_SOCK: env.SSH_AUTH_SOCK ?? '/tmp/test-sock',
48
- },
49
- stdout: 'pipe',
50
- stderr: 'pipe',
51
- })
33
+ return {spawnFn, calls}
34
+ }
52
35
 
53
- const [stdout, stderr, exitCode] = await Promise.all([
54
- new Response(proc.stdout).text(),
55
- new Response(proc.stderr).text(),
56
- proc.exited,
57
- ])
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
+ }
58
40
 
59
- return {stdout, stderr, exitCode}
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'
60
47
  }
61
48
 
62
49
  describe('cliproxy login', () => {
63
50
  beforeEach(() => {
64
51
  originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]))
52
+ originalIsTTY = process.stdin.isTTY
65
53
  })
66
54
 
67
55
  afterEach(() => {
68
56
  restoreManagedEnv()
57
+ Object.defineProperty(process.stdin, 'isTTY', {value: originalIsTTY, configurable: true})
69
58
  })
70
59
 
71
60
  describe('validation', () => {
72
61
  it('rejects unsupported providers', async () => {
73
- const {stderr, exitCode} = await runLoginCommand(['openai'])
74
- expect(exitCode).not.toBe(0)
75
- expect(stderr).toContain('Unsupported provider')
76
- expect(stderr).toContain('only "claude" is supported')
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.')
77
68
  })
78
69
 
79
70
  it('requires interactive terminal (checked before SSH_AUTH_SOCK)', async () => {
80
- const {stderr, exitCode} = await runLoginCommand(['claude'], {SSH_AUTH_SOCK: undefined})
81
- expect(exitCode).not.toBe(0)
82
- expect(stderr).toContain('interactive terminal')
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')
83
78
  })
84
79
  })
85
80
 
86
81
  describe('host resolution', () => {
87
82
  it('uses CLIPROXY_DOMAIN env var', async () => {
88
- const {stderr, exitCode} = await runLoginCommand(['claude'], {
89
- CLIPROXY_DOMAIN: 'custom.host.example',
90
- })
91
- expect(exitCode).not.toBe(0)
92
- expect(stderr).toContain('interactive terminal')
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')
93
91
  })
94
92
 
95
93
  it('uses --host flag', async () => {
96
- const {stderr, exitCode} = await runLoginCommand(['claude', '--host', 'custom.host.example'])
97
- expect(exitCode).not.toBe(0)
98
- expect(stderr).toContain('interactive terminal')
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
+ )
99
103
  })
100
104
  })
101
105
 
@@ -131,4 +135,154 @@ describe('cliproxy login', () => {
131
135
  expect(() => requireSshAuthSock()).toThrow('SSH_AUTH_SOCK is required')
132
136
  })
133
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('anti-phishing notice', () => {
243
+ let logLines: string[]
244
+ let originalLog: typeof console.log
245
+
246
+ beforeEach(() => {
247
+ logLines = []
248
+ originalLog = console.log
249
+ console.log = (...args: unknown[]) => {
250
+ logLines.push(args.map(String).join(' '))
251
+ }
252
+ })
253
+
254
+ afterEach(() => {
255
+ console.log = originalLog
256
+ })
257
+
258
+ it('codex: anti-phishing notice appears before spawn', async () => {
259
+ const {cliproxyLoginAction} = await import('./login')
260
+ const linesBeforeSpawn: string[] = []
261
+ let spawnCalled = false
262
+
263
+ const trackingSpawn: SpawnFn = (_cmd, _opts) => {
264
+ linesBeforeSpawn.push(...logLines)
265
+ spawnCalled = true
266
+ return {exited: Promise.resolve(0)}
267
+ }
268
+
269
+ setValidEnv()
270
+ await cliproxyLoginAction('codex', {}, trackingSpawn)
271
+
272
+ expect(spawnCalled).toBe(true)
273
+ const allOutput = linesBeforeSpawn.join('\n')
274
+ expect(allOutput).toMatch(/openai\.com/i)
275
+ })
276
+
277
+ it('claude: anti-phishing notice does NOT appear', async () => {
278
+ const {cliproxyLoginAction} = await import('./login')
279
+ const {spawnFn} = makeSpawnOk(0)
280
+ setValidEnv()
281
+
282
+ await cliproxyLoginAction('claude', {}, spawnFn)
283
+
284
+ const allOutput = logLines.join('\n')
285
+ expect(allOutput).not.toMatch(/openai\.com/)
286
+ })
287
+ })
134
288
  })
@@ -2,9 +2,27 @@ import type {goke} from 'goke'
2
2
 
3
3
  import {z} from 'zod'
4
4
 
5
+ import {validateCliproxyHost} from './host'
6
+
5
7
  const DEFAULT_HOST = 'cliproxy.fro.bot'
6
8
  const DEFAULT_REMOTE_USER = 'root'
7
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
+
8
26
  export function resolveHost(input?: string): string {
9
27
  const host = input ?? process.env.CLIPROXY_DOMAIN ?? DEFAULT_HOST
10
28
 
@@ -24,11 +42,72 @@ export function requireSshAuthSock(): string {
24
42
  return sshAuthSock
25
43
  }
26
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
+ throw new Error(`Remote login command failed with exit code ${exitCode}`)
101
+ }
102
+
103
+ console.log('If an OAuth URL was printed above, open it in your browser to complete login.')
104
+ }
105
+
27
106
  export function registerCliproxyLogin(cli: ReturnType<typeof goke>): void {
28
107
  cli
29
108
  .command(
30
109
  'cliproxy login <provider>',
31
- 'Run provider login on the remote CLIProxyAPI host and print OAuth URL output.',
110
+ `Run provider login on the remote CLIProxyAPI host. Supported providers: ${SUPPORTED_PROVIDERS_DISPLAY}.`,
32
111
  )
33
112
  .option(
34
113
  '--host [host]',
@@ -38,59 +117,7 @@ export function registerCliproxyLogin(cli: ReturnType<typeof goke>): void {
38
117
  )
39
118
  .example('# Start Claude login flow on remote CLIProxyAPI instance')
40
119
  .example('infra cliproxy login claude')
41
- .action(async (provider, options) => {
42
- if (provider !== 'claude') {
43
- throw new Error(`Unsupported provider "${provider}". Currently only "claude" is supported.`)
44
- }
45
-
46
- if (!process.stdin.isTTY) {
47
- throw new Error('cliproxy login requires an interactive terminal. Run from a shell with TTY attached.')
48
- }
49
-
50
- const host = resolveHost(options.host)
51
- const sshAuthSock = requireSshAuthSock()
52
- const path = process.env.PATH
53
- const home = process.env.HOME
54
-
55
- if (!path) {
56
- throw new Error('PATH is required to invoke ssh')
57
- }
58
-
59
- if (!home) {
60
- throw new Error('HOME is required to invoke ssh')
61
- }
62
-
63
- const remoteCommand =
64
- 'cd /opt/cliproxy && docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --no-browser --claude-login'
65
-
66
- const child = Bun.spawn(
67
- [
68
- 'ssh',
69
- '-tt',
70
- '-o',
71
- 'BatchMode=yes',
72
- '-o',
73
- 'ConnectTimeout=10',
74
- `${DEFAULT_REMOTE_USER}@${host}`,
75
- remoteCommand,
76
- ],
77
- {
78
- env: {
79
- PATH: path,
80
- HOME: home,
81
- SSH_AUTH_SOCK: sshAuthSock,
82
- },
83
- stdin: 'inherit',
84
- stdout: 'inherit',
85
- stderr: 'inherit',
86
- },
87
- )
88
-
89
- const exitCode = await child.exited
90
- if (exitCode !== 0) {
91
- throw new Error(`Remote login command failed with exit code ${exitCode}`)
92
- }
93
-
94
- console.log('If an OAuth URL was printed above, open it in your browser to complete login.')
95
- })
120
+ .example('# Start ChatGPT Pro login flow via device-code')
121
+ .example('infra cliproxy login codex')
122
+ .action((provider, options) => cliproxyLoginAction(provider, options))
96
123
  }