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