@exreve/exk 1.0.45 → 1.0.47

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