@exreve/exk 1.0.6 → 1.0.8

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