@exreve/exk 1.0.44 → 1.0.46

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 CHANGED
@@ -9,11 +9,13 @@ import os, { networkInterfaces } from 'os';
9
9
  import { Buffer } from 'buffer';
10
10
  import crypto from 'crypto';
11
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
12
  import { startSending, startReceiving } from './transferService.js';
13
+ import { registerGitHubHandlers } from './githubHandlers.js';
14
+ import { registerFsHandlers } from './fsHandlers.js';
15
+ import { registerAppHandlers } from './appHandlers.js';
16
+ import { registerSessionHandlers } from './sessionHandlers.js';
17
+ import { registerContainerHandlers } from './containerHandlers.js';
18
+ import { registerCloudflaredHandlers } from './cloudflaredHandlers.js';
17
19
  import { spawn, execSync } from 'child_process';
18
20
  import readline from 'readline';
19
21
  import { fileURLToPath } from 'url';
@@ -68,25 +70,33 @@ async function fetchAiConfig(authToken) {
68
70
  }
69
71
  const AI_CONFIG_FILE = path.join(CONFIG_DIR, 'ai-config.json');
70
72
  const PROXY_CONFIG_FILE = path.join(CONFIG_DIR, 'proxy.json');
71
- /** Read proxy toggle state from disk */
72
- async function readProxyConfig() {
73
- try {
74
- const raw = await fs.readFile(PROXY_CONFIG_FILE, 'utf-8');
75
- return JSON.parse(raw);
76
- }
77
- catch {
78
- return { enabled: false };
79
- }
80
- }
81
73
  /** Write proxy toggle state to disk */
82
74
  async function writeProxyConfig(cfg) {
83
75
  await fs.mkdir(CONFIG_DIR, { recursive: true });
84
76
  await fs.writeFile(PROXY_CONFIG_FILE, JSON.stringify(cfg, null, 2));
85
77
  }
78
+ /** TTL cache for ai-config.json reads (shared by getProxyUrl / hasAiCredentials) */
79
+ let _aiCfgCache = null;
80
+ const _AI_CFG_TTL = 5_000;
81
+ function readAiConfigCached() {
82
+ const now = Date.now();
83
+ if (_aiCfgCache && (now - _aiCfgCache.ts) < _AI_CFG_TTL)
84
+ return _aiCfgCache.raw;
85
+ try {
86
+ const raw = fsSync.readFileSync(AI_CONFIG_FILE, 'utf-8');
87
+ _aiCfgCache = { raw, ts: now };
88
+ return raw;
89
+ }
90
+ catch {
91
+ return '';
92
+ }
93
+ }
86
94
  /** Get the proxy URL from ai-config.json (saved from backend) */
87
95
  function getProxyUrl() {
88
96
  try {
89
- const raw = fsSync.readFileSync(AI_CONFIG_FILE, 'utf-8');
97
+ const raw = readAiConfigCached();
98
+ if (!raw)
99
+ return '';
90
100
  const j = JSON.parse(raw);
91
101
  return typeof j.proxy === 'string' ? j.proxy.trim() : '';
92
102
  }
@@ -97,7 +107,9 @@ function getProxyUrl() {
97
107
  /** True if ai-config.json has a model API key (not read from host ANTHROPIC_* env). */
98
108
  function hasAiCredentials() {
99
109
  try {
100
- const raw = fsSync.readFileSync(AI_CONFIG_FILE, 'utf-8');
110
+ const raw = readAiConfigCached();
111
+ if (!raw)
112
+ return false;
101
113
  const j = JSON.parse(raw);
102
114
  return typeof j.authToken === 'string' && j.authToken.trim().length > 0;
103
115
  }
@@ -249,76 +261,6 @@ async function checkForUpdate() {
249
261
  return null;
250
262
  }
251
263
  }
252
- async function replaceSelf(tarballBuffer) {
253
- const extractDir = path.join(os.tmpdir(), `ttc-update-${Date.now()}`);
254
- await fs.mkdir(extractDir, { recursive: true });
255
- const tarPath = path.join(extractDir, 'ttc-cli.tar.gz');
256
- await fs.writeFile(tarPath, tarballBuffer);
257
- execSync(`tar -xzf "${tarPath}" -C "${extractDir}"`);
258
- await fs.unlink(tarPath);
259
- // Preserve user config/token files (never overwrite on update)
260
- const preserveFiles = ['device-config.json', 'device-id.json', 'config.json'];
261
- const preserved = {};
262
- for (const f of preserveFiles) {
263
- try {
264
- preserved[f] = await fs.readFile(path.join(CONFIG_DIR, f));
265
- }
266
- catch {
267
- /* file may not exist */
268
- }
269
- }
270
- // Replace cli/ and shared/ in CONFIG_DIR
271
- const cliDest = path.join(CONFIG_DIR, 'cli');
272
- const sharedDest = path.join(CONFIG_DIR, 'shared');
273
- await fs.rm(cliDest, { recursive: true, force: true });
274
- await fs.rm(sharedDest, { recursive: true, force: true });
275
- await fs.cp(path.join(extractDir, 'cli'), cliDest, { recursive: true });
276
- await fs.cp(path.join(extractDir, 'shared'), sharedDest, { recursive: true });
277
- // Update package.json and npm install
278
- await fs.copyFile(path.join(extractDir, 'package.json'), path.join(CONFIG_DIR, 'package.json'));
279
- await fs.rm(extractDir, { recursive: true, force: true });
280
- // Restore preserved config
281
- for (const f of preserveFiles) {
282
- if (preserved[f])
283
- await fs.writeFile(path.join(CONFIG_DIR, f), preserved[f]);
284
- }
285
- console.log('✓ CLI updated');
286
- const npmPaths = [path.join(os.homedir(), '.nvm/versions/node/v22/bin/npm'), path.join(os.homedir(), '.nvm/versions/node/v20/bin/npm')];
287
- const npmBin = npmPaths.find(p => fsSync.existsSync(p)) || 'npm';
288
- try {
289
- execSync(`"${npmBin}" install --omit=dev`, { cwd: CONFIG_DIR, stdio: 'inherit' });
290
- console.log('✓ Dependencies updated');
291
- }
292
- catch {
293
- console.warn('⚠ npm install failed');
294
- }
295
- }
296
- async function selfUpdate(force = false) {
297
- const info = await checkForUpdate();
298
- if (!info || !info.updateAvailable) {
299
- console.log('✓ Already up to date');
300
- return;
301
- }
302
- console.log('📦 Update available!');
303
- if (!force && process.stdin.isTTY) {
304
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
305
- const answer = await new Promise(r => rl.question('Update now? [Y/n] ', a => { rl.close(); r(a.trim()); }));
306
- if (answer.toLowerCase().startsWith('n'))
307
- return;
308
- }
309
- if (!info.downloadUrl || !info.hash)
310
- return;
311
- console.log('Downloading...');
312
- const res = await fetch(info.downloadUrl);
313
- if (!res.ok)
314
- throw new Error('Download failed');
315
- const bundle = Buffer.from(await res.arrayBuffer());
316
- if (createHash('sha256').update(bundle).digest('hex') !== info.hash) {
317
- throw new Error('Hash mismatch');
318
- }
319
- await replaceSelf(bundle);
320
- process.exit(0);
321
- }
322
264
  // ============ Commands ============
323
265
  async function registerDevice(name, email) {
324
266
  try {
@@ -480,136 +422,6 @@ async function registerDevice(name, email) {
480
422
  throw error;
481
423
  }
482
424
  }
483
- async function listSessions() {
484
- try {
485
- const socket = await connect();
486
- socket.emit('sessions:list', (response) => {
487
- const { sessions } = response;
488
- console.log('\nSessions:');
489
- console.log('-'.repeat(60));
490
- sessions.forEach((session, idx) => {
491
- console.log(`${idx + 1}. ${session.id}`);
492
- console.log(` Created: ${new Date(session.createdAt).toLocaleString()}`);
493
- console.log();
494
- });
495
- socket.disconnect();
496
- });
497
- }
498
- catch (error) {
499
- console.error('Failed to list sessions:', error.message);
500
- process.exit(1);
501
- }
502
- }
503
- async function createSession() {
504
- try {
505
- const socket = await connect();
506
- socket.emit('session:create', (response) => {
507
- const { success, session } = response;
508
- if (success && session) {
509
- console.log('Session created!');
510
- console.log('Session ID:', session.id);
511
- socket.disconnect();
512
- }
513
- });
514
- }
515
- catch (error) {
516
- console.error('Failed to create session:', error.message);
517
- process.exit(1);
518
- }
519
- }
520
- async function sendPrompt(sessionId, prompt) {
521
- try {
522
- const socket = await connect();
523
- socket.emit('session:subscribe', { sessionId });
524
- socket.on('prompt:output', ({ data, type }) => {
525
- if (type === 'stdout') {
526
- process.stdout.write(data);
527
- }
528
- else {
529
- process.stderr.write(data);
530
- }
531
- });
532
- socket.on('session:exited', ({ exitCode }) => {
533
- console.log(`\nSession exited with code ${exitCode}`);
534
- socket.disconnect();
535
- });
536
- socket.emit('session:prompt', { sessionId, prompt }, ({ success }) => {
537
- if (!success) {
538
- console.error('Failed to send prompt');
539
- socket.disconnect();
540
- }
541
- });
542
- process.on('SIGINT', () => {
543
- socket.emit('session:unsubscribe', { sessionId });
544
- socket.disconnect();
545
- process.exit(0);
546
- });
547
- }
548
- catch (error) {
549
- console.error('Failed to send prompt:', error.message);
550
- process.exit(1);
551
- }
552
- }
553
- async function listDevices() {
554
- try {
555
- const socket = await connect();
556
- socket.emit('devices:list', (response) => {
557
- const { devices } = response;
558
- console.log('\nRegistered Devices:');
559
- console.log('-'.repeat(60));
560
- devices.forEach((device, idx) => {
561
- console.log(`${idx + 1}. ${device.name}`);
562
- console.log(` ID: ${device.deviceId}`);
563
- console.log(` IP Address: ${device.ipAddress || 'Unknown'}`);
564
- console.log(` Hostname: ${device.hostname || 'Unknown'}`);
565
- console.log(` Registered: ${new Date(device.registeredAt).toLocaleString()}`);
566
- console.log(` Last Seen: ${new Date(device.lastSeen).toLocaleString()}`);
567
- console.log();
568
- });
569
- socket.disconnect();
570
- });
571
- }
572
- catch (error) {
573
- console.error('Failed to list devices:', error.message);
574
- process.exit(1);
575
- }
576
- }
577
- async function monitorSession(sessionId) {
578
- try {
579
- const socket = await connect();
580
- console.log(`Monitoring session ${sessionId}...`);
581
- console.log('Press Ctrl+C to stop\n');
582
- socket.emit('session:subscribe', { sessionId });
583
- socket.on('session:state', ({ outputs, isActive }) => {
584
- console.log(`Session active: ${isActive}`);
585
- console.log('Recent output:');
586
- outputs.slice(-10).forEach(({ type, data }) => {
587
- if (type === 'stdout') {
588
- const dataString = typeof data === 'string' ? data : JSON.stringify(data);
589
- process.stdout.write(dataString);
590
- }
591
- });
592
- });
593
- socket.on('prompt:output', ({ data, type }) => {
594
- if (type === 'stdout') {
595
- process.stdout.write(data);
596
- }
597
- });
598
- socket.on('session:exited', ({ exitCode }) => {
599
- console.log(`\nSession exited with code ${exitCode}`);
600
- socket.disconnect();
601
- });
602
- process.on('SIGINT', () => {
603
- socket.emit('session:unsubscribe', { sessionId });
604
- socket.disconnect();
605
- process.exit(0);
606
- });
607
- }
608
- catch (error) {
609
- console.error('Failed to monitor session:', error.message);
610
- process.exit(1);
611
- }
612
- }
613
425
  // Active sessions map - persists across reconnections
614
426
  const activeSessions = new Map();
615
427
  async function runDaemon(foreground = false, email) {
@@ -654,6 +466,12 @@ async function runDaemon(foreground = false, email) {
654
466
  const MAX_RECONNECT_ATTEMPTS = Infinity; // Keep trying forever
655
467
  const connectAndRegister = async () => {
656
468
  try {
469
+ // Destroy any existing socket to prevent zombie connections
470
+ if (socket) {
471
+ socket.removeAllListeners();
472
+ socket.disconnect();
473
+ socket = null;
474
+ }
657
475
  socket = io(config.apiUrl, {
658
476
  transports: ['websocket', 'polling'],
659
477
  reconnection: true,
@@ -739,894 +557,162 @@ async function runDaemon(foreground = false, email) {
739
557
  callback?.({ success: false, error: error.message });
740
558
  }
741
559
  });
742
- // GitHub handlers
743
- socket.on('github:status', async (data, callback) => {
560
+ // GitHub handlers (delegated to githubHandlers module for proper response format)
561
+ registerGitHubHandlers(socket, foreground);
562
+ // Filesystem handlers (delegated to fsHandlers module)
563
+ registerFsHandlers(socket);
564
+ // Session handlers (delegated to sessionHandlers module)
565
+ registerSessionHandlers(socket, foreground, activeSessions, () => socket);
566
+ // App control handlers (delegated to appHandlers module)
567
+ registerAppHandlers(socket, foreground);
568
+ // Handle image save request - saves base64 images to tmp directory
569
+ socket.on('image:save', async (data) => {
744
570
  try {
745
- const { projectPath } = data;
746
- // Check if directory is a git repository
747
- const gitDir = path.join(projectPath, '.git');
748
- let isRepo = false;
749
- try {
750
- const stats = await fs.stat(gitDir);
751
- isRepo = stats.isDirectory();
752
- }
753
- catch {
754
- isRepo = false;
571
+ const { images, saveCallbackId, projectPath } = data;
572
+ if (foreground) {
573
+ console.log(`[CLI] 🖼️ Received request to save ${images.length} image(s)`);
755
574
  }
756
- if (!isRepo) {
757
- callback?.({ success: true, isRepo: false, hasChanges: false });
758
- return;
575
+ // Use project-specific tmp directory (inside the project)
576
+ // If projectPath is provided, use it; otherwise fall back to current working directory
577
+ const basePath = projectPath || process.cwd();
578
+ const tmpDir = path.join(basePath, 'tmp');
579
+ await fs.mkdir(tmpDir, { recursive: true });
580
+ if (foreground) {
581
+ console.log(`[CLI] 📁 Saving to directory: ${tmpDir}`);
759
582
  }
760
- // Check git status
761
- const { execSync } = await import('child_process');
762
- try {
763
- // Get current branch
764
- const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim();
765
- // Get remote URL
766
- let remote;
767
- try {
768
- remote = execSync('git config --get remote.origin.url', { cwd: projectPath, encoding: 'utf-8' }).trim();
769
- }
770
- catch {
771
- // No remote configured
772
- }
773
- // Check for actual changes (modified, staged, deleted files - ignore untracked)
774
- // Use --short to get concise output, and check for actual changes (not just untracked files)
775
- let hasChanges = false;
776
- const changedFiles = [];
583
+ let savedCount = 0;
584
+ const errors = [];
585
+ for (const image of images) {
777
586
  try {
778
- const statusOutput = execSync('git status --porcelain', { cwd: projectPath, encoding: 'utf-8' });
779
- const lines = statusOutput.trim().split('\n').filter(line => line.trim().length > 0);
780
- for (const line of lines) {
781
- const status = line.substring(0, 2).trim();
782
- const filePath = line.substring(2).trim();
783
- // Skip untracked files (??)
784
- if (line.startsWith('??'))
785
- continue;
786
- // Include modified (M), added (A), deleted (D), renamed (R), copied (C)
787
- if (status.includes('M') || status.includes('A') || status.includes('D') || status.includes('R') || status.includes('C')) {
788
- hasChanges = true;
789
- changedFiles.push({ path: filePath, status });
790
- }
587
+ const imagePath = path.join(tmpDir, image.name);
588
+ // Convert base64 to buffer and write to file
589
+ const buffer = Buffer.from(image.data, 'base64');
590
+ await fs.writeFile(imagePath, buffer);
591
+ if (foreground) {
592
+ console.log(`[CLI] Saved image: ${imagePath}`);
791
593
  }
594
+ savedCount++;
792
595
  }
793
- catch {
794
- hasChanges = false;
596
+ catch (saveError) {
597
+ const errorMsg = `Failed to save ${image.name}: ${saveError.message}`;
598
+ errors.push(errorMsg);
599
+ if (foreground) {
600
+ console.error(`[CLI] ✗ ${errorMsg}`);
601
+ }
795
602
  }
796
- callback?.({ success: true, isRepo: true, hasChanges, branch, remote, changedFiles, changesCount: changedFiles.length });
797
603
  }
798
- catch (error) {
799
- callback?.({ success: false, error: error.message });
604
+ // Send response back via app:control:response
605
+ console.log(`[CLI] Sending app:control:response with appControlId=${saveCallbackId}, success=${errors.length === 0}, savedCount=${savedCount}`);
606
+ socket.emit('app:control:response', {
607
+ appControlId: saveCallbackId,
608
+ success: errors.length === 0,
609
+ savedCount,
610
+ error: errors.length > 0 ? errors.join('; ') : undefined
611
+ });
612
+ console.log(`[CLI] Emitted app:control:response`);
613
+ if (foreground) {
614
+ if (errors.length > 0) {
615
+ console.log(`[CLI] ⚠️ Saved ${savedCount}/${images.length} images with errors`);
616
+ }
617
+ else {
618
+ console.log(`[CLI] ✓ Successfully saved all ${savedCount} images to ${tmpDir}`);
619
+ }
800
620
  }
801
621
  }
802
622
  catch (error) {
803
- callback?.({ success: false, error: error.message });
623
+ console.error(`[CLI] Error saving images: ${error.message}`);
624
+ // Send error response
625
+ console.log(`[CLI] Sending error app:control:response with appControlId=${data.saveCallbackId}`);
626
+ socket.emit('app:control:response', {
627
+ appControlId: data.saveCallbackId,
628
+ success: false,
629
+ error: error.message
630
+ });
631
+ console.log(`[CLI] Emitted error app:control:response`);
804
632
  }
805
633
  });
806
- socket.on('github:commit', async (data, callback) => {
807
- try {
808
- const { projectPath, message = 'changes' } = data;
809
- // Check if directory is a git repository
810
- const gitDir = path.join(projectPath, '.git');
811
- let isRepo = false;
812
- try {
813
- const stats = await fs.stat(gitDir);
814
- isRepo = stats.isDirectory();
815
- }
816
- catch {
817
- callback?.({ success: false, error: 'Not a git repository' });
818
- return;
819
- }
820
- const { execSync } = await import('child_process');
821
- try {
822
- // Stage all changes
823
- execSync('git add -A', { cwd: projectPath });
824
- // Commit with message
825
- execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath });
826
- // Determine which branch to push to (master or main)
827
- let targetBranch;
828
- try {
829
- // Check if master branch exists
830
- execSync('git rev-parse --verify master', { cwd: projectPath, stdio: 'ignore' });
831
- targetBranch = 'master';
832
- }
833
- catch {
834
- try {
835
- // Check if main branch exists
836
- execSync('git rev-parse --verify main', { cwd: projectPath, stdio: 'ignore' });
837
- targetBranch = 'main';
838
- }
839
- catch {
840
- // Fallback to current branch if neither master nor main exists
841
- targetBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim();
842
- }
843
- }
844
- // Push to origin after commit
845
- execSync(`git push origin ${targetBranch}`, { cwd: projectPath, stdio: 'pipe' });
846
- callback?.({ success: true });
847
- }
848
- catch (error) {
849
- callback?.({ success: false, error: error.message });
634
+ // Handle folder transfer — this device is the source (pack & upload)
635
+ socket.on('transfer:pack', (data) => {
636
+ if (foreground) {
637
+ console.log(`[transfer] Pack request: ${data.sourcePath} (${data.selectedItems?.length || 0} items) → device ${data.destDeviceId.slice(0, 8)}`);
638
+ }
639
+ startSending(socket, data, foreground);
640
+ });
641
+ // Handle folder transfer — this device is the destination (download & extract)
642
+ socket.on('transfer:pull', (data) => {
643
+ if (foreground) {
644
+ console.log(`[transfer] Pull request: device ${data.sourceDeviceId.slice(0, 8)} → ${data.destPath} (${(data.totalBytes / (1024 * 1024)).toFixed(1)} MB)`);
645
+ }
646
+ startReceiving(socket, data, foreground);
647
+ });
648
+ socket.on('connect', () => {
649
+ if (foreground) {
650
+ console.log(`✓ Connected to backend at ${config.apiUrl}`);
651
+ if (socket) {
652
+ console.log(` Socket ID: ${socket.id}`);
850
653
  }
851
654
  }
852
- catch (error) {
853
- callback?.({ success: false, error: error.message });
655
+ else {
656
+ console.log('Connected to backend');
657
+ }
658
+ reconnectAttempts = 0;
659
+ // Register as CLI device
660
+ if (socket) {
661
+ socket.emit('register', { type: 'cli', deviceId });
854
662
  }
855
663
  });
856
- socket.on('github:push', async (data, callback) => {
857
- try {
858
- const { projectPath } = data;
859
- // Check if directory is a git repository
860
- const gitDir = path.join(projectPath, '.git');
861
- let isRepo = false;
664
+ socket.on('registered', async ({ type }) => {
665
+ if (foreground) {
666
+ console.log(`✓ Registered as ${type}`);
667
+ }
668
+ else {
669
+ console.log(`Registered as ${type}`);
670
+ }
671
+ // Check for auth token: env TTC_AUTH_TOKEN or device-config.json (from previous approval)
672
+ let authToken = process.env.TTC_AUTH_TOKEN;
673
+ let deviceEmail = email;
674
+ let skipEmailFlow = false;
675
+ // Load from device-config if no env token (reuse state from previous approval)
676
+ if (!authToken) {
862
677
  try {
863
- const stats = await fs.stat(gitDir);
864
- isRepo = stats.isDirectory();
678
+ const deviceConfig = await readDiskDeviceConfig();
679
+ authToken = deviceConfig.authToken;
680
+ if (!deviceEmail && deviceConfig.email)
681
+ deviceEmail = deviceConfig.email;
865
682
  }
866
683
  catch {
867
- callback?.({ success: false, error: 'Not a git repository' });
868
- return;
684
+ // No device config yet
869
685
  }
870
- const { execSync } = await import('child_process');
686
+ }
687
+ if (authToken) {
871
688
  try {
872
- // Determine which branch to push to (master or main)
873
- let targetBranch;
874
- try {
875
- // Check if master branch exists
876
- execSync('git rev-parse --verify master', { cwd: projectPath, stdio: 'ignore' });
877
- targetBranch = 'master';
878
- }
879
- catch {
880
- try {
881
- // Check if main branch exists
882
- execSync('git rev-parse --verify main', { cwd: projectPath, stdio: 'ignore' });
883
- targetBranch = 'main';
884
- }
885
- catch {
886
- // Fallback to current branch if neither master nor main exists
887
- targetBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim();
689
+ const parts = authToken.split('.');
690
+ if (parts.length === 3) {
691
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
692
+ if (payload.type === 'device_auth' && payload.email) {
693
+ deviceEmail = payload.email;
694
+ skipEmailFlow = true;
695
+ if (foreground) {
696
+ console.log(`✓ Using stored auth token for ${deviceEmail}`);
697
+ }
888
698
  }
889
699
  }
890
- // Push to origin
891
- execSync(`git push origin ${targetBranch}`, { cwd: projectPath });
892
- callback?.({ success: true });
893
700
  }
894
- catch (error) {
895
- callback?.({ success: false, error: error.message });
701
+ catch (err) {
702
+ if (foreground) {
703
+ console.warn(`⚠️ Invalid auth token in config, falling back to normal auth`);
704
+ }
705
+ authToken = undefined;
896
706
  }
897
707
  }
898
- catch (error) {
899
- callback?.({ success: false, error: error.message });
900
- }
901
- });
902
- socket.on('github:discard', async (data, callback) => {
903
- try {
904
- const { projectPath } = data;
905
- // Check if directory is a git repository
906
- const gitDir = path.join(projectPath, '.git');
907
- let isRepo = false;
708
+ // Load device email from config if not using token and not provided
709
+ if (!deviceEmail && !skipEmailFlow) {
908
710
  try {
909
- const stats = await fs.stat(gitDir);
910
- isRepo = stats.isDirectory();
711
+ const deviceConfig = await readDiskDeviceConfig();
712
+ deviceEmail = deviceConfig.email;
911
713
  }
912
714
  catch {
913
- callback?.({ success: false, error: 'Not a git repository' });
914
- return;
915
- }
916
- const { execSync } = await import('child_process');
917
- try {
918
- // Discard all changes and revert to latest commit
919
- // Reset all changes (both staged and unstaged)
920
- execSync('git reset --hard HEAD', { cwd: projectPath });
921
- // Clean untracked files and directories
922
- execSync('git clean -fd', { cwd: projectPath });
923
- callback?.({ success: true });
924
- }
925
- catch (error) {
926
- callback?.({ success: false, error: error.message });
927
- }
928
- }
929
- catch (error) {
930
- callback?.({ success: false, error: error.message });
931
- }
932
- });
933
- // Directory listing handler
934
- socket.on('fs:list', async (data) => {
935
- try {
936
- const { dirPath } = data;
937
- if (!dirPath) {
938
- socket?.emit('fs:list:response', { success: false, error: 'dirPath is required' });
939
- return;
940
- }
941
- const entries = [];
942
- try {
943
- const dirents = await fs.readdir(dirPath, { withFileTypes: true });
944
- for (const dirent of dirents) {
945
- if (dirent.name.startsWith('.'))
946
- continue; // Skip hidden files
947
- const fullPath = path.join(dirPath, dirent.name);
948
- entries.push({
949
- name: dirent.name,
950
- path: fullPath,
951
- isDir: dirent.isDirectory()
952
- });
953
- }
954
- // Sort: directories first, then alphabetically
955
- entries.sort((a, b) => {
956
- if (a.isDir && !b.isDir)
957
- return -1;
958
- if (!a.isDir && b.isDir)
959
- return 1;
960
- return a.name.localeCompare(b.name);
961
- });
962
- socket?.emit('fs:list:response', { success: true, entries });
963
- }
964
- catch (err) {
965
- socket?.emit('fs:list:response', { success: false, error: err.message });
966
- }
967
- }
968
- catch (error) {
969
- socket?.emit('fs:list:response', { success: false, error: error.message });
970
- }
971
- });
972
- // File write handler - supports text (utf-8) and binary (base64) files
973
- socket.on('fs:write', async (data) => {
974
- try {
975
- const { filePath, content, encoding = 'utf-8' } = data;
976
- if (!filePath) {
977
- socket?.emit('fs:write:response', { success: false, error: 'filePath is required' });
978
- return;
979
- }
980
- try {
981
- // Ensure directory exists
982
- const dir = path.dirname(filePath);
983
- await fs.mkdir(dir, { recursive: true });
984
- // Write file - support base64 encoding for binary files
985
- if (encoding === 'base64') {
986
- const buffer = Buffer.from(content, 'base64');
987
- await fs.writeFile(filePath, buffer);
988
- }
989
- else {
990
- await fs.writeFile(filePath, content, encoding);
991
- }
992
- socket?.emit('fs:write:response', { success: true });
993
- }
994
- catch (err) {
995
- socket?.emit('fs:write:response', { success: false, error: err.message });
996
- }
997
- }
998
- catch (error) {
999
- socket?.emit('fs:write:response', { success: false, error: error.message });
1000
- }
1001
- });
1002
- // File read handler
1003
- socket.on('fs:read', async (data) => {
1004
- try {
1005
- const { filePath, encoding = 'utf-8' } = data;
1006
- if (!filePath) {
1007
- socket?.emit('fs:read:response', { success: false, error: 'filePath is required' });
1008
- return;
1009
- }
1010
- try {
1011
- // Read file
1012
- const content = await fs.readFile(filePath, encoding);
1013
- socket?.emit('fs:read:response', { success: true, content });
1014
- }
1015
- catch (err) {
1016
- socket?.emit('fs:read:response', { success: false, error: err.message });
1017
- }
1018
- }
1019
- catch (error) {
1020
- socket?.emit('fs:read:response', { success: false, error: error.message });
1021
- }
1022
- });
1023
- // Session handlers
1024
- socket.on('session:create', async (data) => {
1025
- try {
1026
- let { sessionId, projectPath } = data;
1027
- // Remap /home/abc to /tmp/abc if /home/abc doesn't exist (container workaround)
1028
- if (projectPath === '/home/abc' && !fsSync.existsSync('/home/abc')) {
1029
- const fallbackPath = '/tmp/abc';
1030
- fsSync.mkdirSync(fallbackPath, { recursive: true });
1031
- projectPath = fallbackPath;
1032
- console.log(`[CLI] Remapped /home/abc -> ${fallbackPath}`);
1033
- }
1034
- activeSessions.set(sessionId, { projectPath });
1035
- if (foreground) {
1036
- console.log(`💬 Session created: ${sessionId}`);
1037
- console.log(` Project: ${projectPath}`);
1038
- }
1039
- else {
1040
- console.log(`Session created: ${sessionId} in project ${projectPath}`);
1041
- }
1042
- }
1043
- catch (error) {
1044
- if (foreground) {
1045
- console.error(`✗ Failed to create session: ${error.message}`);
1046
- }
1047
- else {
1048
- console.error('Failed to create session:', error.message);
1049
- }
1050
- }
1051
- });
1052
- socket.on('session:delete', async (data) => {
1053
- try {
1054
- const { sessionId } = data;
1055
- if (foreground) {
1056
- console.log(`🗑️ Deleting session: ${sessionId}`);
1057
- }
1058
- await agentSessionManager.deleteSession(sessionId);
1059
- activeSessions.delete(sessionId);
1060
- if (foreground) {
1061
- console.log(`✓ Session deleted: ${sessionId}`);
1062
- }
1063
- else {
1064
- console.log(`Session deleted: ${sessionId}`);
1065
- }
1066
- }
1067
- catch (error) {
1068
- if (foreground) {
1069
- console.error(`✗ Failed to delete session: ${error.message}`);
1070
- }
1071
- else {
1072
- console.error('Failed to delete session:', error.message);
1073
- }
1074
- }
1075
- });
1076
- socket.on('project:config:analyze', async (data) => {
1077
- try {
1078
- const { projectId, projectPath, projectName, analysisId } = data;
1079
- if (foreground) {
1080
- console.log(`🔍 Analyzing project: ${projectName} at ${projectPath}`);
1081
- }
1082
- // Run Claude analysis
1083
- const config = await analyzeProjectWithClaude(projectPath, projectName);
1084
- // Save config file (.talk-to-code.json)
1085
- await saveProjectConfig(projectPath, config);
1086
- // Generate and save runner files for each app
1087
- for (const app of config.apps) {
1088
- const runnerCode = generateRunnerCode(app, projectPath);
1089
- const runnerFileName = `${app.name}_runner.ts`;
1090
- const runnerPath = path.join(projectPath, app.directory || '', runnerFileName);
1091
- // Ensure directory exists
1092
- const runnerDir = path.dirname(runnerPath);
1093
- await fs.mkdir(runnerDir, { recursive: true });
1094
- // Write runner file
1095
- await fs.writeFile(runnerPath, runnerCode, 'utf-8');
1096
- if (foreground) {
1097
- console.log(`✓ Generated runner: ${runnerFileName}`);
1098
- }
1099
- }
1100
- if (foreground) {
1101
- console.log(`✓ Analysis complete: Found ${config.apps.length} apps`);
1102
- console.log(`✓ Generated ${config.apps.length} runner files`);
1103
- }
1104
- // Emit result back to backend
1105
- socket.emit('project:config:analyzed', {
1106
- projectId,
1107
- config,
1108
- analysisId
1109
- });
1110
- }
1111
- catch (error) {
1112
- if (foreground) {
1113
- console.error(`✗ Analysis error: ${error.message}`);
1114
- }
1115
- socket.emit('project:config:analyze:error', {
1116
- projectId: data.projectId,
1117
- error: error.message,
1118
- analysisId: data.analysisId
1119
- });
1120
- }
1121
- });
1122
- // App control handlers
1123
- socket.on('app:start', async (data, callback) => {
1124
- try {
1125
- const { projectId, projectPath, appName } = data;
1126
- // Load project config to get app details
1127
- const config = await getProjectConfig(projectPath);
1128
- if (!config) {
1129
- callback?.({ success: false, error: 'Project config not found' });
1130
- return;
1131
- }
1132
- const app = config.apps.find(a => a.name === appName);
1133
- if (!app) {
1134
- callback?.({ success: false, error: `App "${appName}" not found in project config` });
1135
- return;
1136
- }
1137
- const result = await startApp(projectPath, projectId, app);
1138
- if (result.success) {
1139
- if (foreground) {
1140
- console.log(`✓ Started app: ${appName} (PID: ${result.pid})`);
1141
- }
1142
- socket.emit('app:started', {
1143
- projectId,
1144
- appName,
1145
- processId: result.processId,
1146
- pid: result.pid,
1147
- });
1148
- }
1149
- callback?.(result);
1150
- }
1151
- catch (error) {
1152
- if (foreground) {
1153
- console.error(`✗ Error starting app: ${error.message}`);
1154
- }
1155
- callback?.({ success: false, error: error.message });
1156
- }
1157
- });
1158
- socket.on('app:stop', async (data, callback) => {
1159
- try {
1160
- const { projectId, projectPath, appName } = data;
1161
- // Load project config to get app details (for custom stop command)
1162
- const config = await getProjectConfig(projectPath);
1163
- const app = config?.apps.find(a => a.name === appName);
1164
- const result = await stopApp(projectId, appName, app);
1165
- if (result.success) {
1166
- if (foreground) {
1167
- console.log(`✓ Stopped app: ${appName}`);
1168
- }
1169
- socket.emit('app:stopped', {
1170
- projectId,
1171
- appName,
1172
- appControlId: data.appControlId,
1173
- });
1174
- }
1175
- callback?.(result);
1176
- }
1177
- catch (error) {
1178
- if (foreground) {
1179
- console.error(`✗ Error stopping app: ${error.message}`);
1180
- }
1181
- callback?.({ success: false, error: error.message });
1182
- }
1183
- });
1184
- socket.on('app:restart', async (data, callback) => {
1185
- try {
1186
- const { projectId, projectPath, appName } = data;
1187
- // Load project config to get app details
1188
- const config = await getProjectConfig(projectPath);
1189
- if (!config) {
1190
- callback?.({ success: false, error: 'Project config not found' });
1191
- return;
1192
- }
1193
- const app = config.apps.find(a => a.name === appName);
1194
- if (!app) {
1195
- callback?.({ success: false, error: `App "${appName}" not found in project config` });
1196
- return;
1197
- }
1198
- const result = await restartApp(projectPath, projectId, app);
1199
- if (result.success) {
1200
- if (foreground) {
1201
- console.log(`✓ Restarted app: ${appName} (PID: ${result.pid})`);
1202
- }
1203
- socket.emit('app:restarted', {
1204
- projectId,
1205
- appName,
1206
- processId: result.processId,
1207
- pid: result.pid,
1208
- appControlId: data.appControlId,
1209
- });
1210
- }
1211
- callback?.(result);
1212
- }
1213
- catch (error) {
1214
- if (foreground) {
1215
- console.error(`✗ Error restarting app: ${error.message}`);
1216
- }
1217
- callback?.({ success: false, error: error.message });
1218
- }
1219
- });
1220
- socket.on('app:status', async (data, callback) => {
1221
- try {
1222
- const { projectId, projectPath, appName } = data;
1223
- // Load project config
1224
- const config = await getProjectConfig(projectPath);
1225
- if (!config) {
1226
- callback?.({ success: false, error: 'Project config not found' });
1227
- return;
1228
- }
1229
- const appsToCheck = appName
1230
- ? config.apps.filter(a => a.name === appName)
1231
- : config.apps;
1232
- const statuses = getAppStatuses(projectId, appsToCheck);
1233
- // Send response back to backend
1234
- if (data.appControlId) {
1235
- socket.emit('app:control:response', {
1236
- appControlId: data.appControlId,
1237
- success: true,
1238
- apps: statuses,
1239
- });
1240
- }
1241
- callback?.({ success: true, apps: statuses });
1242
- }
1243
- catch (error) {
1244
- if (foreground) {
1245
- console.error(`✗ Error getting app status: ${error.message}`);
1246
- }
1247
- callback?.({ success: false, error: error.message });
1248
- }
1249
- });
1250
- socket.on('app:logs', async (data, callback) => {
1251
- try {
1252
- const { projectId, appName } = data;
1253
- const result = await getAppLogs(projectId, appName, 100);
1254
- // Send response back to backend
1255
- if (data.appControlId) {
1256
- socket.emit('app:control:response', {
1257
- appControlId: data.appControlId,
1258
- ...result,
1259
- });
1260
- }
1261
- callback?.(result);
1262
- }
1263
- catch (error) {
1264
- if (foreground) {
1265
- console.error(`✗ Error getting app logs: ${error.message}`);
1266
- }
1267
- callback?.({ success: false, error: error.message });
1268
- }
1269
- });
1270
- socket.on('session:prompt', async (data) => {
1271
- try {
1272
- const { sessionId, prompt, projectPath: providedProjectPath, promptId, enhancers, model } = data;
1273
- // promptId is REQUIRED when routing to device
1274
- if (!promptId) {
1275
- if (foreground) {
1276
- console.error(`✗ Missing required promptId for session: ${sessionId}`);
1277
- }
1278
- socket.emit('session:error', { sessionId, error: 'Missing required promptId' });
1279
- return;
1280
- }
1281
- // Try to get projectPath from activeSessions or use provided one
1282
- let projectPath = providedProjectPath || activeSessions.get(sessionId)?.projectPath;
1283
- if (!projectPath) {
1284
- if (foreground) {
1285
- console.error(`✗ Session not found: ${sessionId} (missing projectPath)`);
1286
- }
1287
- socket.emit('session:error', { sessionId, error: 'Session not found or projectPath missing' });
1288
- return;
1289
- }
1290
- // Store in activeSessions with promptId and model
1291
- activeSessions.set(sessionId, { projectPath, currentPromptId: promptId, model });
1292
- // Capture promptId in closure to prevent race conditions when multiple prompts arrive quickly
1293
- // This ensures each prompt's messages use the correct promptId even if a new prompt arrives
1294
- const capturedPromptId = promptId;
1295
- if (foreground) {
1296
- console.log(`\n[CLI] 📤 Received prompt for session: ${sessionId}, promptId: ${capturedPromptId}`);
1297
- console.log(`[CLI] Prompt: ${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}`);
1298
- }
1299
- // Create or get session handler
1300
- await agentSessionManager.createSession({
1301
- sessionId,
1302
- projectPath,
1303
- onOutput: (output) => {
1304
- // Serialize data to string if it's an object
1305
- const dataString = typeof output.data === 'string'
1306
- ? output.data
1307
- : JSON.stringify(output.data);
1308
- // Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
1309
- if (!capturedPromptId) {
1310
- console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit prompt:output`);
1311
- return;
1312
- }
1313
- socket.emit('prompt:output', {
1314
- sessionId,
1315
- promptId: capturedPromptId, // Use captured promptId, not currentPromptId
1316
- type: output.type,
1317
- data: dataString,
1318
- timestamp: output.timestamp,
1319
- metadata: output.metadata
1320
- });
1321
- },
1322
- onError: (error) => {
1323
- socket.emit('session:error', { sessionId, error });
1324
- },
1325
- onComplete: (exitCode) => {
1326
- // Note: This onComplete is from createSession, not sendPrompt
1327
- // The actual session:result is emitted from sendPrompt handler below
1328
- // This handler is kept for backward compatibility but may not be used
1329
- },
1330
- });
1331
- // Send prompt - status updates will be emitted from agentSession when processing starts/completes
1332
- await agentSessionManager.sendPrompt(sessionId, prompt, enhancers || [], {
1333
- sessionId,
1334
- projectPath,
1335
- promptId: capturedPromptId,
1336
- model: model, // Pass the model from the session
1337
- attachments: data.attachments, // Pass attachments from frontend
1338
- onStatusUpdate: (status) => {
1339
- // Emit status update from CLI (CLI is source of truth)
1340
- // Use captured promptId to ensure correct prompt is updated
1341
- if (!capturedPromptId) {
1342
- console.error(`[CLI] Missing promptId for status update, cannot emit prompt:updated`);
1343
- return;
1344
- }
1345
- // Always log status updates for debugging (even in background mode)
1346
- console.log(`[CLI] 📊 Status update: promptId=${capturedPromptId}, status=${status}, sessionId=${sessionId}`);
1347
- // Emit status update IMMEDIATELY (real-time)
1348
- socket.emit('prompt:updated', {
1349
- promptId: capturedPromptId, // CRITICAL: Use captured promptId from closure
1350
- sessionId,
1351
- text: prompt,
1352
- status,
1353
- createdAt: new Date().toISOString(),
1354
- ...(status === 'running' ? { startedAt: new Date().toISOString() } : {}),
1355
- ...(status === 'completed' || status === 'error' || status === 'cancelled' ? { completedAt: new Date().toISOString() } : {}),
1356
- messages: []
1357
- });
1358
- },
1359
- onOutput: (output) => {
1360
- // Serialize data to string if it's an object
1361
- const dataString = typeof output.data === 'string'
1362
- ? output.data
1363
- : JSON.stringify(output.data);
1364
- // Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
1365
- if (!capturedPromptId) {
1366
- console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit prompt:output`);
1367
- return;
1368
- }
1369
- if (foreground && output.type === 'stdout') {
1370
- process.stdout.write(dataString);
1371
- }
1372
- socket.emit('prompt:output', {
1373
- sessionId,
1374
- promptId: capturedPromptId, // Use captured promptId, not currentPromptId
1375
- type: output.type,
1376
- data: dataString,
1377
- timestamp: output.timestamp,
1378
- metadata: output.metadata
1379
- });
1380
- },
1381
- onError: (error) => {
1382
- // Error logging is handled by AgentLogger in agentSession.ts
1383
- // Additional console output for foreground mode
1384
- if (foreground) {
1385
- console.error(`\n[CLI] ✗ Session error: ${error}`);
1386
- }
1387
- socket.emit('session:error', { sessionId, error });
1388
- },
1389
- onComplete: (exitCode) => {
1390
- // Completion logging is handled by AgentLogger in agentSession.ts
1391
- // Additional console output for foreground mode
1392
- if (foreground) {
1393
- console.log(`\n[CLI] ✓ Session completed with exit code: ${exitCode ?? 'null'}`);
1394
- }
1395
- // Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
1396
- if (!capturedPromptId) {
1397
- console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit session:result`);
1398
- return;
1399
- }
1400
- socket.emit('session:result', {
1401
- sessionId,
1402
- promptId: capturedPromptId, // Use captured promptId, not currentPromptId
1403
- exitCode
1404
- });
1405
- }
1406
- });
1407
- }
1408
- catch (error) {
1409
- if (foreground) {
1410
- console.error(`✗ Error processing prompt: ${error.message}`);
1411
- }
1412
- socket.emit('session:error', { sessionId: data.sessionId, error: error.message });
1413
- }
1414
- });
1415
- // Handle prompt cancellation
1416
- socket.on('prompt:cancel', async (data, callback) => {
1417
- try {
1418
- const { promptId, sessionId } = data;
1419
- if (!promptId || !sessionId) {
1420
- callback?.({ success: false, error: 'Missing promptId or sessionId' });
1421
- return;
1422
- }
1423
- if (foreground) {
1424
- console.log(`[CLI] 🛑 Cancelling prompt: ${promptId}`);
1425
- }
1426
- // Cancel the prompt
1427
- const cancelled = await agentSessionManager.cancelPrompt(promptId, sessionId, (status) => {
1428
- // Emit cancelled status update
1429
- socket.emit('prompt:updated', {
1430
- promptId,
1431
- sessionId,
1432
- text: '', // Not needed for status update
1433
- status: 'cancelled',
1434
- createdAt: new Date().toISOString(),
1435
- completedAt: new Date().toISOString(),
1436
- messages: []
1437
- });
1438
- });
1439
- if (cancelled) {
1440
- callback?.({ success: true });
1441
- }
1442
- else {
1443
- callback?.({ success: false, error: 'Prompt not found or already completed' });
1444
- }
1445
- }
1446
- catch (error) {
1447
- if (foreground) {
1448
- console.error(`✗ Error cancelling prompt: ${error.message}`);
1449
- }
1450
- callback?.({ success: false, error: error.message });
1451
- }
1452
- });
1453
- // Handle emergency stop - forcefully halt all session activity
1454
- socket.on('emergency:stop', async (data, callback) => {
1455
- try {
1456
- const { sessionId } = data;
1457
- if (!sessionId) {
1458
- callback?.({ success: false, message: 'Missing sessionId' });
1459
- return;
1460
- }
1461
- if (foreground) {
1462
- console.log(`[CLI] ☠️ EMERGENCY STOP for session: ${sessionId}`);
1463
- }
1464
- // Execute emergency stop
1465
- const result = await agentSessionManager.emergencyStop(sessionId);
1466
- // Emit status update for current prompt if any
1467
- socket.emit('emergency:stopped', {
1468
- sessionId,
1469
- success: result.success,
1470
- message: result.message,
1471
- timestamp: new Date().toISOString()
1472
- });
1473
- callback?.(result);
1474
- }
1475
- catch (error) {
1476
- if (foreground) {
1477
- console.error(`✗ Error during emergency stop: ${error.message}`);
1478
- }
1479
- callback?.({ success: false, message: error.message });
1480
- }
1481
- });
1482
- // Handle image save request - saves base64 images to tmp directory
1483
- socket.on('image:save', async (data) => {
1484
- try {
1485
- const { images, saveCallbackId, projectPath } = data;
1486
- if (foreground) {
1487
- console.log(`[CLI] 🖼️ Received request to save ${images.length} image(s)`);
1488
- }
1489
- // Use project-specific tmp directory (inside the project)
1490
- // If projectPath is provided, use it; otherwise fall back to current working directory
1491
- const basePath = projectPath || process.cwd();
1492
- const tmpDir = path.join(basePath, 'tmp');
1493
- await fs.mkdir(tmpDir, { recursive: true });
1494
- if (foreground) {
1495
- console.log(`[CLI] 📁 Saving to directory: ${tmpDir}`);
1496
- }
1497
- let savedCount = 0;
1498
- const errors = [];
1499
- for (const image of images) {
1500
- try {
1501
- const imagePath = path.join(tmpDir, image.name);
1502
- // Convert base64 to buffer and write to file
1503
- const buffer = Buffer.from(image.data, 'base64');
1504
- await fs.writeFile(imagePath, buffer);
1505
- if (foreground) {
1506
- console.log(`[CLI] ✓ Saved image: ${imagePath}`);
1507
- }
1508
- savedCount++;
1509
- }
1510
- catch (saveError) {
1511
- const errorMsg = `Failed to save ${image.name}: ${saveError.message}`;
1512
- errors.push(errorMsg);
1513
- if (foreground) {
1514
- console.error(`[CLI] ✗ ${errorMsg}`);
1515
- }
1516
- }
1517
- }
1518
- // Send response back via app:control:response
1519
- console.log(`[CLI] Sending app:control:response with appControlId=${saveCallbackId}, success=${errors.length === 0}, savedCount=${savedCount}`);
1520
- socket.emit('app:control:response', {
1521
- appControlId: saveCallbackId,
1522
- success: errors.length === 0,
1523
- savedCount,
1524
- error: errors.length > 0 ? errors.join('; ') : undefined
1525
- });
1526
- console.log(`[CLI] Emitted app:control:response`);
1527
- if (foreground) {
1528
- if (errors.length > 0) {
1529
- console.log(`[CLI] ⚠️ Saved ${savedCount}/${images.length} images with errors`);
1530
- }
1531
- else {
1532
- console.log(`[CLI] ✓ Successfully saved all ${savedCount} images to ${tmpDir}`);
1533
- }
1534
- }
1535
- }
1536
- catch (error) {
1537
- console.error(`[CLI] ✗ Error saving images: ${error.message}`);
1538
- // Send error response
1539
- console.log(`[CLI] Sending error app:control:response with appControlId=${data.saveCallbackId}`);
1540
- socket.emit('app:control:response', {
1541
- appControlId: data.saveCallbackId,
1542
- success: false,
1543
- error: error.message
1544
- });
1545
- console.log(`[CLI] Emitted error app:control:response`);
1546
- }
1547
- });
1548
- // Handle folder transfer — this device is the source (pack & upload)
1549
- socket.on('transfer:pack', (data) => {
1550
- if (foreground) {
1551
- console.log(`[transfer] Pack request: ${data.sourcePath} (${data.selectedItems?.length || 0} items) → device ${data.destDeviceId.slice(0, 8)}`);
1552
- }
1553
- startSending(socket, data, foreground);
1554
- });
1555
- // Handle folder transfer — this device is the destination (download & extract)
1556
- socket.on('transfer:pull', (data) => {
1557
- if (foreground) {
1558
- console.log(`[transfer] Pull request: device ${data.sourceDeviceId.slice(0, 8)} → ${data.destPath} (${(data.totalBytes / (1024 * 1024)).toFixed(1)} MB)`);
1559
- }
1560
- startReceiving(socket, data, foreground);
1561
- });
1562
- socket.on('connect', () => {
1563
- if (foreground) {
1564
- console.log(`✓ Connected to backend at ${config.apiUrl}`);
1565
- if (socket) {
1566
- console.log(` Socket ID: ${socket.id}`);
1567
- }
1568
- }
1569
- else {
1570
- console.log('Connected to backend');
1571
- }
1572
- reconnectAttempts = 0;
1573
- // Register as CLI device
1574
- if (socket) {
1575
- socket.emit('register', { type: 'cli', deviceId });
1576
- }
1577
- });
1578
- socket.on('registered', async ({ type }) => {
1579
- if (foreground) {
1580
- console.log(`✓ Registered as ${type}`);
1581
- }
1582
- else {
1583
- console.log(`Registered as ${type}`);
1584
- }
1585
- // Check for auth token: env TTC_AUTH_TOKEN or device-config.json (from previous approval)
1586
- let authToken = process.env.TTC_AUTH_TOKEN;
1587
- let deviceEmail = email;
1588
- let skipEmailFlow = false;
1589
- // Load from device-config if no env token (reuse state from previous approval)
1590
- if (!authToken) {
1591
- try {
1592
- const deviceConfig = await readDiskDeviceConfig();
1593
- authToken = deviceConfig.authToken;
1594
- if (!deviceEmail && deviceConfig.email)
1595
- deviceEmail = deviceConfig.email;
1596
- }
1597
- catch {
1598
- // No device config yet
1599
- }
1600
- }
1601
- if (authToken) {
1602
- try {
1603
- const parts = authToken.split('.');
1604
- if (parts.length === 3) {
1605
- const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
1606
- if (payload.type === 'device_auth' && payload.email) {
1607
- deviceEmail = payload.email;
1608
- skipEmailFlow = true;
1609
- if (foreground) {
1610
- console.log(`✓ Using stored auth token for ${deviceEmail}`);
1611
- }
1612
- }
1613
- }
1614
- }
1615
- catch (err) {
1616
- if (foreground) {
1617
- console.warn(`⚠️ Invalid auth token in config, falling back to normal auth`);
1618
- }
1619
- authToken = undefined;
1620
- }
1621
- }
1622
- // Load device email from config if not using token and not provided
1623
- if (!deviceEmail && !skipEmailFlow) {
1624
- try {
1625
- const deviceConfig = await readDiskDeviceConfig();
1626
- deviceEmail = deviceConfig.email;
1627
- }
1628
- catch {
1629
- // No device config yet
715
+ // No device config yet
1630
716
  }
1631
717
  }
1632
718
  // Register device with backend
@@ -1788,675 +874,10 @@ async function runDaemon(foreground = false, email) {
1788
874
  }, 2000);
1789
875
  });
1790
876
  });
1791
- // Cloudflared handlers
1792
- // Proxy toggle handler
1793
- socket.on('proxy:toggle:request', async (data, callback) => {
1794
- try {
1795
- const { enable } = data;
1796
- const proxyUrl = getProxyUrl();
1797
- if (enable && !proxyUrl) {
1798
- callback?.({ success: false, enabled: false });
1799
- return;
1800
- }
1801
- await writeProxyConfig({ enabled: enable });
1802
- if (foreground) {
1803
- console.log(`[CLI] Proxy ${enable ? 'enabled' : 'disabled'}${proxyUrl ? `: ${proxyUrl}` : ''}`);
1804
- }
1805
- else {
1806
- console.log(`Proxy ${enable ? 'enabled' : 'disabled'}`);
1807
- }
1808
- callback?.({ success: true, enabled: enable, proxyUrl: enable ? proxyUrl : undefined });
1809
- }
1810
- catch (error) {
1811
- callback?.({ success: false, enabled: false });
1812
- }
1813
- });
1814
- // Proxy status handler
1815
- socket.on('proxy:status:request', async (_data, callback) => {
1816
- try {
1817
- const proxyConfig = await readProxyConfig();
1818
- const proxyUrl = getProxyUrl();
1819
- callback?.({ enabled: proxyConfig.enabled, proxyUrl: proxyConfig.enabled ? proxyUrl : undefined });
1820
- }
1821
- catch {
1822
- callback?.({ enabled: false });
1823
- }
1824
- });
1825
- socket.on('cloudflared:check:request', async () => {
1826
- try {
1827
- let installed = false;
1828
- let hasCert = false;
1829
- let activeTunnelId;
1830
- try {
1831
- // Check if cloudflared is installed
1832
- execSync('which cloudflared', { stdio: 'ignore' });
1833
- installed = true;
1834
- // Check if cert.pem exists
1835
- const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
1836
- try {
1837
- // Use fs.stat to check if file exists (more reliable than access)
1838
- const stats = await fs.stat(certPath);
1839
- hasCert = stats.isFile();
1840
- if (foreground && hasCert) {
1841
- console.log(`✓ Found cert.pem at ${certPath}`);
1842
- }
1843
- }
1844
- catch (err) {
1845
- // File doesn't exist or can't be accessed
1846
- hasCert = false;
1847
- if (foreground) {
1848
- console.log(`✗ cert.pem not found at ${certPath}: ${err.message}`);
1849
- }
1850
- }
1851
- // Detect running cloudflared tunnel by inspecting processes
1852
- try {
1853
- const psOutput = execSync('ps aux', { encoding: 'utf-8' });
1854
- const cloudflaredLines = psOutput.split('\n').filter(line => line.includes('cloudflared') && line.includes('tunnel'));
1855
- for (const line of cloudflaredLines) {
1856
- // Look for --token flag in the process args
1857
- const tokenMatch = line.match(/--token\s+(\S+)/);
1858
- if (tokenMatch && tokenMatch[1]) {
1859
- try {
1860
- // Decode base64url token to extract tunnel ID
1861
- const tokenB64 = tokenMatch[1].replace(/-/g, '+').replace(/_/g, '/');
1862
- const padded = tokenB64 + '='.repeat((4 - tokenB64.length % 4) % 4);
1863
- const tokenJson = Buffer.from(padded, 'base64').toString('utf-8');
1864
- const tokenData = JSON.parse(tokenJson);
1865
- if (tokenData.t) {
1866
- activeTunnelId = tokenData.t;
1867
- if (foreground) {
1868
- console.log(`✓ Detected active tunnel: ${activeTunnelId}`);
1869
- }
1870
- break;
1871
- }
1872
- }
1873
- catch {
1874
- // Failed to decode token, skip
1875
- }
1876
- }
1877
- // Also check for named tunnel (cloudflared tunnel run <name>)
1878
- const nameMatch = line.match(/cloudflared.*tunnel\s+run\s+(?:--.*?\s+)?(\S+)/);
1879
- if (!activeTunnelId && nameMatch && nameMatch[1] && !nameMatch[1].startsWith('-')) {
1880
- // This is a tunnel name, not an ID - we'll resolve it later via API
1881
- activeTunnelId = nameMatch[1];
1882
- if (foreground) {
1883
- console.log(`✓ Detected running tunnel name: ${activeTunnelId}`);
1884
- }
1885
- break;
1886
- }
1887
- }
1888
- }
1889
- catch {
1890
- // ps failed, can't detect active tunnel
1891
- }
1892
- }
1893
- catch {
1894
- installed = false;
1895
- }
1896
- socket.emit('cloudflared:check:response', { installed, hasCert, activeTunnelId });
1897
- if (foreground) {
1898
- console.log(`Cloudflared check: installed=${installed}, hasCert=${hasCert}, activeTunnelId=${activeTunnelId || 'none'}`);
1899
- }
1900
- }
1901
- catch (error) {
1902
- socket.emit('cloudflared:check:response', { installed: false, hasCert: false });
1903
- }
1904
- });
1905
- socket.on('cloudflared:sync:request', async () => {
1906
- try {
1907
- const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
1908
- if (foreground) {
1909
- console.log(`Syncing credentials from ${certPath}`);
1910
- }
1911
- // Read cert.pem file
1912
- const certContent = await fs.readFile(certPath, 'utf-8');
1913
- // Extract token between BEGIN and END markers
1914
- const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/);
1915
- if (tokenMatch && tokenMatch[1]) {
1916
- // Decode base64 token
1917
- const tokenBase64 = tokenMatch[1].replace(/\s/g, '');
1918
- const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8');
1919
- const tokenData = JSON.parse(tokenJson);
1920
- // Extract API token, account ID, and zone ID
1921
- const apiToken = tokenData.apiToken;
1922
- const accountId = tokenData.accountID;
1923
- const zoneId = tokenData.zoneID;
1924
- if (foreground) {
1925
- console.log(`✓ Extracted credentials: accountId=${accountId}, zoneId=${zoneId}`);
1926
- }
1927
- socket.emit('cloudflared:sync:complete', {
1928
- accountId,
1929
- accountName: undefined,
1930
- apiToken,
1931
- zoneId
1932
- });
1933
- }
1934
- else {
1935
- const error = 'Failed to extract token from cert.pem';
1936
- if (foreground) {
1937
- console.error(`✗ ${error}`);
1938
- }
1939
- socket.emit('cloudflared:sync:error', { error });
1940
- }
1941
- }
1942
- catch (error) {
1943
- const errorMsg = `Failed to read cert.pem: ${error.message}`;
1944
- if (foreground) {
1945
- console.error(`✗ ${errorMsg}`);
1946
- }
1947
- socket.emit('cloudflared:sync:error', { error: errorMsg });
1948
- }
1949
- });
1950
- socket.on('cloudflared:login:request', async () => {
1951
- try {
1952
- // Run cloudflared tunnel login
1953
- const loginProcess = spawn('cloudflared', ['tunnel', 'login'], {
1954
- stdio: ['ignore', 'pipe', 'pipe']
1955
- });
1956
- let stdout = '';
1957
- let stderr = '';
1958
- let urlEmitted = false;
1959
- let alreadyLoggedIn = false;
1960
- let certPath = null;
1961
- const extractCertPath = (text) => {
1962
- // Look for: "You have an existing certificate at /path/to/cert.pem"
1963
- const pathMatch = text.match(/existing certificate at\s+([^\s]+)/i) ||
1964
- text.match(/certificate at\s+([^\s]+)/i) ||
1965
- text.match(/cert\.pem.*?at\s+([^\s]+)/i);
1966
- return pathMatch ? pathMatch[1] : null;
1967
- };
1968
- const extractLoginUrl = (text) => {
1969
- // Look for URLs in the output
1970
- const urlPatterns = [
1971
- /https:\/\/dash\.cloudflare\.com\/argotunnel[^\s\)]+/g,
1972
- /https:\/\/[^\s\)]+cloudflareaccess\.org[^\s\)]+/g,
1973
- /https:\/\/[^\s\)]+cloudflare\.com[^\s\)]+/g
1974
- ];
1975
- for (const pattern of urlPatterns) {
1976
- const matches = text.match(pattern);
1977
- if (matches && matches.length > 0) {
1978
- return matches[0];
1979
- }
1980
- }
1981
- return null;
1982
- };
1983
- loginProcess.stdout.on('data', (data) => {
1984
- const text = data.toString();
1985
- stdout += text;
1986
- // Check for already logged in error
1987
- if (text.includes('existing certificate') || text.includes('cert.pem which login would overwrite')) {
1988
- alreadyLoggedIn = true;
1989
- const extractedPath = extractCertPath(text);
1990
- if (extractedPath) {
1991
- certPath = extractedPath;
1992
- }
1993
- }
1994
- // Extract login URL if not already logged in
1995
- if (!alreadyLoggedIn && !urlEmitted) {
1996
- const url = extractLoginUrl(text);
1997
- if (url) {
1998
- urlEmitted = true;
1999
- socket.emit('cloudflared:login:url', { loginUrl: url });
2000
- }
2001
- }
2002
- });
2003
- loginProcess.stderr.on('data', (data) => {
2004
- const text = data.toString();
2005
- stderr += text;
2006
- // Check for already logged in error (often in stderr)
2007
- if (text.includes('existing certificate') || text.includes('cert.pem which login would overwrite')) {
2008
- alreadyLoggedIn = true;
2009
- const extractedPath = extractCertPath(text);
2010
- if (extractedPath) {
2011
- certPath = extractedPath;
2012
- }
2013
- }
2014
- // Extract login URL if not already logged in
2015
- if (!alreadyLoggedIn && !urlEmitted) {
2016
- const url = extractLoginUrl(text);
2017
- if (url) {
2018
- urlEmitted = true;
2019
- socket.emit('cloudflared:login:url', { loginUrl: url });
2020
- }
2021
- }
2022
- });
2023
- loginProcess.on('close', async (code) => {
2024
- if (alreadyLoggedIn && certPath) {
2025
- // Already logged in - extract credentials from existing cert
2026
- try {
2027
- if (foreground) {
2028
- console.log(`Already logged in, extracting credentials from ${certPath}`);
2029
- }
2030
- const certContent = await fs.readFile(certPath, 'utf-8');
2031
- const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/);
2032
- if (tokenMatch && tokenMatch[1]) {
2033
- const tokenBase64 = tokenMatch[1].replace(/\s/g, '');
2034
- const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8');
2035
- const tokenData = JSON.parse(tokenJson);
2036
- if (foreground) {
2037
- console.log(`✓ Extracted credentials from existing cert`);
2038
- }
2039
- socket.emit('cloudflared:login:complete', {
2040
- accountId: tokenData.accountID,
2041
- accountName: undefined,
2042
- apiToken: tokenData.apiToken,
2043
- zoneId: tokenData.zoneID
2044
- });
2045
- }
2046
- else {
2047
- const error = 'Failed to extract token from existing cert.pem';
2048
- if (foreground) {
2049
- console.error(`✗ ${error}`);
2050
- }
2051
- socket.emit('cloudflared:login:error', { error });
2052
- }
2053
- }
2054
- catch (error) {
2055
- const errorMsg = `Failed to read cert.pem: ${error.message}`;
2056
- if (foreground) {
2057
- console.error(`✗ ${errorMsg}`);
2058
- }
2059
- socket.emit('cloudflared:login:error', { error: errorMsg });
2060
- }
2061
- }
2062
- else if (code === 0 && !alreadyLoggedIn) {
2063
- // Login completed successfully - wait a moment then extract credentials
2064
- if (foreground) {
2065
- console.log('Login completed, extracting credentials...');
2066
- }
2067
- setTimeout(async () => {
2068
- try {
2069
- const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
2070
- const certContent = await fs.readFile(certPath, 'utf-8');
2071
- const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/);
2072
- if (tokenMatch && tokenMatch[1]) {
2073
- const tokenBase64 = tokenMatch[1].replace(/\s/g, '');
2074
- const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8');
2075
- const tokenData = JSON.parse(tokenJson);
2076
- if (foreground) {
2077
- console.log(`✓ Extracted credentials after login`);
2078
- }
2079
- socket.emit('cloudflared:login:complete', {
2080
- accountId: tokenData.accountID,
2081
- accountName: undefined,
2082
- apiToken: tokenData.apiToken,
2083
- zoneId: tokenData.zoneID
2084
- });
2085
- }
2086
- else {
2087
- const error = 'Failed to extract token from cert.pem after login';
2088
- if (foreground) {
2089
- console.error(`✗ ${error}`);
2090
- }
2091
- socket.emit('cloudflared:login:error', { error });
2092
- }
2093
- }
2094
- catch (error) {
2095
- const errorMsg = `Failed to read cert.pem after login: ${error.message}`;
2096
- if (foreground) {
2097
- console.error(`✗ ${errorMsg}`);
2098
- }
2099
- socket.emit('cloudflared:login:error', { error: errorMsg });
2100
- }
2101
- }, 1000); // Wait 1 second for file to be written
2102
- }
2103
- else if (!alreadyLoggedIn) {
2104
- const error = `Login failed with code ${code}: ${stderr || stdout}`;
2105
- if (foreground) {
2106
- console.error(`✗ ${error}`);
2107
- }
2108
- socket.emit('cloudflared:login:error', { error });
2109
- }
2110
- });
2111
- loginProcess.on('error', (error) => {
2112
- socket.emit('cloudflared:login:error', { error: error.message });
2113
- });
2114
- }
2115
- catch (error) {
2116
- socket.emit('cloudflared:login:error', { error: error.message });
2117
- }
2118
- });
2119
- socket.on('cloudflared:regenerate:request', async () => {
2120
- try {
2121
- const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
2122
- // Delete existing cert.pem
2123
- try {
2124
- await fs.unlink(certPath);
2125
- if (foreground) {
2126
- console.log(`✓ Deleted existing cert.pem`);
2127
- }
2128
- }
2129
- catch (error) {
2130
- // File might not exist, that's okay
2131
- if (foreground && error.code !== 'ENOENT') {
2132
- console.log(`Note: Could not delete cert.pem: ${error.message}`);
2133
- }
2134
- }
2135
- // Emit success - frontend will then trigger login
2136
- socket.emit('cloudflared:regenerate:complete', {});
2137
- }
2138
- catch (error) {
2139
- socket.emit('cloudflared:regenerate:error', { error: error.message });
2140
- }
2141
- });
2142
- socket.on('cloudflared:login:request', async () => {
2143
- try {
2144
- // First check if cloudflared is installed
2145
- try {
2146
- execSync('which cloudflared', { stdio: 'ignore' });
2147
- }
2148
- catch {
2149
- socket.emit('cloudflared:login:error', { error: 'cloudflared is not installed' });
2150
- return;
2151
- }
2152
- // Run cloudflared tunnel login
2153
- // This command outputs a URL that needs to be visited
2154
- const loginProcess = spawn('cloudflared', ['tunnel', 'login'], {
2155
- stdio: ['ignore', 'pipe', 'pipe']
2156
- });
2157
- let stdout = '';
2158
- let stderr = '';
2159
- let urlEmitted = false;
2160
- const findAndEmitUrl = (text) => {
2161
- if (urlEmitted)
2162
- return;
2163
- // Look for URL in output - cloudflared outputs URLs in various formats
2164
- const urlPatterns = [
2165
- /https:\/\/[^\s\)]+/g,
2166
- /https:\/\/[^\n]+/g
2167
- ];
2168
- for (const pattern of urlPatterns) {
2169
- const matches = text.match(pattern);
2170
- if (matches && matches.length > 0) {
2171
- // Find the login URL (usually contains "cloudflareaccess.com" or similar)
2172
- const loginUrl = matches.find(url => url.includes('cloudflareaccess.com') ||
2173
- url.includes('cloudflare.com') ||
2174
- url.includes('trycloudflare.com') ||
2175
- url.includes('dash.cloudflare.com'));
2176
- if (loginUrl) {
2177
- urlEmitted = true;
2178
- socket.emit('cloudflared:login:url', { loginUrl: loginUrl.trim() });
2179
- return;
2180
- }
2181
- }
2182
- }
2183
- };
2184
- loginProcess.stdout.on('data', (data) => {
2185
- const text = data.toString();
2186
- stdout += text;
2187
- findAndEmitUrl(text);
2188
- });
2189
- loginProcess.stderr.on('data', (data) => {
2190
- const text = data.toString();
2191
- stderr += text;
2192
- // cloudflared often outputs URLs to stderr
2193
- findAndEmitUrl(text);
2194
- });
2195
- loginProcess.on('close', async (code) => {
2196
- if (code === 0) {
2197
- // Login successful, extract API token from cert.pem
2198
- try {
2199
- const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
2200
- const certContent = await fs.readFile(certPath, 'utf-8');
2201
- // Extract token between BEGIN and END markers
2202
- const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/);
2203
- if (tokenMatch && tokenMatch[1]) {
2204
- // Decode base64 token
2205
- const tokenBase64 = tokenMatch[1].replace(/\s/g, '');
2206
- const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8');
2207
- const tokenData = JSON.parse(tokenJson);
2208
- // Extract API token, account ID, and zone ID
2209
- const apiToken = tokenData.apiToken;
2210
- const accountId = tokenData.accountID;
2211
- const zoneId = tokenData.zoneID;
2212
- socket.emit('cloudflared:login:complete', {
2213
- accountId,
2214
- accountName: undefined,
2215
- apiToken,
2216
- zoneId
2217
- });
2218
- }
2219
- else {
2220
- socket.emit('cloudflared:login:error', {
2221
- error: 'Failed to extract token from cert.pem'
2222
- });
2223
- }
2224
- }
2225
- catch (error) {
2226
- socket.emit('cloudflared:login:error', {
2227
- error: `Failed to read cert.pem: ${error.message}`
2228
- });
2229
- }
2230
- }
2231
- else {
2232
- socket.emit('cloudflared:login:error', {
2233
- error: `Login failed with code ${code}: ${stderr}`
2234
- });
2235
- }
2236
- });
2237
- loginProcess.on('error', (error) => {
2238
- socket.emit('cloudflared:login:error', { error: error.message });
2239
- });
2240
- }
2241
- catch (error) {
2242
- socket.emit('cloudflared:login:error', { error: error.message });
2243
- }
2244
- });
2245
- // ========== Container Management Handlers ==========
2246
- // Check if container runtime is available
2247
- socket.on('container:check:request', async () => {
2248
- try {
2249
- let enabled = false;
2250
- let runtime;
2251
- let version = '';
2252
- try {
2253
- // Check for docker first
2254
- execSync('which docker', { stdio: 'ignore' });
2255
- runtime = 'docker';
2256
- version = execSync('docker --version', { encoding: 'utf-8' }).trim();
2257
- enabled = true;
2258
- }
2259
- catch {
2260
- try {
2261
- // Check for podman as fallback
2262
- execSync('which podman', { stdio: 'ignore' });
2263
- runtime = 'podman';
2264
- version = execSync('podman --version', { encoding: 'utf-8' }).trim();
2265
- enabled = true;
2266
- }
2267
- catch {
2268
- enabled = false;
2269
- }
2270
- }
2271
- socket.emit('container:check:response', { enabled, runtime, version });
2272
- if (foreground) {
2273
- console.log(`Container runtime check: ${enabled ? `${runtime} (${version})` : 'Not found'}`);
2274
- }
2275
- }
2276
- catch (error) {
2277
- socket.emit('container:check:response', { enabled: false, runtime: undefined, version: '' });
2278
- }
2279
- });
2280
- // List all containers
2281
- socket.on('container:list:request', async () => {
2282
- try {
2283
- const runtime = getContainerRuntime();
2284
- if (!runtime) {
2285
- socket.emit('container:list:response', { success: false, error: 'No container runtime found' });
2286
- return;
2287
- }
2288
- // List all containers including stopped ones
2289
- const output = execSync(`${runtime} ps -a --format "{{json .}}"`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
2290
- const containers = output.trim().split('\n').filter(Boolean).map(line => {
2291
- try {
2292
- const c = JSON.parse(line);
2293
- // Parse ports from the PORTS column (format: "0.0.0.0:8080->80/tcp, 0.0.0.0:9090->9090/tcp")
2294
- const ports = [];
2295
- if (c.Ports) {
2296
- const portMatches = c.Ports.match(/(\d+)->(\d+)/g);
2297
- if (portMatches) {
2298
- portMatches.forEach((p) => {
2299
- const [host, container] = p.split('->').map(Number);
2300
- ports.push({ host, container });
2301
- });
2302
- }
2303
- }
2304
- return {
2305
- containerId: c.ID,
2306
- name: c.Names.replace(/^\//, ''), // Remove leading slash
2307
- image: c.Image,
2308
- status: c.State === 'running' ? 'running' : c.State === 'paused' ? 'paused' : c.Status === 'exited' ? 'exited' : 'stopped',
2309
- ports,
2310
- createdAt: new Date(c.CreatedAt).toISOString()
2311
- };
2312
- }
2313
- catch {
2314
- return null;
2315
- }
2316
- }).filter(Boolean);
2317
- socket.emit('container:list:response', { success: true, containers });
2318
- }
2319
- catch (error) {
2320
- socket.emit('container:list:response', { success: false, error: error.message });
2321
- }
2322
- });
2323
- // Start a new container
2324
- socket.on('container:start:request', async (data) => {
2325
- try {
2326
- const runtime = getContainerRuntime();
2327
- if (!runtime) {
2328
- socket.emit('container:start:response', { success: false, error: 'No container runtime found' });
2329
- return;
2330
- }
2331
- const { name, image, ports = [], env = {}, runAsRoot = false } = data;
2332
- // Get CLI directory path (where this script is running from)
2333
- // Use the same pattern as elsewhere in the file
2334
- const cliDir = path.dirname(CURRENT_FILE);
2335
- // Build docker run command
2336
- let cmd = `${runtime} run -d --name ${name}`;
2337
- // Security: Running as root INSIDE container is safe - it's still isolated from host
2338
- // Root inside container cannot access host filesystem (read-only mounts)
2339
- // Default to root to allow package installation and full container functionality
2340
- // Set runAsRoot: false to run as non-root user (UID 1000) if needed
2341
- if (!runAsRoot) {
2342
- cmd += ' --user 1000:1000';
2343
- }
2344
- // Resource limits
2345
- cmd += ' --memory=8g'; // Limit memory to 8GB
2346
- // Auto-remove on exit (optional - commented out for persistence)
2347
- // cmd += ' --rm'
2348
- // Mount CLI directory into container
2349
- cmd += ` -v "${cliDir}:/opt/ttc:ro"`;
2350
- // Mount entrypoint script
2351
- const entrypointScript = path.join(cliDir, 'container-entrypoint.sh');
2352
- cmd += ` -v "${entrypointScript}:/entrypoint.sh:ro"`;
2353
- // Port mappings
2354
- ports.forEach(p => {
2355
- cmd += ` -p ${p.host}:${p.container}`;
2356
- });
2357
- // Environment variables
2358
- Object.entries(env).forEach(([k, v]) => {
2359
- // Escape quotes in env values
2360
- const escapedValue = String(v).replace(/"/g, '\\"');
2361
- cmd += ` -e ${k}="${escapedValue}"`;
2362
- });
2363
- // Add container-specific environment variables
2364
- cmd += ` -e CONTAINER_NAME="${name}"`;
2365
- cmd += ` -e HOSTNAME="${name}"`;
2366
- // Use entrypoint script
2367
- cmd += ` --entrypoint /bin/sh ${image} /entrypoint.sh`;
2368
- if (foreground) {
2369
- console.log(`Starting container: ${cmd}`);
2370
- }
2371
- // Pull image if not exists, then run
2372
- try {
2373
- execSync(`${runtime} pull ${image}`, { stdio: 'ignore' });
2374
- }
2375
- catch {
2376
- // Pull failed, but might already exist locally
2377
- }
2378
- const output = execSync(cmd, { encoding: 'utf-8', stdio: 'pipe' });
2379
- const containerId = output.trim();
2380
- socket.emit('container:start:response', { success: true, containerId });
2381
- if (foreground) {
2382
- console.log(`✓ Container started: ${containerId}`);
2383
- }
2384
- }
2385
- catch (error) {
2386
- socket.emit('container:start:response', { success: false, error: error.message });
2387
- }
2388
- });
2389
- // Stop a container
2390
- socket.on('container:stop:request', async (data) => {
2391
- try {
2392
- const runtime = getContainerRuntime();
2393
- if (!runtime) {
2394
- socket.emit('container:stop:response', { success: false, error: 'No container runtime found' });
2395
- return;
2396
- }
2397
- const { containerId } = data;
2398
- execSync(`${runtime} stop ${containerId}`, { stdio: 'ignore' });
2399
- socket.emit('container:stop:response', { success: true });
2400
- if (foreground) {
2401
- console.log(`✓ Container stopped: ${containerId}`);
2402
- }
2403
- }
2404
- catch (error) {
2405
- socket.emit('container:stop:response', { success: false, error: error.message });
2406
- }
2407
- });
2408
- // Remove a container
2409
- socket.on('container:remove:request', async (data) => {
2410
- try {
2411
- const runtime = getContainerRuntime();
2412
- if (!runtime) {
2413
- socket.emit('container:remove:response', { success: false, error: 'No container runtime found' });
2414
- return;
2415
- }
2416
- const { containerId } = data;
2417
- // Force remove even if running
2418
- execSync(`${runtime} rm -f ${containerId}`, { stdio: 'ignore' });
2419
- socket.emit('container:remove:response', { success: true });
2420
- if (foreground) {
2421
- console.log(`✓ Container removed: ${containerId}`);
2422
- }
2423
- }
2424
- catch (error) {
2425
- socket.emit('container:remove:response', { success: false, error: error.message });
2426
- }
2427
- });
2428
- // Get container logs
2429
- socket.on('container:logs:request', async (data) => {
2430
- try {
2431
- const runtime = getContainerRuntime();
2432
- if (!runtime) {
2433
- socket.emit('container:logs:response', { success: false, error: 'No container runtime found' });
2434
- return;
2435
- }
2436
- const { containerId, lines = 100 } = data;
2437
- const logs = execSync(`${runtime} logs --tail ${lines} ${containerId}`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
2438
- socket.emit('container:logs:response', { success: true, logs });
2439
- }
2440
- catch (error) {
2441
- socket.emit('container:logs:response', { success: false, error: error.message });
2442
- }
2443
- });
2444
- // Helper function to get available container runtime
2445
- function getContainerRuntime() {
2446
- try {
2447
- execSync('which docker', { stdio: 'ignore' });
2448
- return 'docker';
2449
- }
2450
- catch {
2451
- try {
2452
- execSync('which podman', { stdio: 'ignore' });
2453
- return 'podman';
2454
- }
2455
- catch {
2456
- return null;
2457
- }
2458
- }
2459
- }
877
+ // Cloudflared handlers (delegated to cloudflaredHandlers module)
878
+ registerCloudflaredHandlers(socket, foreground);
879
+ // Container handlers (delegated to containerHandlers module)
880
+ registerContainerHandlers(socket, foreground, __dirname);
2460
881
  socket.on('disconnect', (reason) => {
2461
882
  if (foreground) {
2462
883
  console.log(`\n⚠️ Disconnected: ${reason}`);