@exreve/exk 1.0.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.
package/index.ts ADDED
@@ -0,0 +1,2798 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander'
3
+ import { io, Socket } from 'socket.io-client'
4
+ import { v4 as uuidv4 } from 'uuid'
5
+ import fs from 'fs/promises'
6
+ import fsSync from 'fs'
7
+ import path from 'path'
8
+ import os, { networkInterfaces } from 'os'
9
+ import { Buffer } from 'buffer'
10
+ import crypto from 'crypto'
11
+ import type {
12
+ Session,
13
+ Device,
14
+ SessionOutput,
15
+ Project,
16
+ ProjectCreateData,
17
+ ProjectDeleteData,
18
+ SessionCreateData,
19
+ SessionPromptData,
20
+ SessionSubscribeData,
21
+ SuccessResponse,
22
+ SessionResponse,
23
+ SessionsListResponse,
24
+ DevicesListResponse,
25
+ } from './shared/types'
26
+ import { createProject, deleteProject } from './projectManager'
27
+ import { agentSessionManager } from './agentSession'
28
+ import { getProjectConfig, analyzeProjectWithClaude, saveProjectConfig } from './projectAnalyzer'
29
+ import { generateRunnerCode } from './runnerGenerator'
30
+ import { startApp, stopApp, restartApp, getAppStatuses, getAppLogs } from './appManager'
31
+ import { spawn, execSync } from 'child_process'
32
+ import readline from 'readline'
33
+ import { fileURLToPath } from 'url'
34
+ import { createHash } from 'crypto'
35
+
36
+ // ============ Types ============
37
+
38
+ interface Config {
39
+ apiUrl: string
40
+ }
41
+
42
+ interface DeviceData {
43
+ deviceId: string
44
+ }
45
+
46
+ // ============ Constants ============
47
+
48
+ const CONFIG_DIR = path.join(os.homedir(), '.talk-to-code')
49
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
50
+ const DEVICE_ID_FILE = path.join(CONFIG_DIR, 'device-id.json')
51
+ const DEFAULT_API_URL = 'https://api.talk-to-code.com'
52
+
53
+ // ============ Helpers ============
54
+
55
+ async function readConfig(): Promise<Config> {
56
+ try {
57
+ const data = await fs.readFile(CONFIG_FILE, 'utf-8')
58
+ return JSON.parse(data)
59
+ } catch {
60
+ return { apiUrl: DEFAULT_API_URL }
61
+ }
62
+ }
63
+
64
+ async function writeConfig(config: Config): Promise<void> {
65
+ await fs.mkdir(CONFIG_DIR, { recursive: true })
66
+ await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2))
67
+ }
68
+
69
+ /**
70
+ * Fetch AI config from server and save to ~/.talk-to-code/ai-config.json
71
+ * Called after device registration and on daemon start
72
+ */
73
+ async function fetchAiConfig(authToken: string): Promise<boolean> {
74
+ const AI_CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json')
75
+ try {
76
+ const config = await readConfig()
77
+ const res = await fetch(`${config.apiUrl}/config/ai`, {
78
+ headers: { Authorization: `Bearer ${authToken}` }
79
+ })
80
+ if (!res.ok) {
81
+ console.log(`[fetchAiConfig] Server returned ${res.status}`)
82
+ return false
83
+ }
84
+ const data = await res.json() as { success: boolean; aiConfig?: { authToken: string; baseUrl: string; model: string } }
85
+ if (!data.success || !data.aiConfig) {
86
+ console.log('[fetchAiConfig] No AI config available for this user')
87
+ return false
88
+ }
89
+ await fs.writeFile(AI_CONFIG_FILE, JSON.stringify(data.aiConfig, null, 2))
90
+ console.log('[fetchAiConfig] AI config saved successfully')
91
+ return true
92
+ } catch (error: any) {
93
+ console.log(`[fetchAiConfig] Failed: ${error.message}`)
94
+ return false
95
+ }
96
+ }
97
+
98
+ async function getDeviceId(): Promise<string> {
99
+ try {
100
+ const data = await fs.readFile(DEVICE_ID_FILE, 'utf-8')
101
+ return JSON.parse(data).deviceId
102
+ } catch {
103
+ const deviceId = uuidv4()
104
+ await fs.mkdir(CONFIG_DIR, { recursive: true })
105
+ await fs.writeFile(DEVICE_ID_FILE, JSON.stringify({ deviceId }, null, 2))
106
+ return deviceId
107
+ }
108
+ }
109
+
110
+ function getLocalIpAddress(): string {
111
+ const nets = networkInterfaces()
112
+ for (const name of Object.keys(nets)) {
113
+ for (const net of nets[name]!) {
114
+ if (net.family === 'IPv4' && !net.internal) {
115
+ return net.address
116
+ }
117
+ }
118
+ }
119
+ return 'Unknown'
120
+ }
121
+
122
+ function getHostname(): string {
123
+ return os.hostname() || 'Unknown'
124
+ }
125
+
126
+ async function connect(): Promise<Socket> {
127
+ const config = await readConfig()
128
+ const deviceId = await getDeviceId()
129
+
130
+ return new Promise((resolve, reject) => {
131
+ const socket: Socket = io(config.apiUrl, {
132
+ transports: ['websocket', 'polling'],
133
+ })
134
+
135
+ socket.on('connect', () => {
136
+ socket.emit('register', { type: 'cli', deviceId })
137
+ resolve(socket)
138
+ })
139
+
140
+ socket.on('registered', ({ type }: { type: string }) => {
141
+ console.log(`Connected as ${type}`)
142
+ })
143
+
144
+ socket.on('connect_error', (err) => {
145
+ reject(err)
146
+ })
147
+ })
148
+ }
149
+
150
+ // ============ Update Helpers ============
151
+
152
+ type UpdateCheckResponse = {
153
+ updateAvailable: boolean
154
+ downloadUrl?: string
155
+ hash?: string
156
+ }
157
+
158
+ const CURRENT_FILE = fileURLToPath(import.meta.url)
159
+ const __dirname = path.dirname(CURRENT_FILE)
160
+
161
+ function getCliHash(): string {
162
+ return createHash('sha256').update(fsSync.readFileSync(CURRENT_FILE)).digest('hex')
163
+ }
164
+
165
+ async function checkForUpdate(): Promise<UpdateCheckResponse | null> {
166
+ try {
167
+ const config = await readConfig()
168
+ const res = await fetch(`${config.apiUrl}/update/check`, {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/json' },
171
+ body: JSON.stringify({ hash: getCliHash(), platform: os.platform() })
172
+ })
173
+ if (!res.ok) return null
174
+ return await res.json() as UpdateCheckResponse
175
+ } catch {
176
+ return null
177
+ }
178
+ }
179
+
180
+ async function replaceSelf(tarballBuffer: Buffer): Promise<void> {
181
+ const extractDir = path.join(os.tmpdir(), `ttc-update-${Date.now()}`)
182
+ await fs.mkdir(extractDir, { recursive: true })
183
+ const tarPath = path.join(extractDir, 'ttc-cli.tar.gz')
184
+ await fs.writeFile(tarPath, tarballBuffer)
185
+ execSync(`tar -xzf "${tarPath}" -C "${extractDir}"`)
186
+ await fs.unlink(tarPath)
187
+
188
+ // Preserve user config/token files (never overwrite on update)
189
+ const preserveFiles = ['device-config.json', 'device-id.json', 'config.json']
190
+ const preserved: Record<string, Buffer> = {}
191
+ for (const f of preserveFiles) {
192
+ try {
193
+ preserved[f] = await fs.readFile(path.join(CONFIG_DIR, f))
194
+ } catch {
195
+ /* file may not exist */
196
+ }
197
+ }
198
+
199
+ // Replace cli/ and shared/ in CONFIG_DIR
200
+ const cliDest = path.join(CONFIG_DIR, 'cli')
201
+ const sharedDest = path.join(CONFIG_DIR, 'shared')
202
+ await fs.rm(cliDest, { recursive: true, force: true })
203
+ await fs.rm(sharedDest, { recursive: true, force: true })
204
+ await fs.cp(path.join(extractDir, 'cli'), cliDest, { recursive: true })
205
+ await fs.cp(path.join(extractDir, 'shared'), sharedDest, { recursive: true })
206
+
207
+ // Update package.json and npm install
208
+ await fs.copyFile(path.join(extractDir, 'package.json'), path.join(CONFIG_DIR, 'package.json'))
209
+ await fs.rm(extractDir, { recursive: true, force: true })
210
+
211
+ // Restore preserved config
212
+ for (const f of preserveFiles) {
213
+ if (preserved[f]) await fs.writeFile(path.join(CONFIG_DIR, f), preserved[f])
214
+ }
215
+
216
+ console.log('✓ CLI updated')
217
+
218
+ const npmPaths = [path.join(os.homedir(), '.nvm/versions/node/v22/bin/npm'), path.join(os.homedir(), '.nvm/versions/node/v20/bin/npm')]
219
+ const npmBin = npmPaths.find(p => fsSync.existsSync(p)) || 'npm'
220
+ try {
221
+ execSync(`"${npmBin}" install --omit=dev`, { cwd: CONFIG_DIR, stdio: 'inherit' })
222
+ console.log('✓ Dependencies updated')
223
+ } catch {
224
+ console.warn('⚠ npm install failed')
225
+ }
226
+ }
227
+
228
+ async function selfUpdate(force = false): Promise<void> {
229
+ const info = await checkForUpdate()
230
+ if (!info || !info.updateAvailable) {
231
+ console.log('✓ Already up to date')
232
+ return
233
+ }
234
+
235
+ console.log('📦 Update available!')
236
+ if (!force && process.stdin.isTTY) {
237
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
238
+ const answer = await new Promise<string>(r => rl.question('Update now? [Y/n] ', a => { rl.close(); r(a.trim()) }))
239
+ if (answer.toLowerCase().startsWith('n')) return
240
+ }
241
+
242
+ if (!info.downloadUrl || !info.hash) return
243
+
244
+ console.log('Downloading...')
245
+ const res = await fetch(info.downloadUrl)
246
+ if (!res.ok) throw new Error('Download failed')
247
+
248
+ const bundle = Buffer.from(await res.arrayBuffer())
249
+ if (createHash('sha256').update(bundle).digest('hex') !== info.hash) {
250
+ throw new Error('Hash mismatch')
251
+ }
252
+
253
+ await replaceSelf(bundle)
254
+ process.exit(0)
255
+ }
256
+
257
+ // ============ Commands ============
258
+
259
+ async function registerDevice(name: string | undefined, email: string | undefined): Promise<void> {
260
+ try {
261
+ const deviceId = await getDeviceId()
262
+ let deviceEmail = email
263
+
264
+ // If we already have a valid token, skip approval email and just register
265
+ const deviceConfigFile = path.join(CONFIG_DIR, 'device-config.json')
266
+ try {
267
+ const deviceConfig = JSON.parse(await fs.readFile(deviceConfigFile, 'utf-8'))
268
+ const existingToken = deviceConfig.authToken
269
+ if (existingToken && typeof existingToken === 'string') {
270
+ const config = await readConfig()
271
+ const meRes = await fetch(`${config.apiUrl}/auth/me`, {
272
+ headers: { Authorization: `Bearer ${existingToken}` }
273
+ })
274
+ const meData = await meRes.json() as { success?: boolean; user?: { email?: string }; error?: string }
275
+ if (meRes.ok && meData.success && meData.user?.email) {
276
+ console.log(`✓ Using existing auth token for ${meData.user.email}`)
277
+ deviceEmail = deviceEmail || meData.user.email
278
+ const socket = await connect()
279
+ await new Promise<void>((resolve, reject) => {
280
+ socket.emit('device:register', {
281
+ deviceId,
282
+ name: name || `CLI Device ${deviceId.slice(0, 8)}`,
283
+ ipAddress: getLocalIpAddress(),
284
+ hostname: getHostname(),
285
+ email: deviceEmail
286
+ }, ({ success, device, error }: {
287
+ success: boolean
288
+ device?: Device
289
+ error?: string
290
+ }) => {
291
+ socket.disconnect()
292
+ if (success && device) {
293
+ console.log(`✓ Device registered!`)
294
+ console.log(`Device ID: ${device.deviceId}`)
295
+ console.log(`Name: ${device.name}`)
296
+ console.log(`Email: ${device.email}`)
297
+ resolve()
298
+ } else {
299
+ reject(new Error(error || 'Registration failed'))
300
+ }
301
+ })
302
+ })
303
+
304
+ // Fetch AI config from server after registration
305
+ await fetchAiConfig(existingToken)
306
+
307
+ return
308
+ }
309
+ }
310
+ } catch {
311
+ // No config or invalid token - continue with approval flow
312
+ }
313
+
314
+ // Prompt for email if not provided
315
+ if (!deviceEmail) {
316
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
317
+ deviceEmail = await new Promise<string>((resolve) => {
318
+ rl.question('Enter your email address: ', (answer) => {
319
+ rl.close()
320
+ resolve(answer.trim())
321
+ })
322
+ })
323
+ }
324
+
325
+ if (!deviceEmail?.includes('@')) {
326
+ console.error('✗ Valid email address is required')
327
+ throw new Error('Valid email address is required')
328
+ }
329
+
330
+ // Generate 32-byte random token (base64url encoded)
331
+ const randomBytes = crypto.randomBytes(32)
332
+ const approvalToken = randomBytes.toString('base64url') // base64url encoding (no padding, URL-safe)
333
+
334
+ console.log(`\n📧 Sending approval request to ${deviceEmail}...`)
335
+
336
+ // Send approval request
337
+ const config = await readConfig()
338
+ const response = await fetch(`${config.apiUrl}/auth/approval-request`, {
339
+ method: 'POST',
340
+ headers: {
341
+ 'Content-Type': 'application/json'
342
+ },
343
+ body: JSON.stringify({
344
+ email: deviceEmail,
345
+ deviceId,
346
+ approvalToken
347
+ })
348
+ })
349
+
350
+ const result = await response.json() as { success: boolean; error?: string }
351
+
352
+ if (!result.success) {
353
+ console.error(`✗ Failed to send approval request: ${result.error || 'Unknown error'}`)
354
+ throw new Error(result.error || 'Failed to send approval request')
355
+ }
356
+
357
+ console.log(`✓ Approval email sent! Check your inbox (${deviceEmail})`)
358
+ console.log(`⏳ Waiting for approval...`)
359
+
360
+ // Poll every 5 seconds for approval
361
+ const maxAttempts = 120 // 10 minutes max (120 * 5s)
362
+ let attempts = 0
363
+
364
+ const checkApproval = async (): Promise<string | null> => {
365
+ attempts++
366
+
367
+ try {
368
+ const statusResponse = await fetch(`${config.apiUrl}/auth/approval-status?token=${approvalToken}`)
369
+ const statusResult = await statusResponse.json() as { success?: boolean; approved?: boolean; token?: string; error?: string; pending?: boolean }
370
+
371
+ if (statusResult.success && statusResult.approved) {
372
+ return statusResult.token || null // Return permanent auth token
373
+ }
374
+
375
+ if (statusResult.error && !statusResult.pending) {
376
+ console.error(`\n✗ ${statusResult.error}`)
377
+ return null
378
+ }
379
+
380
+ return null // Still pending
381
+ } catch (error: any) {
382
+ console.error(`\n✗ Error checking approval status: ${error.message}`)
383
+ return null
384
+ }
385
+ }
386
+
387
+ // Poll until approved or timeout
388
+ while (attempts < maxAttempts) {
389
+ await new Promise(resolve => setTimeout(resolve, 5000)) // Wait 5 seconds
390
+
391
+ const token = await checkApproval()
392
+ if (token) {
393
+ // Device approved! Store permanent token
394
+ const deviceConfigFile = path.join(CONFIG_DIR, 'device-config.json')
395
+ await fs.writeFile(deviceConfigFile, JSON.stringify({
396
+ email: deviceEmail,
397
+ authToken: token
398
+ }, null, 2))
399
+
400
+ console.log(`\n✓ Device approved!`)
401
+
402
+ // Register device with backend and wait for completion
403
+ const socket = await connect()
404
+
405
+ await new Promise<void>((resolve, reject) => {
406
+ socket.emit('device:register', {
407
+ deviceId,
408
+ name: name || `CLI Device ${deviceId.slice(0, 8)}`,
409
+ ipAddress: getLocalIpAddress(),
410
+ hostname: getHostname(),
411
+ email: deviceEmail
412
+ }, ({ success, device, error }: {
413
+ success: boolean
414
+ device?: Device
415
+ error?: string
416
+ }) => {
417
+ socket.disconnect()
418
+ if (success && device) {
419
+ console.log(`✓ Device registered!`)
420
+ console.log(`Device ID: ${device.deviceId}`)
421
+ console.log(`Name: ${device.name}`)
422
+ console.log(`Email: ${device.email}`)
423
+ console.log(`IP Address: ${device.ipAddress || 'Unknown'}`)
424
+ console.log(`Hostname: ${device.hostname || 'Unknown'}`)
425
+ resolve()
426
+ } else {
427
+ reject(new Error(error || 'Registration failed'))
428
+ }
429
+ })
430
+ })
431
+
432
+ // Fetch AI config from server after registration
433
+ await fetchAiConfig(token)
434
+
435
+ return // Successfully registered
436
+ }
437
+
438
+ // Show progress every 30 seconds
439
+ if (attempts % 6 === 0) {
440
+ process.stdout.write(`\r⏳ Still waiting... (${attempts * 5}s)`)
441
+ }
442
+ }
443
+
444
+ console.error(`\n✗ Approval timeout. Please check your email and try again.`)
445
+ throw new Error('Approval timeout')
446
+ } catch (error) {
447
+ console.error('Failed to register device:', (error as Error).message)
448
+ throw error
449
+ }
450
+ }
451
+
452
+ async function listSessions(): Promise<void> {
453
+ try {
454
+ const socket = await connect()
455
+
456
+ socket.emit('sessions:list', (response: SessionsListResponse) => {
457
+ const { sessions } = response
458
+ console.log('\nSessions:')
459
+ console.log('-'.repeat(60))
460
+ sessions.forEach((session, idx) => {
461
+ console.log(`${idx + 1}. ${session.id}`)
462
+ console.log(` Created: ${new Date(session.createdAt).toLocaleString()}`)
463
+ console.log()
464
+ })
465
+ socket.disconnect()
466
+ })
467
+ } catch (error) {
468
+ console.error('Failed to list sessions:', (error as Error).message)
469
+ process.exit(1)
470
+ }
471
+ }
472
+
473
+ async function createSession(): Promise<void> {
474
+ try {
475
+ const socket = await connect()
476
+
477
+ socket.emit('session:create', (response: SessionResponse) => {
478
+ const { success, session } = response
479
+ if (success && session) {
480
+ console.log('Session created!')
481
+ console.log('Session ID:', session.id)
482
+ socket.disconnect()
483
+ }
484
+ })
485
+ } catch (error) {
486
+ console.error('Failed to create session:', (error as Error).message)
487
+ process.exit(1)
488
+ }
489
+ }
490
+
491
+ async function sendPrompt(sessionId: string, prompt: string): Promise<void> {
492
+ try {
493
+ const socket = await connect()
494
+
495
+ socket.emit('session:subscribe', { sessionId })
496
+
497
+ socket.on('prompt:output', ({ data, type }: { data: string; type: string }) => {
498
+ if (type === 'stdout') {
499
+ process.stdout.write(data)
500
+ } else {
501
+ process.stderr.write(data)
502
+ }
503
+ })
504
+
505
+ socket.on('session:exited', ({ exitCode }: { exitCode: number | null }) => {
506
+ console.log(`\nSession exited with code ${exitCode}`)
507
+ socket.disconnect()
508
+ })
509
+
510
+ socket.emit('session:prompt', { sessionId, prompt }, ({ success }: { success: boolean }) => {
511
+ if (!success) {
512
+ console.error('Failed to send prompt')
513
+ socket.disconnect()
514
+ }
515
+ })
516
+
517
+ process.on('SIGINT', () => {
518
+ socket.emit('session:unsubscribe', { sessionId } as SessionSubscribeData)
519
+ socket.disconnect()
520
+ process.exit(0)
521
+ })
522
+ } catch (error) {
523
+ console.error('Failed to send prompt:', (error as Error).message)
524
+ process.exit(1)
525
+ }
526
+ }
527
+
528
+ async function listDevices(): Promise<void> {
529
+ try {
530
+ const socket = await connect()
531
+
532
+ socket.emit('devices:list', (response: DevicesListResponse) => {
533
+ const { devices } = response
534
+ console.log('\nRegistered Devices:')
535
+ console.log('-'.repeat(60))
536
+ devices.forEach((device, idx) => {
537
+ console.log(`${idx + 1}. ${device.name}`)
538
+ console.log(` ID: ${device.deviceId}`)
539
+ console.log(` IP Address: ${device.ipAddress || 'Unknown'}`)
540
+ console.log(` Hostname: ${device.hostname || 'Unknown'}`)
541
+ console.log(` Registered: ${new Date(device.registeredAt).toLocaleString()}`)
542
+ console.log(` Last Seen: ${new Date(device.lastSeen).toLocaleString()}`)
543
+ console.log()
544
+ })
545
+ socket.disconnect()
546
+ })
547
+ } catch (error) {
548
+ console.error('Failed to list devices:', (error as Error).message)
549
+ process.exit(1)
550
+ }
551
+ }
552
+
553
+ async function monitorSession(sessionId: string): Promise<void> {
554
+ try {
555
+ const socket = await connect()
556
+
557
+ console.log(`Monitoring session ${sessionId}...`)
558
+ console.log('Press Ctrl+C to stop\n')
559
+
560
+ socket.emit('session:subscribe', { sessionId })
561
+
562
+ socket.on('session:state', ({ outputs, isActive }: { outputs: SessionOutput[]; isActive: boolean }) => {
563
+ console.log(`Session active: ${isActive}`)
564
+ console.log('Recent output:')
565
+ outputs.slice(-10).forEach(({ type, data }) => {
566
+ if (type === 'stdout') {
567
+ const dataString = typeof data === 'string' ? data : JSON.stringify(data)
568
+ process.stdout.write(dataString)
569
+ }
570
+ })
571
+ })
572
+
573
+ socket.on('prompt:output', ({ data, type }: { data: string; type: string }) => {
574
+ if (type === 'stdout') {
575
+ process.stdout.write(data)
576
+ }
577
+ })
578
+
579
+ socket.on('session:exited', ({ exitCode }: { exitCode: number | null }) => {
580
+ console.log(`\nSession exited with code ${exitCode}`)
581
+ socket.disconnect()
582
+ })
583
+
584
+ process.on('SIGINT', () => {
585
+ socket.emit('session:unsubscribe', { sessionId } as SessionSubscribeData)
586
+ socket.disconnect()
587
+ process.exit(0)
588
+ })
589
+ } catch (error) {
590
+ console.error('Failed to monitor session:', (error as Error).message)
591
+ process.exit(1)
592
+ }
593
+ }
594
+
595
+ // Active sessions map - persists across reconnections
596
+ const activeSessions = new Map<string, { projectPath: string; currentPromptId?: string; model?: string }>()
597
+
598
+ async function runDaemon(foreground = false, email?: string): Promise<void> {
599
+ const config = await readConfig()
600
+ const deviceId = await getDeviceId()
601
+ const ipAddress = getLocalIpAddress()
602
+ const hostname = getHostname()
603
+
604
+ // Silent update check on startup
605
+ checkForUpdate().then(info => {
606
+ if (info?.updateAvailable) console.log('📦 Update available: run "ttc update"')
607
+ }).catch(() => {})
608
+
609
+ if (foreground) console.log('=== TalkToCode CLI (Foreground) ===')
610
+ else console.log('Starting daemon...')
611
+ console.log(`API: ${config.apiUrl}`)
612
+ console.log(`Device: ${deviceId}`)
613
+ console.log(`Host: ${hostname}`)
614
+
615
+ // Test if server is reachable
616
+ if (foreground) {
617
+ try {
618
+ const url = new URL(config.apiUrl)
619
+ const testUrl = `${url.protocol}//${url.host}`
620
+ console.log(`Testing connection to ${testUrl}...`)
621
+ const response = await fetch(testUrl, {
622
+ method: 'GET',
623
+ signal: AbortSignal.timeout(5000)
624
+ })
625
+ console.log(`✓ Server is reachable (HTTP ${response.status})`)
626
+ } catch (error: any) {
627
+ console.error(`✗ Cannot reach server: ${error.message}`)
628
+ console.error(` Make sure the backend server is running at ${config.apiUrl}`)
629
+ }
630
+ }
631
+ console.log('')
632
+
633
+ let socket: Socket | null = null
634
+ let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
635
+ let reconnectAttempts = 0
636
+ const MAX_RECONNECT_ATTEMPTS = Infinity // Keep trying forever
637
+
638
+ const connectAndRegister = async () => {
639
+ try {
640
+ socket = io(config.apiUrl, {
641
+ transports: ['websocket', 'polling'],
642
+ reconnection: true,
643
+ reconnectionDelay: 1000,
644
+ reconnectionDelayMax: 10000,
645
+ reconnectionAttempts: MAX_RECONNECT_ATTEMPTS,
646
+ timeout: 20000,
647
+ forceNew: true,
648
+ upgrade: true,
649
+ })
650
+
651
+ if (foreground) {
652
+ console.log(`Attempting to connect to: ${config.apiUrl}`)
653
+ }
654
+
655
+ // Register event handlers once (outside registered callback to prevent duplicates)
656
+ // Project handlers
657
+ socket.on('project:create', async (data: ProjectCreateData, callback: (response: SuccessResponse & { actualPath?: string }) => void) => {
658
+ try {
659
+ if (foreground) {
660
+ console.log(`📁 Creating project: ${data.name} at ${data.path}`)
661
+ }
662
+ const result = await createProject({
663
+ projectId: data.projectId || uuidv4(),
664
+ name: data.name,
665
+ path: data.path,
666
+ sourcePath: data.sourcePath
667
+ })
668
+
669
+ if (result.success) {
670
+ if (foreground) {
671
+ console.log(`✓ Project created: ${data.name} at ${result.actualPath || data.path}`)
672
+ } else {
673
+ console.log(`Project created: ${data.name} at ${result.actualPath || data.path}`)
674
+ }
675
+ } else {
676
+ if (foreground) {
677
+ console.error(`✗ Failed to create project: ${result.error || 'Unknown error'}`)
678
+ }
679
+ }
680
+
681
+ callback?.({
682
+ success: result.success,
683
+ error: result.error,
684
+ actualPath: result.actualPath
685
+ })
686
+ } catch (error: any) {
687
+ if (foreground) {
688
+ console.error(`✗ Error creating project: ${error.message}`)
689
+ }
690
+ callback?.({ success: false, error: error.message })
691
+ }
692
+ })
693
+
694
+ socket.on('project:delete', async (data: ProjectDeleteData & { path?: string }, callback: (response: SuccessResponse) => void) => {
695
+ try {
696
+ const { projectId, path } = data
697
+ if (!path) {
698
+ callback?.({ success: false, error: 'Project path required for deletion' })
699
+ return
700
+ }
701
+
702
+ if (foreground) {
703
+ console.log(`🗑️ Deleting project: ${projectId} at ${path}`)
704
+ }
705
+
706
+ const result = await deleteProject(path)
707
+
708
+ if (result.success) {
709
+ if (foreground) {
710
+ console.log(`✓ Project deleted: ${projectId} at ${path}`)
711
+ } else {
712
+ console.log(`Project deleted: ${projectId} at ${path}`)
713
+ }
714
+ } else {
715
+ if (foreground) {
716
+ console.error(`✗ Failed to delete project: ${result.error || 'Unknown error'}`)
717
+ }
718
+ }
719
+
720
+ callback?.(result)
721
+ } catch (error: any) {
722
+ if (foreground) {
723
+ console.error(`✗ Error deleting project: ${error.message}`)
724
+ }
725
+ callback?.({ success: false, error: error.message })
726
+ }
727
+ })
728
+
729
+ // GitHub handlers
730
+ socket.on('github:status', async (data: { projectId: string; projectPath: string }, callback?: (response: { success: boolean; isRepo?: boolean; hasChanges?: boolean; branch?: string; remote?: string; changedFiles?: Array<{ path: string; status: string }>; changesCount?: number; error?: string }) => void) => {
731
+ try {
732
+ const { projectPath } = data
733
+
734
+ // Check if directory is a git repository
735
+ const gitDir = path.join(projectPath, '.git')
736
+ let isRepo = false
737
+ try {
738
+ const stats = await fs.stat(gitDir)
739
+ isRepo = stats.isDirectory()
740
+ } catch {
741
+ isRepo = false
742
+ }
743
+
744
+ if (!isRepo) {
745
+ callback?.({ success: true, isRepo: false, hasChanges: false })
746
+ return
747
+ }
748
+
749
+ // Check git status
750
+ const { execSync } = await import('child_process')
751
+ try {
752
+ // Get current branch
753
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim()
754
+
755
+ // Get remote URL
756
+ let remote: string | undefined
757
+ try {
758
+ remote = execSync('git config --get remote.origin.url', { cwd: projectPath, encoding: 'utf-8' }).trim()
759
+ } catch {
760
+ // No remote configured
761
+ }
762
+
763
+ // Check for actual changes (modified, staged, deleted files - ignore untracked)
764
+ // Use --short to get concise output, and check for actual changes (not just untracked files)
765
+ let hasChanges = false
766
+ const changedFiles: Array<{ path: string; status: string }> = []
767
+ try {
768
+ const statusOutput = execSync('git status --porcelain', { cwd: projectPath, encoding: 'utf-8' })
769
+ const lines = statusOutput.trim().split('\n').filter(line => line.trim().length > 0)
770
+
771
+ for (const line of lines) {
772
+ const status = line.substring(0, 2).trim()
773
+ const filePath = line.substring(2).trim()
774
+
775
+ // Skip untracked files (??)
776
+ if (line.startsWith('??')) continue
777
+
778
+ // Include modified (M), added (A), deleted (D), renamed (R), copied (C)
779
+ if (status.includes('M') || status.includes('A') || status.includes('D') || status.includes('R') || status.includes('C')) {
780
+ hasChanges = true
781
+ changedFiles.push({ path: filePath, status })
782
+ }
783
+ }
784
+ } catch {
785
+ hasChanges = false
786
+ }
787
+
788
+ callback?.({ success: true, isRepo: true, hasChanges, branch, remote, changedFiles, changesCount: changedFiles.length })
789
+ } catch (error: any) {
790
+ callback?.({ success: false, error: error.message })
791
+ }
792
+ } catch (error: any) {
793
+ callback?.({ success: false, error: error.message })
794
+ }
795
+ })
796
+
797
+ socket.on('github:commit', async (data: { projectId: string; projectPath: string; message?: string }, callback?: (response: { success: boolean; error?: string }) => void) => {
798
+ try {
799
+ const { projectPath, message = 'changes' } = data
800
+
801
+ // Check if directory is a git repository
802
+ const gitDir = path.join(projectPath, '.git')
803
+ let isRepo = false
804
+ try {
805
+ const stats = await fs.stat(gitDir)
806
+ isRepo = stats.isDirectory()
807
+ } catch {
808
+ callback?.({ success: false, error: 'Not a git repository' })
809
+ return
810
+ }
811
+
812
+ const { execSync } = await import('child_process')
813
+ try {
814
+ // Stage all changes
815
+ execSync('git add -A', { cwd: projectPath })
816
+
817
+ // Commit with message
818
+ execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath })
819
+
820
+ // Determine which branch to push to (master or main)
821
+ let targetBranch: string
822
+ try {
823
+ // Check if master branch exists
824
+ execSync('git rev-parse --verify master', { cwd: projectPath, stdio: 'ignore' })
825
+ targetBranch = 'master'
826
+ } catch {
827
+ try {
828
+ // Check if main branch exists
829
+ execSync('git rev-parse --verify main', { cwd: projectPath, stdio: 'ignore' })
830
+ targetBranch = 'main'
831
+ } catch {
832
+ // Fallback to current branch if neither master nor main exists
833
+ targetBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim()
834
+ }
835
+ }
836
+
837
+ // Push to origin after commit
838
+ execSync(`git push origin ${targetBranch}`, { cwd: projectPath, stdio: 'pipe' })
839
+
840
+ callback?.({ success: true })
841
+ } catch (error: any) {
842
+ callback?.({ success: false, error: error.message })
843
+ }
844
+ } catch (error: any) {
845
+ callback?.({ success: false, error: error.message })
846
+ }
847
+ })
848
+
849
+ socket.on('github:push', async (data: { projectId: string; projectPath: string }, callback?: (response: { success: boolean; error?: string }) => void) => {
850
+ try {
851
+ const { projectPath } = data
852
+
853
+ // Check if directory is a git repository
854
+ const gitDir = path.join(projectPath, '.git')
855
+ let isRepo = false
856
+ try {
857
+ const stats = await fs.stat(gitDir)
858
+ isRepo = stats.isDirectory()
859
+ } catch {
860
+ callback?.({ success: false, error: 'Not a git repository' })
861
+ return
862
+ }
863
+
864
+ const { execSync } = await import('child_process')
865
+ try {
866
+ // Determine which branch to push to (master or main)
867
+ let targetBranch: string
868
+ try {
869
+ // Check if master branch exists
870
+ execSync('git rev-parse --verify master', { cwd: projectPath, stdio: 'ignore' })
871
+ targetBranch = 'master'
872
+ } catch {
873
+ try {
874
+ // Check if main branch exists
875
+ execSync('git rev-parse --verify main', { cwd: projectPath, stdio: 'ignore' })
876
+ targetBranch = 'main'
877
+ } catch {
878
+ // Fallback to current branch if neither master nor main exists
879
+ targetBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim()
880
+ }
881
+ }
882
+
883
+ // Push to origin
884
+ execSync(`git push origin ${targetBranch}`, { cwd: projectPath })
885
+
886
+ callback?.({ success: true })
887
+ } catch (error: any) {
888
+ callback?.({ success: false, error: error.message })
889
+ }
890
+ } catch (error: any) {
891
+ callback?.({ success: false, error: error.message })
892
+ }
893
+ })
894
+
895
+ socket.on('github:discard', async (data: { projectId: string; projectPath: string }, callback?: (response: { success: boolean; error?: string }) => void) => {
896
+ try {
897
+ const { projectPath } = data
898
+
899
+ // Check if directory is a git repository
900
+ const gitDir = path.join(projectPath, '.git')
901
+ let isRepo = false
902
+ try {
903
+ const stats = await fs.stat(gitDir)
904
+ isRepo = stats.isDirectory()
905
+ } catch {
906
+ callback?.({ success: false, error: 'Not a git repository' })
907
+ return
908
+ }
909
+
910
+ const { execSync } = await import('child_process')
911
+ try {
912
+ // Discard all changes and revert to latest commit
913
+ // Reset all changes (both staged and unstaged)
914
+ execSync('git reset --hard HEAD', { cwd: projectPath })
915
+ // Clean untracked files and directories
916
+ execSync('git clean -fd', { cwd: projectPath })
917
+
918
+ callback?.({ success: true })
919
+ } catch (error: any) {
920
+ callback?.({ success: false, error: error.message })
921
+ }
922
+ } catch (error: any) {
923
+ callback?.({ success: false, error: error.message })
924
+ }
925
+ })
926
+
927
+ // Directory listing handler
928
+ socket.on('fs:list', async (data: { dirPath: string }) => {
929
+ try {
930
+ const { dirPath } = data
931
+ if (!dirPath) {
932
+ socket?.emit('fs:list:response', { success: false, error: 'dirPath is required' })
933
+ return
934
+ }
935
+
936
+ const entries: Array<{ name: string; path: string; isDir: boolean }> = []
937
+ try {
938
+ const dirents = await fs.readdir(dirPath, { withFileTypes: true })
939
+ for (const dirent of dirents) {
940
+ if (dirent.name.startsWith('.')) continue // Skip hidden files
941
+ const fullPath = path.join(dirPath, dirent.name)
942
+ entries.push({
943
+ name: dirent.name,
944
+ path: fullPath,
945
+ isDir: dirent.isDirectory()
946
+ })
947
+ }
948
+ // Sort: directories first, then alphabetically
949
+ entries.sort((a, b) => {
950
+ if (a.isDir && !b.isDir) return -1
951
+ if (!a.isDir && b.isDir) return 1
952
+ return a.name.localeCompare(b.name)
953
+ })
954
+ socket?.emit('fs:list:response', { success: true, entries })
955
+ } catch (err: any) {
956
+ socket?.emit('fs:list:response', { success: false, error: err.message })
957
+ }
958
+ } catch (error: any) {
959
+ socket?.emit('fs:list:response', { success: false, error: error.message })
960
+ }
961
+ })
962
+
963
+ // File write handler
964
+ socket.on('fs:write', async (data: { filePath: string; content: string; encoding?: string }) => {
965
+ try {
966
+ const { filePath, content, encoding = 'utf-8' } = data
967
+ if (!filePath) {
968
+ socket?.emit('fs:write:response', { success: false, error: 'filePath is required' })
969
+ return
970
+ }
971
+
972
+ try {
973
+ // Ensure directory exists
974
+ const dir = path.dirname(filePath)
975
+ await fs.mkdir(dir, { recursive: true })
976
+
977
+ // Write file
978
+ await fs.writeFile(filePath, content, encoding as BufferEncoding)
979
+
980
+ socket?.emit('fs:write:response', { success: true })
981
+ } catch (err: any) {
982
+ socket?.emit('fs:write:response', { success: false, error: err.message })
983
+ }
984
+ } catch (error: any) {
985
+ socket?.emit('fs:write:response', { success: false, error: error.message })
986
+ }
987
+ })
988
+
989
+ // File read handler
990
+ socket.on('fs:read', async (data: { filePath: string; encoding?: string }) => {
991
+ try {
992
+ const { filePath, encoding = 'utf-8' } = data
993
+ if (!filePath) {
994
+ socket?.emit('fs:read:response', { success: false, error: 'filePath is required' })
995
+ return
996
+ }
997
+
998
+ try {
999
+ // Read file
1000
+ const content = await fs.readFile(filePath, encoding as BufferEncoding)
1001
+
1002
+ socket?.emit('fs:read:response', { success: true, content })
1003
+ } catch (err: any) {
1004
+ socket?.emit('fs:read:response', { success: false, error: err.message })
1005
+ }
1006
+ } catch (error: any) {
1007
+ socket?.emit('fs:read:response', { success: false, error: error.message })
1008
+ }
1009
+ })
1010
+
1011
+ // Session handlers
1012
+ socket.on('session:create', async (data: { sessionId: string; projectId: string; projectPath: string }) => {
1013
+ try {
1014
+ let { sessionId, projectPath } = data
1015
+
1016
+ // Remap /home/abc to /tmp/abc if /home/abc doesn't exist (container workaround)
1017
+ if (projectPath === '/home/abc' && !fsSync.existsSync('/home/abc')) {
1018
+ const fallbackPath = '/tmp/abc'
1019
+ fsSync.mkdirSync(fallbackPath, { recursive: true })
1020
+ projectPath = fallbackPath
1021
+ console.log(`[CLI] Remapped /home/abc -> ${fallbackPath}`)
1022
+ }
1023
+
1024
+ activeSessions.set(sessionId, { projectPath })
1025
+ if (foreground) {
1026
+ console.log(`💬 Session created: ${sessionId}`)
1027
+ console.log(` Project: ${projectPath}`)
1028
+ } else {
1029
+ console.log(`Session created: ${sessionId} in project ${projectPath}`)
1030
+ }
1031
+ } catch (error: any) {
1032
+ if (foreground) {
1033
+ console.error(`✗ Failed to create session: ${error.message}`)
1034
+ } else {
1035
+ console.error('Failed to create session:', error.message)
1036
+ }
1037
+ }
1038
+ })
1039
+
1040
+ socket.on('session:delete', async (data: { sessionId: string }) => {
1041
+ try {
1042
+ const { sessionId } = data
1043
+ if (foreground) {
1044
+ console.log(`🗑️ Deleting session: ${sessionId}`)
1045
+ }
1046
+ await agentSessionManager.deleteSession(sessionId)
1047
+ activeSessions.delete(sessionId)
1048
+ if (foreground) {
1049
+ console.log(`✓ Session deleted: ${sessionId}`)
1050
+ } else {
1051
+ console.log(`Session deleted: ${sessionId}`)
1052
+ }
1053
+ } catch (error: any) {
1054
+ if (foreground) {
1055
+ console.error(`✗ Failed to delete session: ${error.message}`)
1056
+ } else {
1057
+ console.error('Failed to delete session:', error.message)
1058
+ }
1059
+ }
1060
+ })
1061
+
1062
+ socket.on('project:config:analyze', async (data: { projectId: string; projectPath: string; projectName: string; analysisId?: string }) => {
1063
+ try {
1064
+ const { projectId, projectPath, projectName, analysisId } = data
1065
+
1066
+ if (foreground) {
1067
+ console.log(`🔍 Analyzing project: ${projectName} at ${projectPath}`)
1068
+ }
1069
+
1070
+
1071
+ // Run Claude analysis
1072
+ const config = await analyzeProjectWithClaude(projectPath, projectName)
1073
+
1074
+ // Save config file (.talk-to-code.json)
1075
+ await saveProjectConfig(projectPath, config)
1076
+
1077
+ // Generate and save runner files for each app
1078
+ for (const app of config.apps) {
1079
+ const runnerCode = generateRunnerCode(app, projectPath)
1080
+ const runnerFileName = `${app.name}_runner.ts`
1081
+ const runnerPath = path.join(projectPath, app.directory || '', runnerFileName)
1082
+
1083
+ // Ensure directory exists
1084
+ const runnerDir = path.dirname(runnerPath)
1085
+ await fs.mkdir(runnerDir, { recursive: true })
1086
+
1087
+ // Write runner file
1088
+ await fs.writeFile(runnerPath, runnerCode, 'utf-8')
1089
+
1090
+ if (foreground) {
1091
+ console.log(`✓ Generated runner: ${runnerFileName}`)
1092
+ }
1093
+ }
1094
+
1095
+ if (foreground) {
1096
+ console.log(`✓ Analysis complete: Found ${config.apps.length} apps`)
1097
+ console.log(`✓ Generated ${config.apps.length} runner files`)
1098
+ }
1099
+
1100
+ // Emit result back to backend
1101
+ socket!.emit('project:config:analyzed', {
1102
+ projectId,
1103
+ config,
1104
+ analysisId
1105
+ })
1106
+ } catch (error: any) {
1107
+ if (foreground) {
1108
+ console.error(`✗ Analysis error: ${error.message}`)
1109
+ }
1110
+ socket!.emit('project:config:analyze:error', {
1111
+ projectId: data.projectId,
1112
+ error: error.message,
1113
+ analysisId: data.analysisId
1114
+ })
1115
+ }
1116
+ })
1117
+
1118
+ // App control handlers
1119
+ socket.on('app:start', async (data: { projectId: string; projectPath: string; appName: string; appControlId?: string }, callback: (response: { success: boolean; error?: string; processId?: string; pid?: number }) => void) => {
1120
+ try {
1121
+ const { projectId, projectPath, appName } = data
1122
+
1123
+ // Load project config to get app details
1124
+ const config = await getProjectConfig(projectPath)
1125
+
1126
+ if (!config) {
1127
+ callback?.({ success: false, error: 'Project config not found' })
1128
+ return
1129
+ }
1130
+
1131
+ const app = config.apps.find(a => a.name === appName)
1132
+ if (!app) {
1133
+ callback?.({ success: false, error: `App "${appName}" not found in project config` })
1134
+ return
1135
+ }
1136
+
1137
+ const result = await startApp(projectPath, projectId, app)
1138
+
1139
+ if (result.success) {
1140
+ if (foreground) {
1141
+ console.log(`✓ Started app: ${appName} (PID: ${result.pid})`)
1142
+ }
1143
+ socket!.emit('app:started', {
1144
+ projectId,
1145
+ appName,
1146
+ processId: result.processId,
1147
+ pid: result.pid,
1148
+ })
1149
+ }
1150
+
1151
+ callback?.(result)
1152
+ } catch (error: any) {
1153
+ if (foreground) {
1154
+ console.error(`✗ Error starting app: ${error.message}`)
1155
+ }
1156
+ callback?.({ success: false, error: error.message })
1157
+ }
1158
+ })
1159
+
1160
+ socket.on('app:stop', async (data: { projectId: string; projectPath: string; appName: string; appControlId?: string }, callback: (response: { success: boolean; error?: string }) => void) => {
1161
+ try {
1162
+ const { projectId, projectPath, appName } = data
1163
+
1164
+ // Load project config to get app details (for custom stop command)
1165
+ const config = await getProjectConfig(projectPath)
1166
+ const app = config?.apps.find(a => a.name === appName)
1167
+
1168
+ const result = await stopApp(projectId, appName, app)
1169
+
1170
+ if (result.success) {
1171
+ if (foreground) {
1172
+ console.log(`✓ Stopped app: ${appName}`)
1173
+ }
1174
+ socket!.emit('app:stopped', {
1175
+ projectId,
1176
+ appName,
1177
+ appControlId: data.appControlId,
1178
+ })
1179
+ }
1180
+
1181
+ callback?.(result)
1182
+ } catch (error: any) {
1183
+ if (foreground) {
1184
+ console.error(`✗ Error stopping app: ${error.message}`)
1185
+ }
1186
+ callback?.({ success: false, error: error.message })
1187
+ }
1188
+ })
1189
+
1190
+ socket.on('app:restart', async (data: { projectId: string; projectPath: string; appName: string; appControlId?: string }, callback: (response: { success: boolean; error?: string; processId?: string; pid?: number }) => void) => {
1191
+ try {
1192
+ const { projectId, projectPath, appName } = data
1193
+
1194
+ // Load project config to get app details
1195
+ const config = await getProjectConfig(projectPath)
1196
+
1197
+ if (!config) {
1198
+ callback?.({ success: false, error: 'Project config not found' })
1199
+ return
1200
+ }
1201
+
1202
+ const app = config.apps.find(a => a.name === appName)
1203
+ if (!app) {
1204
+ callback?.({ success: false, error: `App "${appName}" not found in project config` })
1205
+ return
1206
+ }
1207
+
1208
+ const result = await restartApp(projectPath, projectId, app)
1209
+
1210
+ if (result.success) {
1211
+ if (foreground) {
1212
+ console.log(`✓ Restarted app: ${appName} (PID: ${result.pid})`)
1213
+ }
1214
+ socket!.emit('app:restarted', {
1215
+ projectId,
1216
+ appName,
1217
+ processId: result.processId,
1218
+ pid: result.pid,
1219
+ appControlId: data.appControlId,
1220
+ })
1221
+ }
1222
+
1223
+ callback?.(result)
1224
+ } catch (error: any) {
1225
+ if (foreground) {
1226
+ console.error(`✗ Error restarting app: ${error.message}`)
1227
+ }
1228
+ callback?.({ success: false, error: error.message })
1229
+ }
1230
+ })
1231
+
1232
+ socket.on('app:status', async (data: { projectId: string; projectPath: string; appName?: string; appControlId?: string }, callback: (response: { success: boolean; apps?: Array<{ name: string; running: boolean; processId?: string; pid?: number }>; error?: string }) => void) => {
1233
+ try {
1234
+ const { projectId, projectPath, appName } = data
1235
+
1236
+ // Load project config
1237
+ const config = await getProjectConfig(projectPath)
1238
+
1239
+ if (!config) {
1240
+ callback?.({ success: false, error: 'Project config not found' })
1241
+ return
1242
+ }
1243
+
1244
+ const appsToCheck = appName
1245
+ ? config.apps.filter(a => a.name === appName)
1246
+ : config.apps
1247
+
1248
+ const statuses = getAppStatuses(projectId, appsToCheck)
1249
+
1250
+ // Send response back to backend
1251
+ if (data.appControlId) {
1252
+ socket!.emit('app:control:response', {
1253
+ appControlId: data.appControlId,
1254
+ success: true,
1255
+ apps: statuses,
1256
+ })
1257
+ }
1258
+
1259
+ callback?.({ success: true, apps: statuses })
1260
+ } catch (error: any) {
1261
+ if (foreground) {
1262
+ console.error(`✗ Error getting app status: ${error.message}`)
1263
+ }
1264
+ callback?.({ success: false, error: error.message })
1265
+ }
1266
+ })
1267
+
1268
+ socket.on('app:logs', async (data: { projectId: string; appName: string; follow?: boolean; appControlId?: string }, callback: (response: { success: boolean; logs?: string; error?: string }) => void) => {
1269
+ try {
1270
+ const { projectId, appName } = data
1271
+
1272
+ const result = await getAppLogs(projectId, appName, 100)
1273
+
1274
+ // Send response back to backend
1275
+ if (data.appControlId) {
1276
+ socket!.emit('app:control:response', {
1277
+ appControlId: data.appControlId,
1278
+ ...result,
1279
+ })
1280
+ }
1281
+
1282
+ callback?.(result)
1283
+ } catch (error: any) {
1284
+ if (foreground) {
1285
+ console.error(`✗ Error getting app logs: ${error.message}`)
1286
+ }
1287
+ callback?.({ success: false, error: error.message })
1288
+ }
1289
+ })
1290
+
1291
+ socket.on('session:prompt', async (data: SessionPromptData & { projectPath?: string; promptId?: string; model?: string }) => {
1292
+ try {
1293
+ const { sessionId, prompt, projectPath: providedProjectPath, promptId, enhancers, model } = data
1294
+
1295
+ // promptId is REQUIRED when routing to device
1296
+ if (!promptId) {
1297
+ if (foreground) {
1298
+ console.error(`✗ Missing required promptId for session: ${sessionId}`)
1299
+ }
1300
+ socket!.emit('session:error', { sessionId, error: 'Missing required promptId' })
1301
+ return
1302
+ }
1303
+
1304
+ // Try to get projectPath from activeSessions or use provided one
1305
+ let projectPath: string | undefined = providedProjectPath || activeSessions.get(sessionId)?.projectPath
1306
+
1307
+ if (!projectPath) {
1308
+ if (foreground) {
1309
+ console.error(`✗ Session not found: ${sessionId} (missing projectPath)`)
1310
+ }
1311
+ socket!.emit('session:error', { sessionId, error: 'Session not found or projectPath missing' })
1312
+ return
1313
+ }
1314
+
1315
+ // Check if user choice is enabled from the prompt data (sent by backend)
1316
+ // Backend now includes enabledModules in the session:prompt event
1317
+ const enabledModules = data.enabledModules || []
1318
+ const moduleSettings = data.moduleSettings || {}
1319
+ const userChoiceEnabled = enabledModules.includes('user-choice') || false
1320
+
1321
+ if (foreground) {
1322
+ console.log(`[CLI] Enabled modules: ${enabledModules.join(', ') || 'none'}`)
1323
+ console.log(`[CLI] User choice enabled: ${userChoiceEnabled}`)
1324
+ }
1325
+
1326
+ // Store in activeSessions with promptId and model
1327
+ activeSessions.set(sessionId, { projectPath, currentPromptId: promptId, model })
1328
+
1329
+ if (!projectPath) {
1330
+ if (foreground) {
1331
+ console.error(`✗ Session not found: ${sessionId} (missing projectPath)`)
1332
+ }
1333
+ socket!.emit('session:error', { sessionId, error: 'Session not found or projectPath missing' })
1334
+ return
1335
+ }
1336
+
1337
+ // Store in activeSessions with promptId and model
1338
+ activeSessions.set(sessionId, { projectPath, currentPromptId: promptId, model })
1339
+
1340
+ // Capture promptId in closure to prevent race conditions when multiple prompts arrive quickly
1341
+ // This ensures each prompt's messages use the correct promptId even if a new prompt arrives
1342
+ const capturedPromptId = promptId
1343
+
1344
+ if (foreground) {
1345
+ console.log(`\n[CLI] 📤 Received prompt for session: ${sessionId}, promptId: ${capturedPromptId}`)
1346
+ console.log(`[CLI] Prompt: ${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}`)
1347
+ }
1348
+
1349
+ // Create or get session handler
1350
+ await agentSessionManager.createSession({
1351
+ sessionId,
1352
+ projectPath,
1353
+ userChoiceEnabled, // Pass user choice enabled setting from backend
1354
+ enabledModules, // Pass enabled modules for MCP server
1355
+ moduleSettings, // Pass module settings for MCP server
1356
+ onOutput: (output: SessionOutput) => {
1357
+ // Serialize data to string if it's an object
1358
+ const dataString = typeof output.data === 'string'
1359
+ ? output.data
1360
+ : JSON.stringify(output.data)
1361
+
1362
+ // Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
1363
+ if (!capturedPromptId) {
1364
+ console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit prompt:output`)
1365
+ return
1366
+ }
1367
+
1368
+ socket!.emit('prompt:output', {
1369
+ sessionId,
1370
+ promptId: capturedPromptId, // Use captured promptId, not currentPromptId
1371
+ type: output.type,
1372
+ data: dataString,
1373
+ timestamp: output.timestamp,
1374
+ metadata: output.metadata
1375
+ })
1376
+ },
1377
+ onError: (error: string) => {
1378
+ socket!.emit('session:error', { sessionId, error })
1379
+ },
1380
+ onComplete: (exitCode: number | null) => {
1381
+ // Note: This onComplete is from createSession, not sendPrompt
1382
+ // The actual session:result is emitted from sendPrompt handler below
1383
+ // This handler is kept for backward compatibility but may not be used
1384
+ },
1385
+ onChoiceRequest: (request) => {
1386
+ // Emit choice request to frontend
1387
+ socket!.emit('user:choice:request', {
1388
+ sessionId,
1389
+ choiceId: request.choiceId,
1390
+ question: request.question,
1391
+ options: request.options,
1392
+ timeout: request.timeout
1393
+ })
1394
+ }
1395
+ })
1396
+
1397
+ // Send prompt - status updates will be emitted from agentSession when processing starts/completes
1398
+ await agentSessionManager.sendPrompt(sessionId, prompt, enhancers || [], {
1399
+ sessionId,
1400
+ projectPath,
1401
+ promptId: capturedPromptId,
1402
+ model: model, // Pass the model from the session
1403
+ onStatusUpdate: (status: 'running' | 'completed' | 'error' | 'cancelled') => {
1404
+ // Emit status update from CLI (CLI is source of truth)
1405
+ // Use captured promptId to ensure correct prompt is updated
1406
+ if (!capturedPromptId) {
1407
+ console.error(`[CLI] Missing promptId for status update, cannot emit prompt:updated`)
1408
+ return
1409
+ }
1410
+
1411
+ // Always log status updates for debugging (even in background mode)
1412
+ console.log(`[CLI] 📊 Status update: promptId=${capturedPromptId}, status=${status}, sessionId=${sessionId}`)
1413
+
1414
+ // Emit status update IMMEDIATELY (real-time)
1415
+ socket!.emit('prompt:updated', {
1416
+ promptId: capturedPromptId, // CRITICAL: Use captured promptId from closure
1417
+ sessionId,
1418
+ text: prompt,
1419
+ status,
1420
+ createdAt: new Date().toISOString(),
1421
+ ...(status === 'running' ? { startedAt: new Date().toISOString() } : {}),
1422
+ ...(status === 'completed' || status === 'error' || status === 'cancelled' ? { completedAt: new Date().toISOString() } : {}),
1423
+ messages: []
1424
+ })
1425
+ },
1426
+ onOutput: (output: SessionOutput) => {
1427
+ // Serialize data to string if it's an object
1428
+ const dataString = typeof output.data === 'string'
1429
+ ? output.data
1430
+ : JSON.stringify(output.data)
1431
+
1432
+ // Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
1433
+ if (!capturedPromptId) {
1434
+ console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit prompt:output`)
1435
+ return
1436
+ }
1437
+
1438
+ if (foreground && output.type === 'stdout') {
1439
+ process.stdout.write(dataString)
1440
+ }
1441
+ socket!.emit('prompt:output', {
1442
+ sessionId,
1443
+ promptId: capturedPromptId, // Use captured promptId, not currentPromptId
1444
+ type: output.type,
1445
+ data: dataString,
1446
+ timestamp: output.timestamp,
1447
+ metadata: output.metadata
1448
+ })
1449
+ },
1450
+ onError: (error: string) => {
1451
+ // Error logging is handled by AgentLogger in agentSession.ts
1452
+ // Additional console output for foreground mode
1453
+ if (foreground) {
1454
+ console.error(`\n[CLI] ✗ Session error: ${error}`)
1455
+ }
1456
+ socket!.emit('session:error', { sessionId, error })
1457
+ },
1458
+ onComplete: (exitCode: number | null) => {
1459
+ // Completion logging is handled by AgentLogger in agentSession.ts
1460
+ // Additional console output for foreground mode
1461
+ if (foreground) {
1462
+ console.log(`\n[CLI] ✓ Session completed with exit code: ${exitCode ?? 'null'}`)
1463
+ }
1464
+
1465
+ // Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
1466
+ if (!capturedPromptId) {
1467
+ console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit session:result`)
1468
+ return
1469
+ }
1470
+
1471
+ socket!.emit('session:result', {
1472
+ sessionId,
1473
+ promptId: capturedPromptId, // Use captured promptId, not currentPromptId
1474
+ exitCode
1475
+ })
1476
+ }
1477
+ })
1478
+ } catch (error: any) {
1479
+ if (foreground) {
1480
+ console.error(`✗ Error processing prompt: ${error.message}`)
1481
+ }
1482
+ socket!.emit('session:error', { sessionId: data.sessionId, error: error.message })
1483
+ }
1484
+ })
1485
+
1486
+ // Handle prompt cancellation
1487
+ socket.on('prompt:cancel', async (data: { promptId: string; sessionId: string }, callback: (response: { success: boolean; error?: string }) => void) => {
1488
+ try {
1489
+ const { promptId, sessionId } = data
1490
+
1491
+ if (!promptId || !sessionId) {
1492
+ callback?.({ success: false, error: 'Missing promptId or sessionId' })
1493
+ return
1494
+ }
1495
+
1496
+ if (foreground) {
1497
+ console.log(`[CLI] 🛑 Cancelling prompt: ${promptId}`)
1498
+ }
1499
+
1500
+ // Cancel the prompt
1501
+ const cancelled = await agentSessionManager.cancelPrompt(
1502
+ promptId,
1503
+ sessionId,
1504
+ (status) => {
1505
+ // Emit cancelled status update
1506
+ socket!.emit('prompt:updated', {
1507
+ promptId,
1508
+ sessionId,
1509
+ text: '', // Not needed for status update
1510
+ status: 'cancelled',
1511
+ createdAt: new Date().toISOString(),
1512
+ completedAt: new Date().toISOString(),
1513
+ messages: []
1514
+ })
1515
+ }
1516
+ )
1517
+
1518
+ if (cancelled) {
1519
+ callback?.({ success: true })
1520
+ } else {
1521
+ callback?.({ success: false, error: 'Prompt not found or already completed' })
1522
+ }
1523
+ } catch (error: any) {
1524
+ if (foreground) {
1525
+ console.error(`✗ Error cancelling prompt: ${error.message}`)
1526
+ }
1527
+ callback?.({ success: false, error: error.message })
1528
+ }
1529
+ })
1530
+
1531
+ // Handle emergency stop - forcefully halt all session activity
1532
+ socket.on('emergency:stop', async (data: { sessionId: string }, callback: (response: { success: boolean; message?: string }) => void) => {
1533
+ try {
1534
+ const { sessionId } = data
1535
+
1536
+ if (!sessionId) {
1537
+ callback?.({ success: false, message: 'Missing sessionId' })
1538
+ return
1539
+ }
1540
+
1541
+ if (foreground) {
1542
+ console.log(`[CLI] ☠️ EMERGENCY STOP for session: ${sessionId}`)
1543
+ }
1544
+
1545
+ // Execute emergency stop
1546
+ const result = await agentSessionManager.emergencyStop(sessionId)
1547
+
1548
+ // Emit status update for current prompt if any
1549
+ socket!.emit('emergency:stopped', {
1550
+ sessionId,
1551
+ success: result.success,
1552
+ message: result.message,
1553
+ timestamp: new Date().toISOString()
1554
+ })
1555
+
1556
+ callback?.(result)
1557
+ } catch (error: any) {
1558
+ if (foreground) {
1559
+ console.error(`✗ Error during emergency stop: ${error.message}`)
1560
+ }
1561
+ callback?.({ success: false, message: error.message })
1562
+ }
1563
+ })
1564
+
1565
+ // Handle user choice response from frontend
1566
+ socket.on('user:choice:response', async (data: { sessionId: string; choiceId: string; selectedValue: string | null }) => {
1567
+ try {
1568
+ const { sessionId, choiceId, selectedValue } = data
1569
+
1570
+ if (foreground) {
1571
+ console.log(`[CLI] 📝 Received user choice response: choiceId=${choiceId}, selectedValue=${selectedValue}`)
1572
+ }
1573
+
1574
+ // Forward the response to the agent session manager
1575
+ await agentSessionManager.handleChoiceResponse(sessionId, {
1576
+ choiceId,
1577
+ selectedValue
1578
+ })
1579
+ } catch (error: any) {
1580
+ if (foreground) {
1581
+ console.error(`✗ Error handling user choice response: ${error.message}`)
1582
+ }
1583
+ }
1584
+ })
1585
+
1586
+ // Handle image save request - saves base64 images to tmp directory
1587
+ socket.on('image:save', async (data: { images: Array<{ name: string; data: string }>; saveCallbackId: string; projectPath?: string }) => {
1588
+ try {
1589
+ const { images, saveCallbackId, projectPath } = data
1590
+
1591
+ if (foreground) {
1592
+ console.log(`[CLI] 🖼️ Received request to save ${images.length} image(s)`)
1593
+ }
1594
+
1595
+ // Use project-specific tmp directory (inside the project)
1596
+ // If projectPath is provided, use it; otherwise fall back to current working directory
1597
+ const basePath = projectPath || process.cwd()
1598
+ const tmpDir = path.join(basePath, 'tmp')
1599
+ await fs.mkdir(tmpDir, { recursive: true })
1600
+
1601
+ if (foreground) {
1602
+ console.log(`[CLI] 📁 Saving to directory: ${tmpDir}`)
1603
+ }
1604
+
1605
+ let savedCount = 0
1606
+ const errors: string[] = []
1607
+
1608
+ for (const image of images) {
1609
+ try {
1610
+ const imagePath = path.join(tmpDir, image.name)
1611
+
1612
+ // Convert base64 to buffer and write to file
1613
+ const buffer = Buffer.from(image.data, 'base64')
1614
+ await fs.writeFile(imagePath, buffer)
1615
+
1616
+ if (foreground) {
1617
+ console.log(`[CLI] ✓ Saved image: ${imagePath}`)
1618
+ }
1619
+ savedCount++
1620
+ } catch (saveError: any) {
1621
+ const errorMsg = `Failed to save ${image.name}: ${saveError.message}`
1622
+ errors.push(errorMsg)
1623
+ if (foreground) {
1624
+ console.error(`[CLI] ✗ ${errorMsg}`)
1625
+ }
1626
+ }
1627
+ }
1628
+
1629
+ // Send response back via app:control:response
1630
+ console.log(`[CLI] Sending app:control:response with appControlId=${saveCallbackId}, success=${errors.length === 0}, savedCount=${savedCount}`)
1631
+ socket!.emit('app:control:response', {
1632
+ appControlId: saveCallbackId,
1633
+ success: errors.length === 0,
1634
+ savedCount,
1635
+ error: errors.length > 0 ? errors.join('; ') : undefined
1636
+ })
1637
+ console.log(`[CLI] Emitted app:control:response`)
1638
+
1639
+ if (foreground) {
1640
+ if (errors.length > 0) {
1641
+ console.log(`[CLI] ⚠️ Saved ${savedCount}/${images.length} images with errors`)
1642
+ } else {
1643
+ console.log(`[CLI] ✓ Successfully saved all ${savedCount} images to ${tmpDir}`)
1644
+ }
1645
+ }
1646
+ } catch (error: any) {
1647
+ console.error(`[CLI] ✗ Error saving images: ${error.message}`)
1648
+ // Send error response
1649
+ console.log(`[CLI] Sending error app:control:response with appControlId=${data.saveCallbackId}`)
1650
+ socket!.emit('app:control:response', {
1651
+ appControlId: data.saveCallbackId,
1652
+ success: false,
1653
+ error: error.message
1654
+ })
1655
+ console.log(`[CLI] Emitted error app:control:response`)
1656
+ }
1657
+ })
1658
+
1659
+ socket.on('connect', () => {
1660
+ if (foreground) {
1661
+ console.log(`✓ Connected to backend at ${config.apiUrl}`)
1662
+ if (socket) {
1663
+ console.log(` Socket ID: ${socket.id}`)
1664
+ }
1665
+ } else {
1666
+ console.log('Connected to backend')
1667
+ }
1668
+ reconnectAttempts = 0
1669
+
1670
+ // Register as CLI device
1671
+ if (socket) {
1672
+ socket.emit('register', { type: 'cli', deviceId })
1673
+ }
1674
+ })
1675
+
1676
+ socket.on('registered', async ({ type }: { type: string }) => {
1677
+ if (foreground) {
1678
+ console.log(`✓ Registered as ${type}`)
1679
+ } else {
1680
+ console.log(`Registered as ${type}`)
1681
+ }
1682
+
1683
+ // Check for auth token: env TTC_AUTH_TOKEN or device-config.json (from previous approval)
1684
+ const deviceConfigFile = path.join(CONFIG_DIR, 'device-config.json')
1685
+ let authToken = process.env.TTC_AUTH_TOKEN
1686
+ let deviceEmail: string | undefined = email
1687
+ let skipEmailFlow = false
1688
+
1689
+ // Load from device-config if no env token (reuse state from previous approval)
1690
+ if (!authToken) {
1691
+ try {
1692
+ const deviceConfig = JSON.parse(await fs.readFile(deviceConfigFile, 'utf-8'))
1693
+ authToken = deviceConfig.authToken
1694
+ if (!deviceEmail && deviceConfig.email) deviceEmail = deviceConfig.email
1695
+ } catch {
1696
+ // No device config yet
1697
+ }
1698
+ }
1699
+
1700
+ if (authToken) {
1701
+ try {
1702
+ const parts = authToken.split('.')
1703
+ if (parts.length === 3) {
1704
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString())
1705
+ if (payload.type === 'device_auth' && payload.email) {
1706
+ deviceEmail = payload.email
1707
+ skipEmailFlow = true
1708
+ if (foreground) {
1709
+ console.log(`✓ Using stored auth token for ${deviceEmail}`)
1710
+ }
1711
+ }
1712
+ }
1713
+ } catch (err) {
1714
+ if (foreground) {
1715
+ console.warn(`⚠️ Invalid auth token in config, falling back to normal auth`)
1716
+ }
1717
+ authToken = undefined
1718
+ }
1719
+ }
1720
+
1721
+ // Load device email from config if not using token and not provided
1722
+ if (!deviceEmail && !skipEmailFlow) {
1723
+ try {
1724
+ const deviceConfig = JSON.parse(await fs.readFile(deviceConfigFile, 'utf-8'))
1725
+ deviceEmail = deviceConfig.email
1726
+ } catch {
1727
+ // No device config yet
1728
+ }
1729
+ }
1730
+
1731
+ // Register device with backend
1732
+ let needsApprovalLoggedOnce = false
1733
+ const registerDevice = () => {
1734
+ // Determine device name
1735
+ const containerName = process.env.CONTAINER_NAME
1736
+ const deviceName = containerName ? `Container: ${containerName}` : `CLI Device ${hostname}`
1737
+
1738
+ socket!.emit('device:register', {
1739
+ deviceId,
1740
+ name: deviceName,
1741
+ ipAddress,
1742
+ hostname,
1743
+ email: deviceEmail,
1744
+ // When using auth token, include the token for verification
1745
+ authToken: skipEmailFlow ? authToken : undefined
1746
+ }, ({ success, needsApproval, message, device, error }: {
1747
+ success: boolean
1748
+ needsApproval?: boolean
1749
+ message?: string
1750
+ device?: Device
1751
+ error?: string
1752
+ }) => {
1753
+ if (success && device) {
1754
+ needsApprovalLoggedOnce = false
1755
+ if (foreground) {
1756
+ console.log(`✓ Device registered: ${device.name}`)
1757
+ if (device.approved) {
1758
+ console.log(`✓ Device approved and ready`)
1759
+ }
1760
+ } else {
1761
+ console.log(`Device registered: ${device.name}${device.approved ? ' (approved)' : ' (pending approval)'}`)
1762
+ }
1763
+ // Save config: email + authToken (preserve token so next run can reuse)
1764
+ if (device.email) {
1765
+ const toSave = { email: device.email, ...(authToken ? { authToken } : {}) }
1766
+ fs.writeFile(deviceConfigFile, JSON.stringify(toSave, null, 2)).catch(() => {})
1767
+ }
1768
+ // Fetch AI config from server (non-blocking, runs on each heartbeat)
1769
+ if (authToken) {
1770
+ fetchAiConfig(authToken).catch(() => {})
1771
+ }
1772
+ } else if (needsApproval) {
1773
+ // Persist email when approval was requested so restarts don't prompt again
1774
+ if (deviceEmail) {
1775
+ fs.writeFile(deviceConfigFile, JSON.stringify({ email: deviceEmail }, null, 2)).catch(() => {})
1776
+ }
1777
+ // Only log once to avoid flooding logs every 30s on heartbeat
1778
+ if (!needsApprovalLoggedOnce) {
1779
+ needsApprovalLoggedOnce = true
1780
+ if (foreground) {
1781
+ console.log(`\n⚠️ ${message || 'Device approval required'}`)
1782
+ if (!deviceEmail) {
1783
+ console.log(` Please run: npx tsx index.ts register --email your@email.com`)
1784
+ } else {
1785
+ console.log(` Check your email (${deviceEmail}) and click the approval link.`)
1786
+ }
1787
+ } else {
1788
+ console.log(`⚠️ Device approval required: ${message || 'Please approve device'}`)
1789
+ }
1790
+ }
1791
+ } else if (error) {
1792
+ console.error(`✗ Registration error: ${error}`)
1793
+ }
1794
+ })
1795
+ }
1796
+
1797
+ registerDevice()
1798
+
1799
+ // Update lastSeen every 30 seconds while connected
1800
+ const heartbeatInterval = setInterval(() => {
1801
+ if (socket?.connected) {
1802
+ registerDevice()
1803
+ } else {
1804
+ clearInterval(heartbeatInterval)
1805
+ }
1806
+ }, 30000)
1807
+
1808
+ // Store interval ID for cleanup
1809
+ ;(socket as any).heartbeatInterval = heartbeatInterval
1810
+ })
1811
+
1812
+ // Cloudflared handlers
1813
+ socket.on('cloudflared:check:request', async () => {
1814
+ try {
1815
+ let installed = false
1816
+ let hasCert = false
1817
+ let activeTunnelId: string | undefined
1818
+
1819
+ try {
1820
+ // Check if cloudflared is installed
1821
+ execSync('which cloudflared', { stdio: 'ignore' })
1822
+ installed = true
1823
+
1824
+ // Check if cert.pem exists
1825
+ const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem')
1826
+ try {
1827
+ // Use fs.stat to check if file exists (more reliable than access)
1828
+ const stats = await fs.stat(certPath)
1829
+ hasCert = stats.isFile()
1830
+ if (foreground && hasCert) {
1831
+ console.log(`✓ Found cert.pem at ${certPath}`)
1832
+ }
1833
+ } catch (err: any) {
1834
+ // File doesn't exist or can't be accessed
1835
+ hasCert = false
1836
+ if (foreground) {
1837
+ console.log(`✗ cert.pem not found at ${certPath}: ${err.message}`)
1838
+ }
1839
+ }
1840
+
1841
+ // Detect running cloudflared tunnel by inspecting processes
1842
+ try {
1843
+ const psOutput = execSync('ps aux', { encoding: 'utf-8' })
1844
+ const cloudflaredLines = psOutput.split('\n').filter(line => line.includes('cloudflared') && line.includes('tunnel'))
1845
+
1846
+ for (const line of cloudflaredLines) {
1847
+ // Look for --token flag in the process args
1848
+ const tokenMatch = line.match(/--token\s+(\S+)/)
1849
+ if (tokenMatch && tokenMatch[1]) {
1850
+ try {
1851
+ // Decode base64url token to extract tunnel ID
1852
+ const tokenB64 = tokenMatch[1].replace(/-/g, '+').replace(/_/g, '/')
1853
+ const padded = tokenB64 + '='.repeat((4 - tokenB64.length % 4) % 4)
1854
+ const tokenJson = Buffer.from(padded, 'base64').toString('utf-8')
1855
+ const tokenData = JSON.parse(tokenJson)
1856
+ if (tokenData.t) {
1857
+ activeTunnelId = tokenData.t
1858
+ if (foreground) {
1859
+ console.log(`✓ Detected active tunnel: ${activeTunnelId}`)
1860
+ }
1861
+ break
1862
+ }
1863
+ } catch {
1864
+ // Failed to decode token, skip
1865
+ }
1866
+ }
1867
+
1868
+ // Also check for named tunnel (cloudflared tunnel run <name>)
1869
+ const nameMatch = line.match(/cloudflared.*tunnel\s+run\s+(?:--.*?\s+)?(\S+)/)
1870
+ if (!activeTunnelId && nameMatch && nameMatch[1] && !nameMatch[1].startsWith('-')) {
1871
+ // This is a tunnel name, not an ID - we'll resolve it later via API
1872
+ activeTunnelId = nameMatch[1]
1873
+ if (foreground) {
1874
+ console.log(`✓ Detected running tunnel name: ${activeTunnelId}`)
1875
+ }
1876
+ break
1877
+ }
1878
+ }
1879
+ } catch {
1880
+ // ps failed, can't detect active tunnel
1881
+ }
1882
+ } catch {
1883
+ installed = false
1884
+ }
1885
+
1886
+ socket!.emit('cloudflared:check:response', { installed, hasCert, activeTunnelId })
1887
+ if (foreground) {
1888
+ console.log(`Cloudflared check: installed=${installed}, hasCert=${hasCert}, activeTunnelId=${activeTunnelId || 'none'}`)
1889
+ }
1890
+ } catch (error: any) {
1891
+ socket!.emit('cloudflared:check:response', { installed: false, hasCert: false })
1892
+ }
1893
+ })
1894
+
1895
+ socket.on('cloudflared:sync:request', async () => {
1896
+ try {
1897
+ const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem')
1898
+
1899
+ if (foreground) {
1900
+ console.log(`Syncing credentials from ${certPath}`)
1901
+ }
1902
+
1903
+ // Read cert.pem file
1904
+ const certContent = await fs.readFile(certPath, 'utf-8')
1905
+
1906
+ // Extract token between BEGIN and END markers
1907
+ const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/)
1908
+ if (tokenMatch && tokenMatch[1]) {
1909
+ // Decode base64 token
1910
+ const tokenBase64 = tokenMatch[1].replace(/\s/g, '')
1911
+ const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8')
1912
+ const tokenData = JSON.parse(tokenJson)
1913
+
1914
+ // Extract API token, account ID, and zone ID
1915
+ const apiToken = tokenData.apiToken
1916
+ const accountId = tokenData.accountID
1917
+ const zoneId = tokenData.zoneID
1918
+
1919
+ if (foreground) {
1920
+ console.log(`✓ Extracted credentials: accountId=${accountId}, zoneId=${zoneId}`)
1921
+ }
1922
+
1923
+ socket!.emit('cloudflared:sync:complete', {
1924
+ accountId,
1925
+ accountName: undefined,
1926
+ apiToken,
1927
+ zoneId
1928
+ })
1929
+ } else {
1930
+ const error = 'Failed to extract token from cert.pem'
1931
+ if (foreground) {
1932
+ console.error(`✗ ${error}`)
1933
+ }
1934
+ socket!.emit('cloudflared:sync:error', { error })
1935
+ }
1936
+ } catch (error: any) {
1937
+ const errorMsg = `Failed to read cert.pem: ${error.message}`
1938
+ if (foreground) {
1939
+ console.error(`✗ ${errorMsg}`)
1940
+ }
1941
+ socket!.emit('cloudflared:sync:error', { error: errorMsg })
1942
+ }
1943
+ })
1944
+
1945
+ socket.on('cloudflared:login:request', async () => {
1946
+ try {
1947
+
1948
+ // Run cloudflared tunnel login
1949
+ const loginProcess = spawn('cloudflared', ['tunnel', 'login'], {
1950
+ stdio: ['ignore', 'pipe', 'pipe']
1951
+ })
1952
+
1953
+ let stdout = ''
1954
+ let stderr = ''
1955
+ let urlEmitted = false
1956
+ let alreadyLoggedIn = false
1957
+ let certPath: string | null = null
1958
+
1959
+ const extractCertPath = (text: string): string | null => {
1960
+ // Look for: "You have an existing certificate at /path/to/cert.pem"
1961
+ const pathMatch = text.match(/existing certificate at\s+([^\s]+)/i) ||
1962
+ text.match(/certificate at\s+([^\s]+)/i) ||
1963
+ text.match(/cert\.pem.*?at\s+([^\s]+)/i)
1964
+ return pathMatch ? pathMatch[1] : null
1965
+ }
1966
+
1967
+ const extractLoginUrl = (text: string): string | null => {
1968
+ // Look for URLs in the output
1969
+ const urlPatterns = [
1970
+ /https:\/\/dash\.cloudflare\.com\/argotunnel[^\s\)]+/g,
1971
+ /https:\/\/[^\s\)]+cloudflareaccess\.org[^\s\)]+/g,
1972
+ /https:\/\/[^\s\)]+cloudflare\.com[^\s\)]+/g
1973
+ ]
1974
+
1975
+ for (const pattern of urlPatterns) {
1976
+ const matches = text.match(pattern)
1977
+ if (matches && matches.length > 0) {
1978
+ return matches[0]
1979
+ }
1980
+ }
1981
+ return null
1982
+ }
1983
+
1984
+ loginProcess.stdout.on('data', (data) => {
1985
+ const text = data.toString()
1986
+ stdout += text
1987
+
1988
+ // Check for already logged in error
1989
+ if (text.includes('existing certificate') || text.includes('cert.pem which login would overwrite')) {
1990
+ alreadyLoggedIn = true
1991
+ const extractedPath = extractCertPath(text)
1992
+ if (extractedPath) {
1993
+ certPath = extractedPath
1994
+ }
1995
+ }
1996
+
1997
+ // Extract login URL if not already logged in
1998
+ if (!alreadyLoggedIn && !urlEmitted) {
1999
+ const url = extractLoginUrl(text)
2000
+ if (url) {
2001
+ urlEmitted = true
2002
+ socket!.emit('cloudflared:login:url', { loginUrl: url })
2003
+ }
2004
+ }
2005
+ })
2006
+
2007
+ loginProcess.stderr.on('data', (data) => {
2008
+ const text = data.toString()
2009
+ stderr += text
2010
+
2011
+ // Check for already logged in error (often in stderr)
2012
+ if (text.includes('existing certificate') || text.includes('cert.pem which login would overwrite')) {
2013
+ alreadyLoggedIn = true
2014
+ const extractedPath = extractCertPath(text)
2015
+ if (extractedPath) {
2016
+ certPath = extractedPath
2017
+ }
2018
+ }
2019
+
2020
+ // Extract login URL if not already logged in
2021
+ if (!alreadyLoggedIn && !urlEmitted) {
2022
+ const url = extractLoginUrl(text)
2023
+ if (url) {
2024
+ urlEmitted = true
2025
+ socket!.emit('cloudflared:login:url', { loginUrl: url })
2026
+ }
2027
+ }
2028
+ })
2029
+
2030
+ loginProcess.on('close', async (code) => {
2031
+ if (alreadyLoggedIn && certPath) {
2032
+ // Already logged in - extract credentials from existing cert
2033
+ try {
2034
+ if (foreground) {
2035
+ console.log(`Already logged in, extracting credentials from ${certPath}`)
2036
+ }
2037
+ const certContent = await fs.readFile(certPath, 'utf-8')
2038
+ const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/)
2039
+ if (tokenMatch && tokenMatch[1]) {
2040
+ const tokenBase64 = tokenMatch[1].replace(/\s/g, '')
2041
+ const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8')
2042
+ const tokenData = JSON.parse(tokenJson)
2043
+
2044
+ if (foreground) {
2045
+ console.log(`✓ Extracted credentials from existing cert`)
2046
+ }
2047
+
2048
+ socket!.emit('cloudflared:login:complete', {
2049
+ accountId: tokenData.accountID,
2050
+ accountName: undefined,
2051
+ apiToken: tokenData.apiToken,
2052
+ zoneId: tokenData.zoneID
2053
+ })
2054
+ } else {
2055
+ const error = 'Failed to extract token from existing cert.pem'
2056
+ if (foreground) {
2057
+ console.error(`✗ ${error}`)
2058
+ }
2059
+ socket!.emit('cloudflared:login:error', { error })
2060
+ }
2061
+ } catch (error: any) {
2062
+ const errorMsg = `Failed to read cert.pem: ${error.message}`
2063
+ if (foreground) {
2064
+ console.error(`✗ ${errorMsg}`)
2065
+ }
2066
+ socket!.emit('cloudflared:login:error', { error: errorMsg })
2067
+ }
2068
+ } else if (code === 0 && !alreadyLoggedIn) {
2069
+ // Login completed successfully - wait a moment then extract credentials
2070
+ if (foreground) {
2071
+ console.log('Login completed, extracting credentials...')
2072
+ }
2073
+ setTimeout(async () => {
2074
+ try {
2075
+ const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem')
2076
+ const certContent = await fs.readFile(certPath, 'utf-8')
2077
+ const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/)
2078
+ if (tokenMatch && tokenMatch[1]) {
2079
+ const tokenBase64 = tokenMatch[1].replace(/\s/g, '')
2080
+ const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8')
2081
+ const tokenData = JSON.parse(tokenJson)
2082
+
2083
+ if (foreground) {
2084
+ console.log(`✓ Extracted credentials after login`)
2085
+ }
2086
+
2087
+ socket!.emit('cloudflared:login:complete', {
2088
+ accountId: tokenData.accountID,
2089
+ accountName: undefined,
2090
+ apiToken: tokenData.apiToken,
2091
+ zoneId: tokenData.zoneID
2092
+ })
2093
+ } else {
2094
+ const error = 'Failed to extract token from cert.pem after login'
2095
+ if (foreground) {
2096
+ console.error(`✗ ${error}`)
2097
+ }
2098
+ socket!.emit('cloudflared:login:error', { error })
2099
+ }
2100
+ } catch (error: any) {
2101
+ const errorMsg = `Failed to read cert.pem after login: ${error.message}`
2102
+ if (foreground) {
2103
+ console.error(`✗ ${errorMsg}`)
2104
+ }
2105
+ socket!.emit('cloudflared:login:error', { error: errorMsg })
2106
+ }
2107
+ }, 1000) // Wait 1 second for file to be written
2108
+ } else if (!alreadyLoggedIn) {
2109
+ const error = `Login failed with code ${code}: ${stderr || stdout}`
2110
+ if (foreground) {
2111
+ console.error(`✗ ${error}`)
2112
+ }
2113
+ socket!.emit('cloudflared:login:error', { error })
2114
+ }
2115
+ })
2116
+
2117
+ loginProcess.on('error', (error) => {
2118
+ socket!.emit('cloudflared:login:error', { error: error.message })
2119
+ })
2120
+ } catch (error: any) {
2121
+ socket!.emit('cloudflared:login:error', { error: error.message })
2122
+ }
2123
+ })
2124
+
2125
+ socket.on('cloudflared:regenerate:request', async () => {
2126
+ try {
2127
+ const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem')
2128
+
2129
+ // Delete existing cert.pem
2130
+ try {
2131
+ await fs.unlink(certPath)
2132
+ if (foreground) {
2133
+ console.log(`✓ Deleted existing cert.pem`)
2134
+ }
2135
+ } catch (error: any) {
2136
+ // File might not exist, that's okay
2137
+ if (foreground && error.code !== 'ENOENT') {
2138
+ console.log(`Note: Could not delete cert.pem: ${error.message}`)
2139
+ }
2140
+ }
2141
+
2142
+ // Emit success - frontend will then trigger login
2143
+ socket!.emit('cloudflared:regenerate:complete', {})
2144
+ } catch (error: any) {
2145
+ socket!.emit('cloudflared:regenerate:error', { error: error.message })
2146
+ }
2147
+ })
2148
+
2149
+ socket.on('cloudflared:login:request', async () => {
2150
+ try {
2151
+
2152
+ // First check if cloudflared is installed
2153
+ try {
2154
+ execSync('which cloudflared', { stdio: 'ignore' })
2155
+ } catch {
2156
+ socket!.emit('cloudflared:login:error', { error: 'cloudflared is not installed' })
2157
+ return
2158
+ }
2159
+
2160
+ // Run cloudflared tunnel login
2161
+ // This command outputs a URL that needs to be visited
2162
+ const loginProcess = spawn('cloudflared', ['tunnel', 'login'], {
2163
+ stdio: ['ignore', 'pipe', 'pipe']
2164
+ })
2165
+
2166
+ let stdout = ''
2167
+ let stderr = ''
2168
+ let urlEmitted = false
2169
+
2170
+ const findAndEmitUrl = (text: string) => {
2171
+ if (urlEmitted) return
2172
+
2173
+ // Look for URL in output - cloudflared outputs URLs in various formats
2174
+ const urlPatterns = [
2175
+ /https:\/\/[^\s\)]+/g,
2176
+ /https:\/\/[^\n]+/g
2177
+ ]
2178
+
2179
+ for (const pattern of urlPatterns) {
2180
+ const matches = text.match(pattern)
2181
+ if (matches && matches.length > 0) {
2182
+ // Find the login URL (usually contains "cloudflareaccess.com" or similar)
2183
+ const loginUrl = matches.find(url =>
2184
+ url.includes('cloudflareaccess.com') ||
2185
+ url.includes('cloudflare.com') ||
2186
+ url.includes('trycloudflare.com') ||
2187
+ url.includes('dash.cloudflare.com')
2188
+ )
2189
+ if (loginUrl) {
2190
+ urlEmitted = true
2191
+ socket!.emit('cloudflared:login:url', { loginUrl: loginUrl.trim() })
2192
+ return
2193
+ }
2194
+ }
2195
+ }
2196
+ }
2197
+
2198
+ loginProcess.stdout.on('data', (data) => {
2199
+ const text = data.toString()
2200
+ stdout += text
2201
+ findAndEmitUrl(text)
2202
+ })
2203
+
2204
+ loginProcess.stderr.on('data', (data) => {
2205
+ const text = data.toString()
2206
+ stderr += text
2207
+ // cloudflared often outputs URLs to stderr
2208
+ findAndEmitUrl(text)
2209
+ })
2210
+
2211
+ loginProcess.on('close', async (code) => {
2212
+ if (code === 0) {
2213
+ // Login successful, extract API token from cert.pem
2214
+ try {
2215
+ const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem')
2216
+ const certContent = await fs.readFile(certPath, 'utf-8')
2217
+
2218
+ // Extract token between BEGIN and END markers
2219
+ const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/)
2220
+ if (tokenMatch && tokenMatch[1]) {
2221
+ // Decode base64 token
2222
+ const tokenBase64 = tokenMatch[1].replace(/\s/g, '')
2223
+ const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8')
2224
+ const tokenData = JSON.parse(tokenJson)
2225
+
2226
+ // Extract API token, account ID, and zone ID
2227
+ const apiToken = tokenData.apiToken
2228
+ const accountId = tokenData.accountID
2229
+ const zoneId = tokenData.zoneID
2230
+
2231
+ socket!.emit('cloudflared:login:complete', {
2232
+ accountId,
2233
+ accountName: undefined,
2234
+ apiToken,
2235
+ zoneId
2236
+ })
2237
+ } else {
2238
+ socket!.emit('cloudflared:login:error', {
2239
+ error: 'Failed to extract token from cert.pem'
2240
+ })
2241
+ }
2242
+ } catch (error: any) {
2243
+ socket!.emit('cloudflared:login:error', {
2244
+ error: `Failed to read cert.pem: ${error.message}`
2245
+ })
2246
+ }
2247
+ } else {
2248
+ socket!.emit('cloudflared:login:error', {
2249
+ error: `Login failed with code ${code}: ${stderr}`
2250
+ })
2251
+ }
2252
+ })
2253
+
2254
+ loginProcess.on('error', (error) => {
2255
+ socket!.emit('cloudflared:login:error', { error: error.message })
2256
+ })
2257
+ } catch (error: any) {
2258
+ socket!.emit('cloudflared:login:error', { error: error.message })
2259
+ }
2260
+ })
2261
+
2262
+ // ========== Container Management Handlers ==========
2263
+
2264
+ // Check if container runtime is available
2265
+ socket.on('container:check:request', async () => {
2266
+ try {
2267
+ let enabled = false
2268
+ let runtime: 'docker' | 'podman' | undefined
2269
+ let version = ''
2270
+
2271
+ try {
2272
+ // Check for docker first
2273
+ execSync('which docker', { stdio: 'ignore' })
2274
+ runtime = 'docker'
2275
+ version = execSync('docker --version', { encoding: 'utf-8' }).trim()
2276
+ enabled = true
2277
+ } catch {
2278
+ try {
2279
+ // Check for podman as fallback
2280
+ execSync('which podman', { stdio: 'ignore' })
2281
+ runtime = 'podman'
2282
+ version = execSync('podman --version', { encoding: 'utf-8' }).trim()
2283
+ enabled = true
2284
+ } catch {
2285
+ enabled = false
2286
+ }
2287
+ }
2288
+
2289
+ socket!.emit('container:check:response', { enabled, runtime, version })
2290
+ if (foreground) {
2291
+ console.log(`Container runtime check: ${enabled ? `${runtime} (${version})` : 'Not found'}`)
2292
+ }
2293
+ } catch (error: any) {
2294
+ socket!.emit('container:check:response', { enabled: false, runtime: undefined, version: '' })
2295
+ }
2296
+ })
2297
+
2298
+ // List all containers
2299
+ socket.on('container:list:request', async () => {
2300
+ try {
2301
+ const runtime = getContainerRuntime()
2302
+ if (!runtime) {
2303
+ socket!.emit('container:list:response', { success: false, error: 'No container runtime found' })
2304
+ return
2305
+ }
2306
+
2307
+ // List all containers including stopped ones
2308
+ const output = execSync(
2309
+ `${runtime} ps -a --format "{{json .}}"`,
2310
+ { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
2311
+ )
2312
+
2313
+ const containers = output.trim().split('\n').filter(Boolean).map(line => {
2314
+ try {
2315
+ const c = JSON.parse(line)
2316
+ // Parse ports from the PORTS column (format: "0.0.0.0:8080->80/tcp, 0.0.0.0:9090->9090/tcp")
2317
+ const ports: Array<{ host: number; container: number }> = []
2318
+ if (c.Ports) {
2319
+ const portMatches = c.Ports.match(/(\d+)->(\d+)/g)
2320
+ if (portMatches) {
2321
+ portMatches.forEach((p: string) => {
2322
+ const [host, container] = p.split('->').map(Number)
2323
+ ports.push({ host, container })
2324
+ })
2325
+ }
2326
+ }
2327
+ return {
2328
+ containerId: c.ID,
2329
+ name: c.Names.replace(/^\//, ''), // Remove leading slash
2330
+ image: c.Image,
2331
+ status: c.State === 'running' ? 'running' : c.State === 'paused' ? 'paused' : c.Status === 'exited' ? 'exited' : 'stopped',
2332
+ ports,
2333
+ createdAt: new Date(c.CreatedAt).toISOString()
2334
+ }
2335
+ } catch {
2336
+ return null
2337
+ }
2338
+ }).filter(Boolean)
2339
+
2340
+ socket!.emit('container:list:response', { success: true, containers })
2341
+ } catch (error: any) {
2342
+ socket!.emit('container:list:response', { success: false, error: error.message })
2343
+ }
2344
+ })
2345
+
2346
+ // Start a new container
2347
+ socket.on('container:start:request', async (data: {
2348
+ name: string
2349
+ image: string
2350
+ ports?: Array<{ host: number; container: number }>
2351
+ env?: Record<string, string>
2352
+ runAsRoot?: boolean // Allow running as root inside container (safe, still isolated)
2353
+ }) => {
2354
+ try {
2355
+ const runtime = getContainerRuntime()
2356
+ if (!runtime) {
2357
+ socket!.emit('container:start:response', { success: false, error: 'No container runtime found' })
2358
+ return
2359
+ }
2360
+
2361
+ const { name, image, ports = [], env = {}, runAsRoot = false } = data
2362
+
2363
+ // Get CLI directory path (where this script is running from)
2364
+ // Use the same pattern as elsewhere in the file
2365
+ const cliDir = path.dirname(CURRENT_FILE)
2366
+
2367
+ // Build docker run command
2368
+ let cmd = `${runtime} run -d --name ${name}`
2369
+
2370
+ // Security: Running as root INSIDE container is safe - it's still isolated from host
2371
+ // Root inside container cannot access host filesystem (read-only mounts)
2372
+ // Default to root to allow package installation and full container functionality
2373
+ // Set runAsRoot: false to run as non-root user (UID 1000) if needed
2374
+ if (!runAsRoot) {
2375
+ cmd += ' --user 1000:1000'
2376
+ }
2377
+
2378
+ // Resource limits
2379
+ cmd += ' --memory=8g' // Limit memory to 8GB
2380
+
2381
+ // Auto-remove on exit (optional - commented out for persistence)
2382
+ // cmd += ' --rm'
2383
+
2384
+ // Mount CLI directory into container
2385
+ cmd += ` -v "${cliDir}:/opt/ttc:ro"`
2386
+
2387
+ // Mount entrypoint script
2388
+ const entrypointScript = path.join(cliDir, 'container-entrypoint.sh')
2389
+ cmd += ` -v "${entrypointScript}:/entrypoint.sh:ro"`
2390
+
2391
+ // Port mappings
2392
+ ports.forEach(p => {
2393
+ cmd += ` -p ${p.host}:${p.container}`
2394
+ })
2395
+
2396
+ // Environment variables
2397
+ Object.entries(env).forEach(([k, v]) => {
2398
+ // Escape quotes in env values
2399
+ const escapedValue = String(v).replace(/"/g, '\\"')
2400
+ cmd += ` -e ${k}="${escapedValue}"`
2401
+ })
2402
+
2403
+ // Add container-specific environment variables
2404
+ cmd += ` -e CONTAINER_NAME="${name}"`
2405
+ cmd += ` -e HOSTNAME="${name}"`
2406
+
2407
+ // Use entrypoint script
2408
+ cmd += ` --entrypoint /bin/sh ${image} /entrypoint.sh`
2409
+
2410
+ if (foreground) {
2411
+ console.log(`Starting container: ${cmd}`)
2412
+ }
2413
+
2414
+ // Pull image if not exists, then run
2415
+ try {
2416
+ execSync(`${runtime} pull ${image}`, { stdio: 'ignore' })
2417
+ } catch {
2418
+ // Pull failed, but might already exist locally
2419
+ }
2420
+
2421
+ const output = execSync(cmd, { encoding: 'utf-8', stdio: 'pipe' })
2422
+ const containerId = output.trim()
2423
+
2424
+ socket!.emit('container:start:response', { success: true, containerId })
2425
+
2426
+ if (foreground) {
2427
+ console.log(`✓ Container started: ${containerId}`)
2428
+ }
2429
+ } catch (error: any) {
2430
+ socket!.emit('container:start:response', { success: false, error: error.message })
2431
+ }
2432
+ })
2433
+
2434
+ // Stop a container
2435
+ socket.on('container:stop:request', async (data: { containerId: string }) => {
2436
+ try {
2437
+ const runtime = getContainerRuntime()
2438
+ if (!runtime) {
2439
+ socket!.emit('container:stop:response', { success: false, error: 'No container runtime found' })
2440
+ return
2441
+ }
2442
+
2443
+ const { containerId } = data
2444
+
2445
+ execSync(`${runtime} stop ${containerId}`, { stdio: 'ignore' })
2446
+
2447
+ socket!.emit('container:stop:response', { success: true })
2448
+
2449
+ if (foreground) {
2450
+ console.log(`✓ Container stopped: ${containerId}`)
2451
+ }
2452
+ } catch (error: any) {
2453
+ socket!.emit('container:stop:response', { success: false, error: error.message })
2454
+ }
2455
+ })
2456
+
2457
+ // Remove a container
2458
+ socket.on('container:remove:request', async (data: { containerId: string }) => {
2459
+ try {
2460
+ const runtime = getContainerRuntime()
2461
+ if (!runtime) {
2462
+ socket!.emit('container:remove:response', { success: false, error: 'No container runtime found' })
2463
+ return
2464
+ }
2465
+
2466
+ const { containerId } = data
2467
+
2468
+ // Force remove even if running
2469
+ execSync(`${runtime} rm -f ${containerId}`, { stdio: 'ignore' })
2470
+
2471
+ socket!.emit('container:remove:response', { success: true })
2472
+
2473
+ if (foreground) {
2474
+ console.log(`✓ Container removed: ${containerId}`)
2475
+ }
2476
+ } catch (error: any) {
2477
+ socket!.emit('container:remove:response', { success: false, error: error.message })
2478
+ }
2479
+ })
2480
+
2481
+ // Get container logs
2482
+ socket.on('container:logs:request', async (data: { containerId: string; lines?: number }) => {
2483
+ try {
2484
+ const runtime = getContainerRuntime()
2485
+ if (!runtime) {
2486
+ socket!.emit('container:logs:response', { success: false, error: 'No container runtime found' })
2487
+ return
2488
+ }
2489
+
2490
+ const { containerId, lines = 100 } = data
2491
+
2492
+ const logs = execSync(
2493
+ `${runtime} logs --tail ${lines} ${containerId}`,
2494
+ { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 }
2495
+ )
2496
+
2497
+ socket!.emit('container:logs:response', { success: true, logs })
2498
+ } catch (error: any) {
2499
+ socket!.emit('container:logs:response', { success: false, error: error.message })
2500
+ }
2501
+ })
2502
+
2503
+ // Helper function to get available container runtime
2504
+ function getContainerRuntime(): 'docker' | 'podman' | null {
2505
+ try {
2506
+ execSync('which docker', { stdio: 'ignore' })
2507
+ return 'docker'
2508
+ } catch {
2509
+ try {
2510
+ execSync('which podman', { stdio: 'ignore' })
2511
+ return 'podman'
2512
+ } catch {
2513
+ return null
2514
+ }
2515
+ }
2516
+ }
2517
+
2518
+ socket.on('disconnect', (reason) => {
2519
+ if (foreground) {
2520
+ console.log(`\n⚠️ Disconnected: ${reason}`)
2521
+ console.log(' Attempting to reconnect...')
2522
+ } else {
2523
+ console.log(`Disconnected: ${reason}`)
2524
+ }
2525
+
2526
+ // Clear heartbeat interval
2527
+ if ((socket as any).heartbeatInterval) {
2528
+ clearInterval((socket as any).heartbeatInterval)
2529
+ }
2530
+
2531
+ if (reason === 'io server disconnect') {
2532
+ // Server disconnected, reconnect manually
2533
+ socket!.connect()
2534
+ } else {
2535
+ // Client disconnect or transport close: schedule full reconnect so process stays alive
2536
+ if (reconnectTimeout) clearTimeout(reconnectTimeout)
2537
+ const delay = 2000
2538
+ if (!foreground) console.log(`Reconnecting in ${delay}ms...`)
2539
+ reconnectTimeout = setTimeout(connectAndRegister, delay)
2540
+ }
2541
+ })
2542
+
2543
+ socket.on('connect_error', (err: Error & { type?: string; description?: string; cause?: any }) => {
2544
+ reconnectAttempts++
2545
+ if (foreground) {
2546
+ console.error(`✗ Connection error (attempt ${reconnectAttempts}): ${err.message}`)
2547
+ if (err.type) {
2548
+ console.error(` Error type: ${err.type}`)
2549
+ }
2550
+ if (err.description) {
2551
+ console.error(` Error description: ${err.description}`)
2552
+ }
2553
+ console.error(` Connecting to: ${config.apiUrl}`)
2554
+ if (err.cause) {
2555
+ console.error(` Cause:`, err.cause)
2556
+ }
2557
+ } else {
2558
+ console.error(`Connection error (attempt ${reconnectAttempts}):`, err.message)
2559
+ }
2560
+ })
2561
+
2562
+ // Keep process alive
2563
+ socket.on('error', (err) => {
2564
+ if (foreground) {
2565
+ console.error(`✗ Socket error: ${err}`)
2566
+ } else {
2567
+ console.error('Socket error:', err)
2568
+ }
2569
+ })
2570
+
2571
+ } catch (error) {
2572
+ if (foreground) {
2573
+ console.error(`✗ Failed to connect: ${(error as Error).message}`)
2574
+ } else {
2575
+ console.error('Failed to connect:', (error as Error).message)
2576
+ }
2577
+ reconnectAttempts++
2578
+
2579
+ if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
2580
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
2581
+ if (foreground) {
2582
+ console.log(`⏳ Reconnecting in ${delay}ms...`)
2583
+ } else {
2584
+ console.log(`Reconnecting in ${delay}ms...`)
2585
+ }
2586
+ reconnectTimeout = setTimeout(connectAndRegister, delay)
2587
+ }
2588
+ }
2589
+ }
2590
+
2591
+ // Handle graceful shutdown
2592
+ const shutdown = () => {
2593
+ if (foreground) {
2594
+ console.log('\n\n🛑 Shutting down gracefully...')
2595
+ } else {
2596
+ console.log('\nShutting down...')
2597
+ }
2598
+ if (reconnectTimeout) {
2599
+ clearTimeout(reconnectTimeout)
2600
+ }
2601
+ if (socket) {
2602
+ // Clear heartbeat interval
2603
+ if ((socket as any).heartbeatInterval) {
2604
+ clearInterval((socket as any).heartbeatInterval)
2605
+ }
2606
+ socket.disconnect()
2607
+ }
2608
+ process.exit(0)
2609
+ }
2610
+
2611
+ process.on('SIGTERM', shutdown)
2612
+ process.on('SIGINT', shutdown)
2613
+ process.on('SIGHUP', shutdown)
2614
+
2615
+ // Start connection
2616
+ await connectAndRegister()
2617
+
2618
+ // Keep process alive
2619
+ process.stdin.resume()
2620
+ }
2621
+
2622
+ // ============ CLI Program ============
2623
+
2624
+ const program = new Command()
2625
+
2626
+ program
2627
+ .name('exk')
2628
+ .description('exk - Control Claude CLI with voice and programmable interfaces')
2629
+ .version('1.0.0')
2630
+
2631
+ // Config command
2632
+ program
2633
+ .command('config')
2634
+ .description('Get or set config (api-url)')
2635
+ .option('--api-url <url>', 'API base URL')
2636
+ .action(async (opts: { apiUrl?: string }) => {
2637
+ const config = await readConfig()
2638
+ if (opts.apiUrl !== undefined) {
2639
+ config.apiUrl = opts.apiUrl
2640
+ await writeConfig(config)
2641
+ console.log('Set api-url:', config.apiUrl)
2642
+ } else {
2643
+ console.log('Config:', JSON.stringify(config, null, 2))
2644
+ }
2645
+ process.exit(0)
2646
+ })
2647
+
2648
+ // Register command
2649
+ program
2650
+ .command('register')
2651
+ .description('Register this device and install as persistent daemon')
2652
+ .argument('[email]', 'Email address for device approval')
2653
+ .action(async (email: string | undefined) => {
2654
+ try {
2655
+ // First register the device
2656
+ await registerDevice(undefined, email)
2657
+
2658
+ // After successful registration, install as service (Linux only)
2659
+ if (process.platform === 'win32') {
2660
+ console.log('\n✓ Device registered.')
2661
+ console.log('On Windows, run the daemon manually: exk daemon')
2662
+ } else {
2663
+ console.log('\n📦 Installing exk daemon with pm2...')
2664
+ // install-service.sh: bundled at __dirname (npm package root)
2665
+ let installScript = path.join(__dirname, 'install-service.sh')
2666
+ const scriptDir = path.dirname(installScript)
2667
+
2668
+ // Determine paths for the npm-installed package
2669
+ const pkgDir = path.resolve(__dirname)
2670
+ const exkBin = process.argv[1] || 'exk'
2671
+
2672
+ try {
2673
+ await fs.access(installScript)
2674
+ const { execSync } = await import('child_process')
2675
+ execSync(`bash "${installScript}"`, {
2676
+ stdio: 'inherit',
2677
+ cwd: scriptDir,
2678
+ env: {
2679
+ ...process.env,
2680
+ INSTALL_DIR: CONFIG_DIR,
2681
+ EXK_PKG_DIR: pkgDir,
2682
+ EXK_BIN: exkBin,
2683
+ BINARY_PATH: exkBin
2684
+ }
2685
+ })
2686
+ console.log('\n✓ exk installation complete!')
2687
+ console.log('The service is now running in the background.')
2688
+ } catch (scriptErr: unknown) {
2689
+ const err = scriptErr as Error
2690
+ console.log('\n⚠ Service installation had issues, but device is registered.')
2691
+ const execErr = err as { stderr?: Buffer; stdout?: Buffer }
2692
+ const errDetail = execErr.stderr?.toString() || execErr.stdout?.toString() || err.message
2693
+ if (errDetail) console.error('Error:', errDetail)
2694
+ console.log('You can start the daemon manually with: exk daemon')
2695
+ }
2696
+ }
2697
+ } catch (error) {
2698
+ const err = error as Error
2699
+ const errorMsg = err.message || String(error)
2700
+ if (errorMsg.includes('approval') || errorMsg.includes('email')) {
2701
+ // Registration failed - exit with error
2702
+ process.exit(1)
2703
+ }
2704
+ // Service installation failed, but device is registered
2705
+ console.log('\n⚠ Service installation had issues, but device is registered.')
2706
+ console.error('Error:', (err as { stderr?: Buffer }).stderr?.toString() || errorMsg)
2707
+ console.log('You can start the daemon manually with: exk daemon')
2708
+ }
2709
+
2710
+ // Exit after install is complete
2711
+ process.exit(0)
2712
+ })
2713
+
2714
+ // Uninstall command
2715
+ program
2716
+ .command('uninstall')
2717
+ .description('Uninstall exk daemon (removes pm2 service and config)')
2718
+ .action(async () => {
2719
+ console.log('Uninstalling exk daemon...')
2720
+
2721
+ if (process.platform === 'win32') {
2722
+ console.log('On Windows there is no service to remove.')
2723
+ console.log('To remove exk completely: npm uninstall -g exk')
2724
+ } else {
2725
+ let installScript = path.join(__dirname, 'install-service.sh')
2726
+ const scriptDir = path.dirname(installScript)
2727
+ try {
2728
+ await fs.access(installScript)
2729
+ const { execSync } = await import('child_process')
2730
+ execSync(`bash "${installScript}" --uninstall`, {
2731
+ stdio: 'inherit',
2732
+ cwd: scriptDir,
2733
+ env: { ...process.env, PATH: process.env.PATH }
2734
+ })
2735
+ console.log('\n✓ Service uninstalled!')
2736
+ console.log('To remove the package completely: npm uninstall -g exk')
2737
+ } catch (error) {
2738
+ console.log('\n⚠ Service uninstall script not found or failed.')
2739
+ console.log('To remove exk: npm uninstall -g exk')
2740
+ console.log(' rm -rf ~/.talk-to-code')
2741
+ }
2742
+ }
2743
+
2744
+ // Exit after uninstall is complete
2745
+ process.exit(0)
2746
+ })
2747
+
2748
+ // Update command
2749
+ program
2750
+ .command('update')
2751
+ .description('Check for and apply CLI updates')
2752
+ .option('-f, --force', 'Update without confirmation')
2753
+ .action(async (options: { force?: boolean }) => {
2754
+ await selfUpdate(options.force)
2755
+ process.exit(0)
2756
+ })
2757
+
2758
+ program
2759
+ .command('check-update')
2760
+ .description('Check for updates')
2761
+ .action(async () => {
2762
+ const info = await checkForUpdate()
2763
+ if (!info) process.exit(1)
2764
+ if (!info.updateAvailable) {
2765
+ console.log('✓ Up to date')
2766
+ process.exit(0)
2767
+ }
2768
+ console.log('📦 Update available')
2769
+ console.log('Run "exk update" to install')
2770
+ process.exit(0)
2771
+ })
2772
+
2773
+ // Run (connect to backend and process prompts)
2774
+ program
2775
+ .command('run')
2776
+ .description('Run CLI and connect to backend')
2777
+ .option('--email <email>', 'Email for device approval')
2778
+ .action((options: { email?: string }) => {
2779
+ runDaemon(false, options.email).catch((error) => {
2780
+ console.error('Run error:', (error as Error).message)
2781
+ process.exit(1)
2782
+ })
2783
+ })
2784
+
2785
+ // Hidden daemon command (internal alias)
2786
+ program
2787
+ .command('daemon', { hidden: true })
2788
+ .description('Run daemon (internal)')
2789
+ .option('--foreground', 'Run in foreground', false)
2790
+ .option('--email <email>', 'Email address')
2791
+ .action((options: { foreground?: boolean; email?: string }) => {
2792
+ runDaemon(options.foreground, options.email).catch((error) => {
2793
+ console.error('Daemon error:', (error as Error).message)
2794
+ process.exit(1)
2795
+ })
2796
+ })
2797
+
2798
+ program.parse()