@exreve/exk 1.0.7 → 1.0.8

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