@exreve/exk 1.0.26 → 1.0.28

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