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