@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/app-child.js CHANGED
@@ -18,10 +18,14 @@ import { Buffer } from 'buffer';
18
18
  import crypto from 'crypto';
19
19
  import { createProject, deleteProject } from './projectManager.js';
20
20
  import { agentSessionManager } from './agentSession.js';
21
- import { getProjectConfig, analyzeProjectWithClaude, saveProjectConfig } from './projectAnalyzer.js';
22
- import { generateRunnerCode } from './runnerGenerator.js';
23
- import { startApp, stopApp, restartApp, getAppStatuses, getAppLogs } from './appManager.js';
24
- import { spawn, execSync } from 'child_process';
21
+ import { registerGitHubHandlers } from './githubHandlers.js';
22
+ import { registerCloudflaredHandlers } from './cloudflaredHandlers.js';
23
+ import { registerContainerHandlers } from './containerHandlers.js';
24
+ import { registerSessionHandlers } from './sessionHandlers.js';
25
+ import { registerAppHandlers } from './appHandlers.js';
26
+ import { registerFsHandlers } from './fsHandlers.js';
27
+ import { registerUpdateHandlers } from './updateHandlers.js';
28
+ import { execSync } from 'child_process';
25
29
  import readline from 'readline';
26
30
  import { fileURLToPath } from 'url';
27
31
  import { createHash } from 'crypto';
@@ -41,21 +45,6 @@ function requestUpdateFromParent() {
41
45
  }
42
46
  }
43
47
  }
44
- /**
45
- * Notify parent that we want to restart after update
46
- */
47
- function requestRestartFromParent() {
48
- const updaterPid = process.env.TTC_UPDATER_PID;
49
- if (updaterPid) {
50
- try {
51
- // Send SIGUSR2 to parent to request restart
52
- process.kill(parseInt(updaterPid, 10), 'SIGUSR2');
53
- }
54
- catch (error) {
55
- console.error('Failed to request restart from parent:', error);
56
- }
57
- }
58
- }
59
48
  // ============ Constants ============
60
49
  const CONFIG_DIR = path.join(os.homedir(), '.talk-to-code');
61
50
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
@@ -104,9 +93,27 @@ async function fetchAiConfig(authToken) {
104
93
  return false;
105
94
  }
106
95
  }
107
- function hasAiCredentials() {
96
+ /** TTL cache for ai-config.json reads */
97
+ let _aiCfgCache = null;
98
+ const _AI_CFG_TTL = 5_000;
99
+ function readAiConfigCached() {
100
+ const now = Date.now();
101
+ if (_aiCfgCache && (now - _aiCfgCache.ts) < _AI_CFG_TTL)
102
+ return _aiCfgCache.raw;
108
103
  try {
109
104
  const raw = fsSync.readFileSync(AI_CONFIG_FILE, 'utf-8');
105
+ _aiCfgCache = { raw, ts: now };
106
+ return raw;
107
+ }
108
+ catch {
109
+ return '';
110
+ }
111
+ }
112
+ function hasAiCredentials() {
113
+ try {
114
+ const raw = readAiConfigCached();
115
+ if (!raw)
116
+ return false;
110
117
  const j = JSON.parse(raw);
111
118
  return typeof j.authToken === 'string' && j.authToken.trim().length > 0;
112
119
  }
@@ -464,136 +471,6 @@ async function registerDevice(name, email) {
464
471
  throw error;
465
472
  }
466
473
  }
467
- async function listSessions() {
468
- try {
469
- const socket = await connect();
470
- socket.emit('sessions:list', (response) => {
471
- const { sessions } = response;
472
- console.log('\nSessions:');
473
- console.log('-'.repeat(60));
474
- sessions.forEach((session, idx) => {
475
- console.log(`${idx + 1}. ${session.id}`);
476
- console.log(` Created: ${new Date(session.createdAt).toLocaleString()}`);
477
- console.log();
478
- });
479
- socket.disconnect();
480
- });
481
- }
482
- catch (error) {
483
- console.error('Failed to list sessions:', error.message);
484
- process.exit(1);
485
- }
486
- }
487
- async function createSession() {
488
- try {
489
- const socket = await connect();
490
- socket.emit('session:create', (response) => {
491
- const { success, session } = response;
492
- if (success && session) {
493
- console.log('Session created!');
494
- console.log('Session ID:', session.id);
495
- socket.disconnect();
496
- }
497
- });
498
- }
499
- catch (error) {
500
- console.error('Failed to create session:', error.message);
501
- process.exit(1);
502
- }
503
- }
504
- async function sendPrompt(sessionId, prompt) {
505
- try {
506
- const socket = await connect();
507
- socket.emit('session:subscribe', { sessionId });
508
- socket.on('prompt:output', ({ data, type }) => {
509
- if (type === 'stdout') {
510
- process.stdout.write(data);
511
- }
512
- else {
513
- process.stderr.write(data);
514
- }
515
- });
516
- socket.on('session:exited', ({ exitCode }) => {
517
- console.log(`\nSession exited with code ${exitCode}`);
518
- socket.disconnect();
519
- });
520
- socket.emit('session:prompt', { sessionId, prompt }, ({ success }) => {
521
- if (!success) {
522
- console.error('Failed to send prompt');
523
- socket.disconnect();
524
- }
525
- });
526
- process.on('SIGINT', () => {
527
- socket.emit('session:unsubscribe', { sessionId });
528
- socket.disconnect();
529
- process.exit(0);
530
- });
531
- }
532
- catch (error) {
533
- console.error('Failed to send prompt:', error.message);
534
- process.exit(1);
535
- }
536
- }
537
- async function listDevices() {
538
- try {
539
- const socket = await connect();
540
- socket.emit('devices:list', (response) => {
541
- const { devices } = response;
542
- console.log('\nRegistered Devices:');
543
- console.log('-'.repeat(60));
544
- devices.forEach((device, idx) => {
545
- console.log(`${idx + 1}. ${device.name}`);
546
- console.log(` ID: ${device.deviceId}`);
547
- console.log(` IP Address: ${device.ipAddress || 'Unknown'}`);
548
- console.log(` Hostname: ${device.hostname || 'Unknown'}`);
549
- console.log(` Registered: ${new Date(device.registeredAt).toLocaleString()}`);
550
- console.log(` Last Seen: ${new Date(device.lastSeen).toLocaleString()}`);
551
- console.log();
552
- });
553
- socket.disconnect();
554
- });
555
- }
556
- catch (error) {
557
- console.error('Failed to list devices:', error.message);
558
- process.exit(1);
559
- }
560
- }
561
- async function monitorSession(sessionId) {
562
- try {
563
- const socket = await connect();
564
- console.log(`Monitoring session ${sessionId}...`);
565
- console.log('Press Ctrl+C to stop\n');
566
- socket.emit('session:subscribe', { sessionId });
567
- socket.on('session:state', ({ outputs, isActive }) => {
568
- console.log(`Session active: ${isActive}`);
569
- console.log('Recent output:');
570
- outputs.slice(-10).forEach(({ type, data }) => {
571
- if (type === 'stdout') {
572
- const dataString = typeof data === 'string' ? data : JSON.stringify(data);
573
- process.stdout.write(dataString);
574
- }
575
- });
576
- });
577
- socket.on('prompt:output', ({ data, type }) => {
578
- if (type === 'stdout') {
579
- process.stdout.write(data);
580
- }
581
- });
582
- socket.on('session:exited', ({ exitCode }) => {
583
- console.log(`\nSession exited with code ${exitCode}`);
584
- socket.disconnect();
585
- });
586
- process.on('SIGINT', () => {
587
- socket.emit('session:unsubscribe', { sessionId });
588
- socket.disconnect();
589
- process.exit(0);
590
- });
591
- }
592
- catch (error) {
593
- console.error('Failed to monitor session:', error.message);
594
- process.exit(1);
595
- }
596
- }
597
474
  // Active sessions map - persists across reconnections
598
475
  const activeSessions = new Map();
599
476
  async function runDaemon(foreground = false, email) {
@@ -648,6 +525,8 @@ async function runDaemon(foreground = false, email) {
648
525
  forceNew: true,
649
526
  upgrade: true,
650
527
  });
528
+ // Wire socket reference for agentSession to fetch history from backend
529
+ agentSessionManager.setSocketRef(socket);
651
530
  if (foreground) {
652
531
  console.log(`Attempting to connect to: ${config.apiUrl}`);
653
532
  }
@@ -723,1556 +602,166 @@ async function runDaemon(foreground = false, email) {
723
602
  callback?.({ success: false, error: error.message });
724
603
  }
725
604
  });
726
- // GitHub handlers
727
- socket.on('github:status', async (data, callback) => {
728
- try {
729
- const { projectPath } = data;
730
- // Check if directory is a git repository
731
- const gitDir = path.join(projectPath, '.git');
732
- let isRepo = false;
733
- try {
734
- const stats = await fs.stat(gitDir);
735
- isRepo = stats.isDirectory();
736
- }
737
- catch {
738
- isRepo = false;
739
- }
740
- if (!isRepo) {
741
- callback?.({ success: true, isRepo: false, hasChanges: false });
742
- return;
743
- }
744
- // Check git status
745
- const { execSync } = await import('child_process');
746
- try {
747
- // Get current branch
748
- const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim();
749
- // Get remote URL
750
- let remote;
751
- try {
752
- remote = execSync('git config --get remote.origin.url', { cwd: projectPath, encoding: 'utf-8' }).trim();
753
- }
754
- catch {
755
- // No remote configured
756
- }
757
- // Check for actual changes (modified, staged, deleted files - ignore untracked)
758
- // Use --short to get concise output, and check for actual changes (not just untracked files)
759
- let hasChanges = false;
760
- const changedFiles = [];
761
- try {
762
- const statusOutput = execSync('git status --porcelain', { cwd: projectPath, encoding: 'utf-8' });
763
- const lines = statusOutput.trim().split('\n').filter(line => line.trim().length > 0);
764
- for (const line of lines) {
765
- const status = line.substring(0, 2).trim();
766
- const filePath = line.substring(2).trim();
767
- // Skip untracked files (??)
768
- if (line.startsWith('??'))
769
- continue;
770
- // Include modified (M), added (A), deleted (D), renamed (R), copied (C)
771
- if (status.includes('M') || status.includes('A') || status.includes('D') || status.includes('R') || status.includes('C')) {
772
- hasChanges = true;
773
- changedFiles.push({ path: filePath, status });
774
- }
775
- }
776
- }
777
- catch {
778
- hasChanges = false;
779
- }
780
- callback?.({ success: true, isRepo: true, hasChanges, branch, remote, changedFiles, changesCount: changedFiles.length });
781
- }
782
- catch (error) {
783
- callback?.({ success: false, error: error.message });
605
+ // GitHub handlers (extracted to cli/githubHandlers.ts)
606
+ registerGitHubHandlers(socket, foreground);
607
+ // FS handlers (extracted to cli/fsHandlers.ts)
608
+ registerFsHandlers(socket);
609
+ // Update handlers (extracted to cli/updateHandlers.ts)
610
+ registerUpdateHandlers(socket, foreground, readConfig, requestUpdateFromParent);
611
+ // Session handlers (extracted to cli/sessionHandlers.ts)
612
+ registerSessionHandlers(socket, foreground, activeSessions, () => socket);
613
+ // App control handlers (extracted to cli/appHandlers.ts)
614
+ registerAppHandlers(socket, foreground);
615
+ socket.on('connect', () => {
616
+ if (foreground) {
617
+ console.log(`✓ Connected to backend at ${config.apiUrl}`);
618
+ if (socket) {
619
+ console.log(` Socket ID: ${socket.id}`);
784
620
  }
785
621
  }
786
- catch (error) {
787
- callback?.({ success: false, error: error.message });
622
+ else {
623
+ console.log('Connected to backend');
624
+ }
625
+ reconnectAttempts = 0;
626
+ // Register as CLI device
627
+ if (socket) {
628
+ socket.emit('register', { type: 'cli', deviceId });
788
629
  }
789
630
  });
790
- socket.on('github:commit', async (data, callback) => {
791
- try {
792
- const { projectPath, message = 'changes' } = data;
793
- // Check if directory is a git repository
794
- const gitDir = path.join(projectPath, '.git');
795
- let isRepo = false;
631
+ socket.on('registered', async ({ type }) => {
632
+ if (foreground) {
633
+ console.log(`✓ Registered as ${type}`);
634
+ }
635
+ else {
636
+ console.log(`Registered as ${type}`);
637
+ }
638
+ // Check for auth token: env TTC_AUTH_TOKEN or device-config.json (from previous approval)
639
+ let authToken = process.env.TTC_AUTH_TOKEN;
640
+ let deviceEmail = email;
641
+ let skipEmailFlow = false;
642
+ // Load from device-config if no env token (reuse state from previous approval)
643
+ if (!authToken) {
796
644
  try {
797
- const stats = await fs.stat(gitDir);
798
- isRepo = stats.isDirectory();
645
+ const deviceConfig = await readDiskDeviceConfig();
646
+ authToken = deviceConfig.authToken;
647
+ if (!deviceEmail && deviceConfig.email)
648
+ deviceEmail = deviceConfig.email;
799
649
  }
800
650
  catch {
801
- callback?.({ success: false, error: 'Not a git repository' });
802
- return;
651
+ // No device config yet
803
652
  }
804
- const { execSync } = await import('child_process');
653
+ }
654
+ if (authToken) {
805
655
  try {
806
- // Stage all changes
807
- execSync('git add -A', { cwd: projectPath });
808
- // Commit with message
809
- execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath });
810
- // Determine which branch to push to (master or main)
811
- let targetBranch;
812
- try {
813
- // Check if master branch exists
814
- execSync('git rev-parse --verify master', { cwd: projectPath, stdio: 'ignore' });
815
- targetBranch = 'master';
816
- }
817
- catch {
818
- try {
819
- // Check if main branch exists
820
- execSync('git rev-parse --verify main', { cwd: projectPath, stdio: 'ignore' });
821
- targetBranch = 'main';
822
- }
823
- catch {
824
- // Fallback to current branch if neither master nor main exists
825
- targetBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim();
656
+ const parts = authToken.split('.');
657
+ if (parts.length === 3) {
658
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
659
+ if (payload.type === 'device_auth' && payload.email) {
660
+ deviceEmail = payload.email;
661
+ skipEmailFlow = true;
662
+ if (foreground) {
663
+ console.log(`✓ Using stored auth token for ${deviceEmail}`);
664
+ }
826
665
  }
827
666
  }
828
- // Push to origin after commit
829
- execSync(`git push origin ${targetBranch}`, { cwd: projectPath, stdio: 'pipe' });
830
- callback?.({ success: true });
831
667
  }
832
- catch (error) {
833
- callback?.({ success: false, error: error.message });
668
+ catch (err) {
669
+ if (foreground) {
670
+ console.warn(`⚠️ Invalid auth token in config, falling back to normal auth`);
671
+ }
672
+ authToken = undefined;
834
673
  }
835
674
  }
836
- catch (error) {
837
- callback?.({ success: false, error: error.message });
838
- }
839
- });
840
- socket.on('github:push', async (data, callback) => {
841
- try {
842
- const { projectPath } = data;
843
- // Check if directory is a git repository
844
- const gitDir = path.join(projectPath, '.git');
845
- let isRepo = false;
675
+ // Load device email from config if not using token and not provided
676
+ if (!deviceEmail && !skipEmailFlow) {
846
677
  try {
847
- const stats = await fs.stat(gitDir);
848
- isRepo = stats.isDirectory();
678
+ const deviceConfig = await readDiskDeviceConfig();
679
+ deviceEmail = deviceConfig.email;
849
680
  }
850
681
  catch {
851
- callback?.({ success: false, error: 'Not a git repository' });
852
- return;
682
+ // No device config yet
853
683
  }
854
- const { execSync } = await import('child_process');
855
- try {
856
- // Determine which branch to push to (master or main)
857
- let targetBranch;
858
- try {
859
- // Check if master branch exists
860
- execSync('git rev-parse --verify master', { cwd: projectPath, stdio: 'ignore' });
861
- targetBranch = 'master';
684
+ }
685
+ // Register device with backend
686
+ let needsApprovalLoggedOnce = false;
687
+ const registerDevice = () => {
688
+ // Determine device name
689
+ const containerName = process.env.CONTAINER_NAME;
690
+ const deviceName = containerName ? `Container: ${containerName}` : `CLI Device ${hostname}`;
691
+ socket.emit('device:register', {
692
+ deviceId,
693
+ name: deviceName,
694
+ ipAddress,
695
+ hostname,
696
+ email: deviceEmail,
697
+ // When using auth token, include the token for verification
698
+ authToken: skipEmailFlow ? authToken : undefined
699
+ }, ({ success, needsApproval, message, device, error }) => {
700
+ if (success && device) {
701
+ needsApprovalLoggedOnce = false;
702
+ if (foreground) {
703
+ console.log(`✓ Device registered: ${device.name}`);
704
+ if (device.approved) {
705
+ console.log(`✓ Device approved and ready`);
706
+ }
707
+ }
708
+ else {
709
+ console.log(`Device registered: ${device.name}${device.approved ? ' (approved)' : ' (pending approval)'}`);
710
+ }
711
+ // Save config: email + authToken (preserve token so next run can reuse)
712
+ if (device.email) {
713
+ void writeDeviceConfigMerged({
714
+ email: device.email,
715
+ ...(authToken ? { authToken } : {}),
716
+ }).catch(() => { });
717
+ }
718
+ if (authToken) {
719
+ scheduleAiConfigSync(authToken);
720
+ }
862
721
  }
863
- catch {
864
- try {
865
- // Check if main branch exists
866
- execSync('git rev-parse --verify main', { cwd: projectPath, stdio: 'ignore' });
867
- targetBranch = 'main';
722
+ else if (needsApproval) {
723
+ // Persist email when approval was requested so restarts don't prompt again
724
+ if (deviceEmail) {
725
+ void writeDeviceConfigMerged({ email: deviceEmail }).catch(() => { });
868
726
  }
869
- catch {
870
- // Fallback to current branch if neither master nor main exists
871
- targetBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath, encoding: 'utf-8' }).trim();
727
+ // Only log once to avoid flooding logs every 30s on heartbeat
728
+ if (!needsApprovalLoggedOnce) {
729
+ needsApprovalLoggedOnce = true;
730
+ if (foreground) {
731
+ console.log(`\n⚠️ ${message || 'Device approval required'}`);
732
+ if (!deviceEmail) {
733
+ console.log(` Please run: npx tsx index.ts register --email your@email.com`);
734
+ }
735
+ else {
736
+ console.log(` Check your email (${deviceEmail}) and click the approval link.`);
737
+ }
738
+ }
739
+ else {
740
+ console.log(`⚠️ Device approval required: ${message || 'Please approve device'}`);
741
+ }
872
742
  }
873
743
  }
874
- // Push to origin
875
- execSync(`git push origin ${targetBranch}`, { cwd: projectPath });
876
- callback?.({ success: true });
877
- }
878
- catch (error) {
879
- callback?.({ success: false, error: error.message });
880
- }
881
- }
882
- catch (error) {
883
- callback?.({ success: false, error: error.message });
884
- }
885
- });
886
- socket.on('github:discard', async (data, callback) => {
887
- try {
888
- const { projectPath } = data;
889
- // Check if directory is a git repository
890
- const gitDir = path.join(projectPath, '.git');
891
- let isRepo = false;
892
- try {
893
- const stats = await fs.stat(gitDir);
894
- isRepo = stats.isDirectory();
895
- }
896
- catch {
897
- callback?.({ success: false, error: 'Not a git repository' });
898
- return;
899
- }
900
- const { execSync } = await import('child_process');
901
- try {
902
- // Discard all changes and revert to latest commit
903
- // Reset all changes (both staged and unstaged)
904
- execSync('git reset --hard HEAD', { cwd: projectPath });
905
- // Clean untracked files and directories
906
- execSync('git clean -fd', { cwd: projectPath });
907
- callback?.({ success: true });
908
- }
909
- catch (error) {
910
- callback?.({ success: false, error: error.message });
911
- }
912
- }
913
- catch (error) {
914
- callback?.({ success: false, error: error.message });
915
- }
916
- });
917
- // Directory listing handler
918
- socket.on('fs:list', async (data) => {
919
- try {
920
- const { dirPath } = data;
921
- if (!dirPath) {
922
- socket?.emit('fs:list:response', { success: false, error: 'dirPath is required' });
923
- return;
924
- }
925
- const entries = [];
926
- try {
927
- const dirents = await fs.readdir(dirPath, { withFileTypes: true });
928
- for (const dirent of dirents) {
929
- if (dirent.name.startsWith('.'))
930
- continue; // Skip hidden files
931
- const fullPath = path.join(dirPath, dirent.name);
932
- entries.push({
933
- name: dirent.name,
934
- path: fullPath,
935
- isDir: dirent.isDirectory()
936
- });
744
+ else if (error) {
745
+ console.error(`✗ Registration error: ${error}`);
937
746
  }
938
- // Sort: directories first, then alphabetically
939
- entries.sort((a, b) => {
940
- if (a.isDir && !b.isDir)
941
- return -1;
942
- if (!a.isDir && b.isDir)
943
- return 1;
944
- return a.name.localeCompare(b.name);
945
- });
946
- socket?.emit('fs:list:response', { success: true, entries });
747
+ });
748
+ };
749
+ registerDevice();
750
+ // Update lastSeen every 30 seconds while connected
751
+ const heartbeatInterval = setInterval(() => {
752
+ if (socket?.connected) {
753
+ registerDevice();
947
754
  }
948
- catch (err) {
949
- socket?.emit('fs:list:response', { success: false, error: err.message });
755
+ else {
756
+ clearInterval(heartbeatInterval);
950
757
  }
951
- }
952
- catch (error) {
953
- socket?.emit('fs:list:response', { success: false, error: error.message });
954
- }
758
+ }, 30000);
759
+ socket.heartbeatInterval = heartbeatInterval;
955
760
  });
956
- // File write handler
957
- socket.on('fs:write', async (data) => {
958
- try {
959
- const { filePath, content, encoding = 'utf-8' } = data;
960
- if (!filePath) {
961
- socket?.emit('fs:write:response', { success: false, error: 'filePath is required' });
962
- return;
963
- }
964
- try {
965
- // Ensure directory exists
966
- const dir = path.dirname(filePath);
967
- await fs.mkdir(dir, { recursive: true });
968
- // Write file
969
- await fs.writeFile(filePath, content, encoding);
970
- socket?.emit('fs:write:response', { success: true });
971
- }
972
- catch (err) {
973
- socket?.emit('fs:write:response', { success: false, error: err.message });
974
- }
975
- }
976
- catch (error) {
977
- socket?.emit('fs:write:response', { success: false, error: error.message });
978
- }
979
- });
980
- // File read handler
981
- socket.on('fs:read', async (data) => {
982
- try {
983
- const { filePath, encoding = 'utf-8' } = data;
984
- if (!filePath) {
985
- socket?.emit('fs:read:response', { success: false, error: 'filePath is required' });
986
- return;
987
- }
988
- try {
989
- // Read file
990
- const content = await fs.readFile(filePath, encoding);
991
- socket?.emit('fs:read:response', { success: true, content });
992
- }
993
- catch (err) {
994
- socket?.emit('fs:read:response', { success: false, error: err.message });
995
- }
996
- }
997
- catch (error) {
998
- socket?.emit('fs:read:response', { success: false, error: error.message });
999
- }
1000
- });
1001
- // ========== Update Handlers ==========
1002
- // Check for updates
1003
- socket.on('update:check', async (data, callback) => {
1004
- try {
1005
- const config = await readConfig();
1006
- const currentHash = createHash('sha256').update(fsSync.readFileSync(fileURLToPath(import.meta.url))).digest('hex');
1007
- const response = await fetch(`${config.apiUrl}/update/check`, {
1008
- method: 'POST',
1009
- headers: { 'Content-Type': 'application/json' },
1010
- body: JSON.stringify({
1011
- hash: currentHash,
1012
- platform: os.platform(),
1013
- arch: os.arch()
1014
- })
1015
- });
1016
- if (!response.ok) {
1017
- callback?.({ success: false, error: `HTTP ${response.status}` });
1018
- return;
1019
- }
1020
- const info = await response.json();
1021
- callback?.({
1022
- success: true,
1023
- updateAvailable: info.updateAvailable,
1024
- version: info.version,
1025
- changelog: info.changelog,
1026
- size: info.size
1027
- });
1028
- }
1029
- catch (error) {
1030
- callback?.({ success: false, error: error.message });
1031
- }
1032
- });
1033
- // Start update
1034
- socket.on('update:start', async (data, callback) => {
1035
- try {
1036
- // Request update from parent updater process
1037
- requestUpdateFromParent();
1038
- callback?.({
1039
- success: true,
1040
- message: 'Update initiated. The CLI will restart automatically when complete.'
1041
- });
1042
- }
1043
- catch (error) {
1044
- callback?.({ success: false, error: error.message });
1045
- }
1046
- });
1047
- // Get version info
1048
- socket.on('version:info', async (data, callback) => {
1049
- try {
1050
- const currentHash = createHash('sha256').update(fsSync.readFileSync(fileURLToPath(import.meta.url))).digest('hex');
1051
- // Try to read version from binary-hashes.json
1052
- let version = 'unknown';
1053
- let date = undefined;
1054
- try {
1055
- const hashesPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'binary-hashes.json');
1056
- const hashes = JSON.parse(fsSync.readFileSync(hashesPath, 'utf-8'));
1057
- if (hashes['js-bundle']) {
1058
- version = hashes['js-bundle'].version || version;
1059
- date = hashes['js-bundle'].date;
1060
- }
1061
- }
1062
- catch { }
1063
- callback?.({
1064
- success: true,
1065
- version,
1066
- hash: currentHash.substring(0, 16) + '...',
1067
- date,
1068
- nodeVersion: process.version,
1069
- platform: os.platform(),
1070
- arch: os.arch()
1071
- });
1072
- }
1073
- catch (error) {
1074
- callback?.({ success: false, error: error.message });
1075
- }
1076
- });
1077
- // Session handlers
1078
- socket.on('session:create', async (data) => {
1079
- try {
1080
- let { sessionId, projectPath } = data;
1081
- // Remap /home/abc to /tmp/abc if /home/abc doesn't exist (container workaround)
1082
- if (projectPath === '/home/abc' && !fsSync.existsSync('/home/abc')) {
1083
- const fallbackPath = '/tmp/abc';
1084
- fsSync.mkdirSync(fallbackPath, { recursive: true });
1085
- projectPath = fallbackPath;
1086
- console.log(`[CLI] Remapped /home/abc -> ${fallbackPath}`);
1087
- }
1088
- activeSessions.set(sessionId, { projectPath });
1089
- if (foreground) {
1090
- console.log(`💬 Session created: ${sessionId}`);
1091
- console.log(` Project: ${projectPath}`);
1092
- }
1093
- else {
1094
- console.log(`Session created: ${sessionId} in project ${projectPath}`);
1095
- }
1096
- }
1097
- catch (error) {
1098
- if (foreground) {
1099
- console.error(`✗ Failed to create session: ${error.message}`);
1100
- }
1101
- else {
1102
- console.error('Failed to create session:', error.message);
1103
- }
1104
- }
1105
- });
1106
- socket.on('session:delete', async (data) => {
1107
- try {
1108
- const { sessionId } = data;
1109
- if (foreground) {
1110
- console.log(`🗑️ Deleting session: ${sessionId}`);
1111
- }
1112
- await agentSessionManager.deleteSession(sessionId);
1113
- activeSessions.delete(sessionId);
1114
- if (foreground) {
1115
- console.log(`✓ Session deleted: ${sessionId}`);
1116
- }
1117
- else {
1118
- console.log(`Session deleted: ${sessionId}`);
1119
- }
1120
- }
1121
- catch (error) {
1122
- if (foreground) {
1123
- console.error(`✗ Failed to delete session: ${error.message}`);
1124
- }
1125
- else {
1126
- console.error('Failed to delete session:', error.message);
1127
- }
1128
- }
1129
- });
1130
- socket.on('project:config:analyze', async (data) => {
1131
- try {
1132
- const { projectId, projectPath, projectName, analysisId } = data;
1133
- if (foreground) {
1134
- console.log(`🔍 Analyzing project: ${projectName} at ${projectPath}`);
1135
- }
1136
- // Run Claude analysis
1137
- const config = await analyzeProjectWithClaude(projectPath, projectName);
1138
- // Save config file (.talk-to-code.json)
1139
- await saveProjectConfig(projectPath, config);
1140
- // Generate and save runner files for each app
1141
- for (const app of config.apps) {
1142
- const runnerCode = generateRunnerCode(app, projectPath);
1143
- const runnerFileName = `${app.name}_runner.ts`;
1144
- const runnerPath = path.join(projectPath, app.directory || '', runnerFileName);
1145
- // Ensure directory exists
1146
- const runnerDir = path.dirname(runnerPath);
1147
- await fs.mkdir(runnerDir, { recursive: true });
1148
- // Write runner file
1149
- await fs.writeFile(runnerPath, runnerCode, 'utf-8');
1150
- if (foreground) {
1151
- console.log(`✓ Generated runner: ${runnerFileName}`);
1152
- }
1153
- }
1154
- if (foreground) {
1155
- console.log(`✓ Analysis complete: Found ${config.apps.length} apps`);
1156
- console.log(`✓ Generated ${config.apps.length} runner files`);
1157
- }
1158
- // Emit result back to backend
1159
- socket.emit('project:config:analyzed', {
1160
- projectId,
1161
- config,
1162
- analysisId
1163
- });
1164
- }
1165
- catch (error) {
1166
- if (foreground) {
1167
- console.error(`✗ Analysis error: ${error.message}`);
1168
- }
1169
- socket.emit('project:config:analyze:error', {
1170
- projectId: data.projectId,
1171
- error: error.message,
1172
- analysisId: data.analysisId
1173
- });
1174
- }
1175
- });
1176
- // App control handlers
1177
- socket.on('app:start', async (data, callback) => {
1178
- try {
1179
- const { projectId, projectPath, appName } = data;
1180
- // Load project config to get app details
1181
- const config = await getProjectConfig(projectPath);
1182
- if (!config) {
1183
- callback?.({ success: false, error: 'Project config not found' });
1184
- return;
1185
- }
1186
- const app = config.apps.find(a => a.name === appName);
1187
- if (!app) {
1188
- callback?.({ success: false, error: `App "${appName}" not found in project config` });
1189
- return;
1190
- }
1191
- const result = await startApp(projectPath, projectId, app);
1192
- if (result.success) {
1193
- if (foreground) {
1194
- console.log(`✓ Started app: ${appName} (PID: ${result.pid})`);
1195
- }
1196
- socket.emit('app:started', {
1197
- projectId,
1198
- appName,
1199
- processId: result.processId,
1200
- pid: result.pid,
1201
- });
1202
- }
1203
- callback?.(result);
1204
- }
1205
- catch (error) {
1206
- if (foreground) {
1207
- console.error(`✗ Error starting app: ${error.message}`);
1208
- }
1209
- callback?.({ success: false, error: error.message });
1210
- }
1211
- });
1212
- socket.on('app:stop', async (data, callback) => {
1213
- try {
1214
- const { projectId, projectPath, appName } = data;
1215
- // Load project config to get app details (for custom stop command)
1216
- const config = await getProjectConfig(projectPath);
1217
- const app = config?.apps.find(a => a.name === appName);
1218
- const result = await stopApp(projectId, appName, app);
1219
- if (result.success) {
1220
- if (foreground) {
1221
- console.log(`✓ Stopped app: ${appName}`);
1222
- }
1223
- socket.emit('app:stopped', {
1224
- projectId,
1225
- appName,
1226
- appControlId: data.appControlId,
1227
- });
1228
- }
1229
- callback?.(result);
1230
- }
1231
- catch (error) {
1232
- if (foreground) {
1233
- console.error(`✗ Error stopping app: ${error.message}`);
1234
- }
1235
- callback?.({ success: false, error: error.message });
1236
- }
1237
- });
1238
- socket.on('app:restart', async (data, callback) => {
1239
- try {
1240
- const { projectId, projectPath, appName } = data;
1241
- // Load project config to get app details
1242
- const config = await getProjectConfig(projectPath);
1243
- if (!config) {
1244
- callback?.({ success: false, error: 'Project config not found' });
1245
- return;
1246
- }
1247
- const app = config.apps.find(a => a.name === appName);
1248
- if (!app) {
1249
- callback?.({ success: false, error: `App "${appName}" not found in project config` });
1250
- return;
1251
- }
1252
- const result = await restartApp(projectPath, projectId, app);
1253
- if (result.success) {
1254
- if (foreground) {
1255
- console.log(`✓ Restarted app: ${appName} (PID: ${result.pid})`);
1256
- }
1257
- socket.emit('app:restarted', {
1258
- projectId,
1259
- appName,
1260
- processId: result.processId,
1261
- pid: result.pid,
1262
- appControlId: data.appControlId,
1263
- });
1264
- }
1265
- callback?.(result);
1266
- }
1267
- catch (error) {
1268
- if (foreground) {
1269
- console.error(`✗ Error restarting app: ${error.message}`);
1270
- }
1271
- callback?.({ success: false, error: error.message });
1272
- }
1273
- });
1274
- socket.on('app:status', async (data, callback) => {
1275
- try {
1276
- const { projectId, projectPath, appName } = data;
1277
- // Load project config
1278
- const config = await getProjectConfig(projectPath);
1279
- if (!config) {
1280
- callback?.({ success: false, error: 'Project config not found' });
1281
- return;
1282
- }
1283
- const appsToCheck = appName
1284
- ? config.apps.filter(a => a.name === appName)
1285
- : config.apps;
1286
- const statuses = getAppStatuses(projectId, appsToCheck);
1287
- // Send response back to backend
1288
- if (data.appControlId) {
1289
- socket.emit('app:control:response', {
1290
- appControlId: data.appControlId,
1291
- success: true,
1292
- apps: statuses,
1293
- });
1294
- }
1295
- callback?.({ success: true, apps: statuses });
1296
- }
1297
- catch (error) {
1298
- if (foreground) {
1299
- console.error(`✗ Error getting app status: ${error.message}`);
1300
- }
1301
- callback?.({ success: false, error: error.message });
1302
- }
1303
- });
1304
- socket.on('app:logs', async (data, callback) => {
1305
- try {
1306
- const { projectId, appName } = data;
1307
- const result = await getAppLogs(projectId, appName, 100);
1308
- // Send response back to backend
1309
- if (data.appControlId) {
1310
- socket.emit('app:control:response', {
1311
- appControlId: data.appControlId,
1312
- ...result,
1313
- });
1314
- }
1315
- callback?.(result);
1316
- }
1317
- catch (error) {
1318
- if (foreground) {
1319
- console.error(`✗ Error getting app logs: ${error.message}`);
1320
- }
1321
- callback?.({ success: false, error: error.message });
1322
- }
1323
- });
1324
- socket.on('session:prompt', async (data) => {
1325
- try {
1326
- const { sessionId, prompt, projectPath: providedProjectPath, promptId, enhancers, model } = data;
1327
- // promptId is REQUIRED when routing to device
1328
- if (!promptId) {
1329
- if (foreground) {
1330
- console.error(`✗ Missing required promptId for session: ${sessionId}`);
1331
- }
1332
- socket.emit('session:error', { sessionId, error: 'Missing required promptId' });
1333
- return;
1334
- }
1335
- // Try to get projectPath from activeSessions or use provided one
1336
- let projectPath = providedProjectPath || activeSessions.get(sessionId)?.projectPath;
1337
- if (!projectPath) {
1338
- if (foreground) {
1339
- console.error(`✗ Session not found: ${sessionId} (missing projectPath)`);
1340
- }
1341
- socket.emit('session:error', { sessionId, error: 'Session not found or projectPath missing' });
1342
- return;
1343
- }
1344
- // Store in activeSessions with promptId and model
1345
- activeSessions.set(sessionId, { projectPath, currentPromptId: promptId, model });
1346
- // Capture promptId in closure to prevent race conditions when multiple prompts arrive quickly
1347
- // This ensures each prompt's messages use the correct promptId even if a new prompt arrives
1348
- const capturedPromptId = promptId;
1349
- if (foreground) {
1350
- console.log(`\n[CLI] 📤 Received prompt for session: ${sessionId}, promptId: ${capturedPromptId}`);
1351
- console.log(`[CLI] Prompt: ${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}`);
1352
- }
1353
- // Create or get session handler
1354
- await agentSessionManager.createSession({
1355
- sessionId,
1356
- projectPath,
1357
- onOutput: (output) => {
1358
- // Serialize data to string if it's an object
1359
- const dataString = typeof output.data === 'string'
1360
- ? output.data
1361
- : JSON.stringify(output.data);
1362
- // Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
1363
- if (!capturedPromptId) {
1364
- console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit prompt:output`);
1365
- return;
1366
- }
1367
- socket.emit('prompt:output', {
1368
- sessionId,
1369
- promptId: capturedPromptId, // Use captured promptId, not currentPromptId
1370
- type: output.type,
1371
- data: dataString,
1372
- timestamp: output.timestamp,
1373
- metadata: output.metadata
1374
- });
1375
- },
1376
- onError: (error) => {
1377
- socket.emit('session:error', { sessionId, error });
1378
- },
1379
- onComplete: (exitCode) => {
1380
- // Note: This onComplete is from createSession, not sendPrompt
1381
- // The actual session:result is emitted from sendPrompt handler below
1382
- // This handler is kept for backward compatibility but may not be used
1383
- },
1384
- });
1385
- // Send prompt - status updates will be emitted from agentSession when processing starts/completes
1386
- await agentSessionManager.sendPrompt(sessionId, prompt, enhancers || [], {
1387
- sessionId,
1388
- projectPath,
1389
- promptId: capturedPromptId,
1390
- model: model, // Pass the model from the session
1391
- attachments: data.attachments, // Pass attachments from frontend
1392
- onStatusUpdate: (status) => {
1393
- // Emit status update from CLI (CLI is source of truth)
1394
- // Use captured promptId to ensure correct prompt is updated
1395
- if (!capturedPromptId) {
1396
- console.error(`[CLI] Missing promptId for status update, cannot emit prompt:updated`);
1397
- return;
1398
- }
1399
- // Always log status updates for debugging (even in background mode)
1400
- console.log(`[CLI] 📊 Status update: promptId=${capturedPromptId}, status=${status}, sessionId=${sessionId}`);
1401
- // Emit status update IMMEDIATELY (real-time)
1402
- socket.emit('prompt:updated', {
1403
- promptId: capturedPromptId, // CRITICAL: Use captured promptId from closure
1404
- sessionId,
1405
- text: prompt,
1406
- status,
1407
- createdAt: new Date().toISOString(),
1408
- ...(status === 'running' ? { startedAt: new Date().toISOString() } : {}),
1409
- ...(status === 'completed' || status === 'error' || status === 'cancelled' ? { completedAt: new Date().toISOString() } : {}),
1410
- messages: []
1411
- });
1412
- },
1413
- onOutput: (output) => {
1414
- // Serialize data to string if it's an object
1415
- const dataString = typeof output.data === 'string'
1416
- ? output.data
1417
- : JSON.stringify(output.data);
1418
- // Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
1419
- if (!capturedPromptId) {
1420
- console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit prompt:output`);
1421
- return;
1422
- }
1423
- if (foreground && output.type === 'stdout') {
1424
- process.stdout.write(dataString);
1425
- }
1426
- socket.emit('prompt:output', {
1427
- sessionId,
1428
- promptId: capturedPromptId, // Use captured promptId, not currentPromptId
1429
- type: output.type,
1430
- data: dataString,
1431
- timestamp: output.timestamp,
1432
- metadata: output.metadata
1433
- });
1434
- },
1435
- onError: (error) => {
1436
- // Error logging is handled by AgentLogger in agentSession.ts
1437
- // Additional console output for foreground mode
1438
- if (foreground) {
1439
- console.error(`\n[CLI] ✗ Session error: ${error}`);
1440
- }
1441
- socket.emit('session:error', { sessionId, error });
1442
- },
1443
- onComplete: (exitCode) => {
1444
- // Completion logging is handled by AgentLogger in agentSession.ts
1445
- // Additional console output for foreground mode
1446
- if (foreground) {
1447
- console.log(`\n[CLI] ✓ Session completed with exit code: ${exitCode ?? 'null'}`);
1448
- }
1449
- // Use captured promptId (not currentPromptId from activeSessions) to prevent race conditions
1450
- if (!capturedPromptId) {
1451
- console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit session:result`);
1452
- return;
1453
- }
1454
- socket.emit('session:result', {
1455
- sessionId,
1456
- promptId: capturedPromptId, // Use captured promptId, not currentPromptId
1457
- exitCode
1458
- });
1459
- }
1460
- });
1461
- }
1462
- catch (error) {
1463
- if (foreground) {
1464
- console.error(`✗ Error processing prompt: ${error.message}`);
1465
- }
1466
- socket.emit('session:error', { sessionId: data.sessionId, error: error.message });
1467
- }
1468
- });
1469
- // Handle prompt cancellation
1470
- socket.on('prompt:cancel', async (data, callback) => {
1471
- try {
1472
- const { promptId, sessionId } = data;
1473
- if (!promptId || !sessionId) {
1474
- callback?.({ success: false, error: 'Missing promptId or sessionId' });
1475
- return;
1476
- }
1477
- if (foreground) {
1478
- console.log(`[CLI] 🛑 Cancelling prompt: ${promptId}`);
1479
- }
1480
- // Cancel the prompt
1481
- const cancelled = await agentSessionManager.cancelPrompt(promptId, sessionId, (status) => {
1482
- // Emit cancelled status update
1483
- socket.emit('prompt:updated', {
1484
- promptId,
1485
- sessionId,
1486
- text: '', // Not needed for status update
1487
- status: 'cancelled',
1488
- createdAt: new Date().toISOString(),
1489
- completedAt: new Date().toISOString(),
1490
- messages: []
1491
- });
1492
- });
1493
- if (cancelled) {
1494
- callback?.({ success: true });
1495
- }
1496
- else {
1497
- callback?.({ success: false, error: 'Prompt not found or already completed' });
1498
- }
1499
- }
1500
- catch (error) {
1501
- if (foreground) {
1502
- console.error(`✗ Error cancelling prompt: ${error.message}`);
1503
- }
1504
- callback?.({ success: false, error: error.message });
1505
- }
1506
- });
1507
- // Handle emergency stop - forcefully halt all session activity
1508
- socket.on('emergency:stop', async (data, callback) => {
1509
- try {
1510
- const { sessionId } = data;
1511
- if (!sessionId) {
1512
- callback?.({ success: false, message: 'Missing sessionId' });
1513
- return;
1514
- }
1515
- if (foreground) {
1516
- console.log(`[CLI] ☠️ EMERGENCY STOP for session: ${sessionId}`);
1517
- }
1518
- // Execute emergency stop
1519
- const result = await agentSessionManager.emergencyStop(sessionId);
1520
- // Emit status update for current prompt if any
1521
- socket.emit('emergency:stopped', {
1522
- sessionId,
1523
- success: result.success,
1524
- message: result.message,
1525
- timestamp: new Date().toISOString()
1526
- });
1527
- callback?.(result);
1528
- }
1529
- catch (error) {
1530
- if (foreground) {
1531
- console.error(`✗ Error during emergency stop: ${error.message}`);
1532
- }
1533
- callback?.({ success: false, message: error.message });
1534
- }
1535
- });
1536
- socket.on('connect', () => {
1537
- if (foreground) {
1538
- console.log(`✓ Connected to backend at ${config.apiUrl}`);
1539
- if (socket) {
1540
- console.log(` Socket ID: ${socket.id}`);
1541
- }
1542
- }
1543
- else {
1544
- console.log('Connected to backend');
1545
- }
1546
- reconnectAttempts = 0;
1547
- // Register as CLI device
1548
- if (socket) {
1549
- socket.emit('register', { type: 'cli', deviceId });
1550
- }
1551
- });
1552
- socket.on('registered', async ({ type }) => {
1553
- if (foreground) {
1554
- console.log(`✓ Registered as ${type}`);
1555
- }
1556
- else {
1557
- console.log(`Registered as ${type}`);
1558
- }
1559
- // Check for auth token: env TTC_AUTH_TOKEN or device-config.json (from previous approval)
1560
- let authToken = process.env.TTC_AUTH_TOKEN;
1561
- let deviceEmail = email;
1562
- let skipEmailFlow = false;
1563
- // Load from device-config if no env token (reuse state from previous approval)
1564
- if (!authToken) {
1565
- try {
1566
- const deviceConfig = await readDiskDeviceConfig();
1567
- authToken = deviceConfig.authToken;
1568
- if (!deviceEmail && deviceConfig.email)
1569
- deviceEmail = deviceConfig.email;
1570
- }
1571
- catch {
1572
- // No device config yet
1573
- }
1574
- }
1575
- if (authToken) {
1576
- try {
1577
- const parts = authToken.split('.');
1578
- if (parts.length === 3) {
1579
- const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
1580
- if (payload.type === 'device_auth' && payload.email) {
1581
- deviceEmail = payload.email;
1582
- skipEmailFlow = true;
1583
- if (foreground) {
1584
- console.log(`✓ Using stored auth token for ${deviceEmail}`);
1585
- }
1586
- }
1587
- }
1588
- }
1589
- catch (err) {
1590
- if (foreground) {
1591
- console.warn(`⚠️ Invalid auth token in config, falling back to normal auth`);
1592
- }
1593
- authToken = undefined;
1594
- }
1595
- }
1596
- // Load device email from config if not using token and not provided
1597
- if (!deviceEmail && !skipEmailFlow) {
1598
- try {
1599
- const deviceConfig = await readDiskDeviceConfig();
1600
- deviceEmail = deviceConfig.email;
1601
- }
1602
- catch {
1603
- // No device config yet
1604
- }
1605
- }
1606
- // Register device with backend
1607
- let needsApprovalLoggedOnce = false;
1608
- const registerDevice = () => {
1609
- // Determine device name
1610
- const containerName = process.env.CONTAINER_NAME;
1611
- const deviceName = containerName ? `Container: ${containerName}` : `CLI Device ${hostname}`;
1612
- socket.emit('device:register', {
1613
- deviceId,
1614
- name: deviceName,
1615
- ipAddress,
1616
- hostname,
1617
- email: deviceEmail,
1618
- // When using auth token, include the token for verification
1619
- authToken: skipEmailFlow ? authToken : undefined
1620
- }, ({ success, needsApproval, message, device, error }) => {
1621
- if (success && device) {
1622
- needsApprovalLoggedOnce = false;
1623
- if (foreground) {
1624
- console.log(`✓ Device registered: ${device.name}`);
1625
- if (device.approved) {
1626
- console.log(`✓ Device approved and ready`);
1627
- }
1628
- }
1629
- else {
1630
- console.log(`Device registered: ${device.name}${device.approved ? ' (approved)' : ' (pending approval)'}`);
1631
- }
1632
- // Save config: email + authToken (preserve token so next run can reuse)
1633
- if (device.email) {
1634
- void writeDeviceConfigMerged({
1635
- email: device.email,
1636
- ...(authToken ? { authToken } : {}),
1637
- }).catch(() => { });
1638
- }
1639
- if (authToken) {
1640
- scheduleAiConfigSync(authToken);
1641
- }
1642
- }
1643
- else if (needsApproval) {
1644
- // Persist email when approval was requested so restarts don't prompt again
1645
- if (deviceEmail) {
1646
- void writeDeviceConfigMerged({ email: deviceEmail }).catch(() => { });
1647
- }
1648
- // Only log once to avoid flooding logs every 30s on heartbeat
1649
- if (!needsApprovalLoggedOnce) {
1650
- needsApprovalLoggedOnce = true;
1651
- if (foreground) {
1652
- console.log(`\n⚠️ ${message || 'Device approval required'}`);
1653
- if (!deviceEmail) {
1654
- console.log(` Please run: npx tsx index.ts register --email your@email.com`);
1655
- }
1656
- else {
1657
- console.log(` Check your email (${deviceEmail}) and click the approval link.`);
1658
- }
1659
- }
1660
- else {
1661
- console.log(`⚠️ Device approval required: ${message || 'Please approve device'}`);
1662
- }
1663
- }
1664
- }
1665
- else if (error) {
1666
- console.error(`✗ Registration error: ${error}`);
1667
- }
1668
- });
1669
- };
1670
- registerDevice();
1671
- // Update lastSeen every 30 seconds while connected
1672
- const heartbeatInterval = setInterval(() => {
1673
- if (socket?.connected) {
1674
- registerDevice();
1675
- }
1676
- else {
1677
- clearInterval(heartbeatInterval);
1678
- }
1679
- }, 30000);
1680
- socket.heartbeatInterval = heartbeatInterval;
1681
- });
1682
- // Cloudflared handlers
1683
- socket.on('cloudflared:check:request', async () => {
1684
- try {
1685
- let installed = false;
1686
- let hasCert = false;
1687
- try {
1688
- // Check if cloudflared is installed
1689
- execSync('which cloudflared', { stdio: 'ignore' });
1690
- installed = true;
1691
- // Check if cert.pem exists
1692
- const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
1693
- try {
1694
- // Use fs.stat to check if file exists (more reliable than access)
1695
- const stats = await fs.stat(certPath);
1696
- hasCert = stats.isFile();
1697
- if (foreground && hasCert) {
1698
- console.log(`✓ Found cert.pem at ${certPath}`);
1699
- }
1700
- }
1701
- catch (err) {
1702
- // File doesn't exist or can't be accessed
1703
- hasCert = false;
1704
- if (foreground) {
1705
- console.log(`✗ cert.pem not found at ${certPath}: ${err.message}`);
1706
- }
1707
- }
1708
- }
1709
- catch {
1710
- installed = false;
1711
- }
1712
- socket.emit('cloudflared:check:response', { installed, hasCert });
1713
- if (foreground) {
1714
- console.log(`Cloudflared check: installed=${installed}, hasCert=${hasCert}`);
1715
- }
1716
- }
1717
- catch (error) {
1718
- socket.emit('cloudflared:check:response', { installed: false, hasCert: false });
1719
- }
1720
- });
1721
- socket.on('cloudflared:sync:request', async () => {
1722
- try {
1723
- const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
1724
- if (foreground) {
1725
- console.log(`Syncing credentials from ${certPath}`);
1726
- }
1727
- // Read cert.pem file
1728
- const certContent = await fs.readFile(certPath, 'utf-8');
1729
- // Extract token between BEGIN and END markers
1730
- const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/);
1731
- if (tokenMatch && tokenMatch[1]) {
1732
- // Decode base64 token
1733
- const tokenBase64 = tokenMatch[1].replace(/\s/g, '');
1734
- const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8');
1735
- const tokenData = JSON.parse(tokenJson);
1736
- // Extract API token, account ID, and zone ID
1737
- const apiToken = tokenData.apiToken;
1738
- const accountId = tokenData.accountID;
1739
- const zoneId = tokenData.zoneID;
1740
- if (foreground) {
1741
- console.log(`✓ Extracted credentials: accountId=${accountId}, zoneId=${zoneId}`);
1742
- }
1743
- socket.emit('cloudflared:sync:complete', {
1744
- accountId,
1745
- accountName: undefined,
1746
- apiToken,
1747
- zoneId
1748
- });
1749
- }
1750
- else {
1751
- const error = 'Failed to extract token from cert.pem';
1752
- if (foreground) {
1753
- console.error(`✗ ${error}`);
1754
- }
1755
- socket.emit('cloudflared:sync:error', { error });
1756
- }
1757
- }
1758
- catch (error) {
1759
- const errorMsg = `Failed to read cert.pem: ${error.message}`;
1760
- if (foreground) {
1761
- console.error(`✗ ${errorMsg}`);
1762
- }
1763
- socket.emit('cloudflared:sync:error', { error: errorMsg });
1764
- }
1765
- });
1766
- socket.on('cloudflared:login:request', async () => {
1767
- try {
1768
- // Run cloudflared tunnel login
1769
- const loginProcess = spawn('cloudflared', ['tunnel', 'login'], {
1770
- stdio: ['ignore', 'pipe', 'pipe']
1771
- });
1772
- let stdout = '';
1773
- let stderr = '';
1774
- let urlEmitted = false;
1775
- let alreadyLoggedIn = false;
1776
- let certPath = null;
1777
- const extractCertPath = (text) => {
1778
- // Look for: "You have an existing certificate at /path/to/cert.pem"
1779
- const pathMatch = text.match(/existing certificate at\s+([^\s]+)/i) ||
1780
- text.match(/certificate at\s+([^\s]+)/i) ||
1781
- text.match(/cert\.pem.*?at\s+([^\s]+)/i);
1782
- return pathMatch ? pathMatch[1] : null;
1783
- };
1784
- const extractLoginUrl = (text) => {
1785
- // Look for URLs in the output
1786
- const urlPatterns = [
1787
- /https:\/\/dash\.cloudflare\.com\/argotunnel[^\s\)]+/g,
1788
- /https:\/\/[^\s\)]+cloudflareaccess\.org[^\s\)]+/g,
1789
- /https:\/\/[^\s\)]+cloudflare\.com[^\s\)]+/g
1790
- ];
1791
- for (const pattern of urlPatterns) {
1792
- const matches = text.match(pattern);
1793
- if (matches && matches.length > 0) {
1794
- return matches[0];
1795
- }
1796
- }
1797
- return null;
1798
- };
1799
- loginProcess.stdout.on('data', (data) => {
1800
- const text = data.toString();
1801
- stdout += text;
1802
- // Check for already logged in error
1803
- if (text.includes('existing certificate') || text.includes('cert.pem which login would overwrite')) {
1804
- alreadyLoggedIn = true;
1805
- const extractedPath = extractCertPath(text);
1806
- if (extractedPath) {
1807
- certPath = extractedPath;
1808
- }
1809
- }
1810
- // Extract login URL if not already logged in
1811
- if (!alreadyLoggedIn && !urlEmitted) {
1812
- const url = extractLoginUrl(text);
1813
- if (url) {
1814
- urlEmitted = true;
1815
- socket.emit('cloudflared:login:url', { loginUrl: url });
1816
- }
1817
- }
1818
- });
1819
- loginProcess.stderr.on('data', (data) => {
1820
- const text = data.toString();
1821
- stderr += text;
1822
- // Check for already logged in error (often in stderr)
1823
- if (text.includes('existing certificate') || text.includes('cert.pem which login would overwrite')) {
1824
- alreadyLoggedIn = true;
1825
- const extractedPath = extractCertPath(text);
1826
- if (extractedPath) {
1827
- certPath = extractedPath;
1828
- }
1829
- }
1830
- // Extract login URL if not already logged in
1831
- if (!alreadyLoggedIn && !urlEmitted) {
1832
- const url = extractLoginUrl(text);
1833
- if (url) {
1834
- urlEmitted = true;
1835
- socket.emit('cloudflared:login:url', { loginUrl: url });
1836
- }
1837
- }
1838
- });
1839
- loginProcess.on('close', async (code) => {
1840
- if (alreadyLoggedIn && certPath) {
1841
- // Already logged in - extract credentials from existing cert
1842
- try {
1843
- if (foreground) {
1844
- console.log(`Already logged in, extracting credentials from ${certPath}`);
1845
- }
1846
- const certContent = await fs.readFile(certPath, 'utf-8');
1847
- const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/);
1848
- if (tokenMatch && tokenMatch[1]) {
1849
- const tokenBase64 = tokenMatch[1].replace(/\s/g, '');
1850
- const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8');
1851
- const tokenData = JSON.parse(tokenJson);
1852
- if (foreground) {
1853
- console.log(`✓ Extracted credentials from existing cert`);
1854
- }
1855
- socket.emit('cloudflared:login:complete', {
1856
- accountId: tokenData.accountID,
1857
- accountName: undefined,
1858
- apiToken: tokenData.apiToken,
1859
- zoneId: tokenData.zoneID
1860
- });
1861
- }
1862
- else {
1863
- const error = 'Failed to extract token from existing cert.pem';
1864
- if (foreground) {
1865
- console.error(`✗ ${error}`);
1866
- }
1867
- socket.emit('cloudflared:login:error', { error });
1868
- }
1869
- }
1870
- catch (error) {
1871
- const errorMsg = `Failed to read cert.pem: ${error.message}`;
1872
- if (foreground) {
1873
- console.error(`✗ ${errorMsg}`);
1874
- }
1875
- socket.emit('cloudflared:login:error', { error: errorMsg });
1876
- }
1877
- }
1878
- else if (code === 0 && !alreadyLoggedIn) {
1879
- // Login completed successfully - wait a moment then extract credentials
1880
- if (foreground) {
1881
- console.log('Login completed, extracting credentials...');
1882
- }
1883
- setTimeout(async () => {
1884
- try {
1885
- const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
1886
- const certContent = await fs.readFile(certPath, 'utf-8');
1887
- const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/);
1888
- if (tokenMatch && tokenMatch[1]) {
1889
- const tokenBase64 = tokenMatch[1].replace(/\s/g, '');
1890
- const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8');
1891
- const tokenData = JSON.parse(tokenJson);
1892
- if (foreground) {
1893
- console.log(`✓ Extracted credentials after login`);
1894
- }
1895
- socket.emit('cloudflared:login:complete', {
1896
- accountId: tokenData.accountID,
1897
- accountName: undefined,
1898
- apiToken: tokenData.apiToken,
1899
- zoneId: tokenData.zoneID
1900
- });
1901
- }
1902
- else {
1903
- const error = 'Failed to extract token from cert.pem after login';
1904
- if (foreground) {
1905
- console.error(`✗ ${error}`);
1906
- }
1907
- socket.emit('cloudflared:login:error', { error });
1908
- }
1909
- }
1910
- catch (error) {
1911
- const errorMsg = `Failed to read cert.pem after login: ${error.message}`;
1912
- if (foreground) {
1913
- console.error(`✗ ${errorMsg}`);
1914
- }
1915
- socket.emit('cloudflared:login:error', { error: errorMsg });
1916
- }
1917
- }, 1000); // Wait 1 second for file to be written
1918
- }
1919
- else if (!alreadyLoggedIn) {
1920
- const error = `Login failed with code ${code}: ${stderr || stdout}`;
1921
- if (foreground) {
1922
- console.error(`✗ ${error}`);
1923
- }
1924
- socket.emit('cloudflared:login:error', { error });
1925
- }
1926
- });
1927
- loginProcess.on('error', (error) => {
1928
- socket.emit('cloudflared:login:error', { error: error.message });
1929
- });
1930
- }
1931
- catch (error) {
1932
- socket.emit('cloudflared:login:error', { error: error.message });
1933
- }
1934
- });
1935
- socket.on('cloudflared:regenerate:request', async () => {
1936
- try {
1937
- const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
1938
- // Delete existing cert.pem
1939
- try {
1940
- await fs.unlink(certPath);
1941
- if (foreground) {
1942
- console.log(`✓ Deleted existing cert.pem`);
1943
- }
1944
- }
1945
- catch (error) {
1946
- // File might not exist, that's okay
1947
- if (foreground && error.code !== 'ENOENT') {
1948
- console.log(`Note: Could not delete cert.pem: ${error.message}`);
1949
- }
1950
- }
1951
- // Emit success - frontend will then trigger login
1952
- socket.emit('cloudflared:regenerate:complete', {});
1953
- }
1954
- catch (error) {
1955
- socket.emit('cloudflared:regenerate:error', { error: error.message });
1956
- }
1957
- });
1958
- socket.on('cloudflared:login:request', async () => {
1959
- try {
1960
- // First check if cloudflared is installed
1961
- try {
1962
- execSync('which cloudflared', { stdio: 'ignore' });
1963
- }
1964
- catch {
1965
- socket.emit('cloudflared:login:error', { error: 'cloudflared is not installed' });
1966
- return;
1967
- }
1968
- // Run cloudflared tunnel login
1969
- // This command outputs a URL that needs to be visited
1970
- const loginProcess = spawn('cloudflared', ['tunnel', 'login'], {
1971
- stdio: ['ignore', 'pipe', 'pipe']
1972
- });
1973
- let stdout = '';
1974
- let stderr = '';
1975
- let urlEmitted = false;
1976
- const findAndEmitUrl = (text) => {
1977
- if (urlEmitted)
1978
- return;
1979
- // Look for URL in output - cloudflared outputs URLs in various formats
1980
- const urlPatterns = [
1981
- /https:\/\/[^\s\)]+/g,
1982
- /https:\/\/[^\n]+/g
1983
- ];
1984
- for (const pattern of urlPatterns) {
1985
- const matches = text.match(pattern);
1986
- if (matches && matches.length > 0) {
1987
- // Find the login URL (usually contains "cloudflareaccess.com" or similar)
1988
- const loginUrl = matches.find(url => url.includes('cloudflareaccess.com') ||
1989
- url.includes('cloudflare.com') ||
1990
- url.includes('trycloudflare.com') ||
1991
- url.includes('dash.cloudflare.com'));
1992
- if (loginUrl) {
1993
- urlEmitted = true;
1994
- socket.emit('cloudflared:login:url', { loginUrl: loginUrl.trim() });
1995
- return;
1996
- }
1997
- }
1998
- }
1999
- };
2000
- loginProcess.stdout.on('data', (data) => {
2001
- const text = data.toString();
2002
- stdout += text;
2003
- findAndEmitUrl(text);
2004
- });
2005
- loginProcess.stderr.on('data', (data) => {
2006
- const text = data.toString();
2007
- stderr += text;
2008
- // cloudflared often outputs URLs to stderr
2009
- findAndEmitUrl(text);
2010
- });
2011
- loginProcess.on('close', async (code) => {
2012
- if (code === 0) {
2013
- // Login successful, extract API token from cert.pem
2014
- try {
2015
- const certPath = path.join(os.homedir(), '.cloudflared', 'cert.pem');
2016
- const certContent = await fs.readFile(certPath, 'utf-8');
2017
- // Extract token between BEGIN and END markers
2018
- const tokenMatch = certContent.match(/-----BEGIN ARGO TUNNEL TOKEN-----\s*([\s\S]*?)\s*-----END ARGO TUNNEL TOKEN-----/);
2019
- if (tokenMatch && tokenMatch[1]) {
2020
- // Decode base64 token
2021
- const tokenBase64 = tokenMatch[1].replace(/\s/g, '');
2022
- const tokenJson = Buffer.from(tokenBase64, 'base64').toString('utf-8');
2023
- const tokenData = JSON.parse(tokenJson);
2024
- // Extract API token, account ID, and zone ID
2025
- const apiToken = tokenData.apiToken;
2026
- const accountId = tokenData.accountID;
2027
- const zoneId = tokenData.zoneID;
2028
- socket.emit('cloudflared:login:complete', {
2029
- accountId,
2030
- accountName: undefined,
2031
- apiToken,
2032
- zoneId
2033
- });
2034
- }
2035
- else {
2036
- socket.emit('cloudflared:login:error', {
2037
- error: 'Failed to extract token from cert.pem'
2038
- });
2039
- }
2040
- }
2041
- catch (error) {
2042
- socket.emit('cloudflared:login:error', {
2043
- error: `Failed to read cert.pem: ${error.message}`
2044
- });
2045
- }
2046
- }
2047
- else {
2048
- socket.emit('cloudflared:login:error', {
2049
- error: `Login failed with code ${code}: ${stderr}`
2050
- });
2051
- }
2052
- });
2053
- loginProcess.on('error', (error) => {
2054
- socket.emit('cloudflared:login:error', { error: error.message });
2055
- });
2056
- }
2057
- catch (error) {
2058
- socket.emit('cloudflared:login:error', { error: error.message });
2059
- }
2060
- });
2061
- // ========== Container Management Handlers ==========
2062
- // Check if container runtime is available
2063
- socket.on('container:check:request', async () => {
2064
- try {
2065
- let enabled = false;
2066
- let runtime;
2067
- let version = '';
2068
- try {
2069
- // Check for docker first
2070
- execSync('which docker', { stdio: 'ignore' });
2071
- runtime = 'docker';
2072
- version = execSync('docker --version', { encoding: 'utf-8' }).trim();
2073
- enabled = true;
2074
- }
2075
- catch {
2076
- try {
2077
- // Check for podman as fallback
2078
- execSync('which podman', { stdio: 'ignore' });
2079
- runtime = 'podman';
2080
- version = execSync('podman --version', { encoding: 'utf-8' }).trim();
2081
- enabled = true;
2082
- }
2083
- catch {
2084
- enabled = false;
2085
- }
2086
- }
2087
- socket.emit('container:check:response', { enabled, runtime, version });
2088
- if (foreground) {
2089
- console.log(`Container runtime check: ${enabled ? `${runtime} (${version})` : 'Not found'}`);
2090
- }
2091
- }
2092
- catch (error) {
2093
- socket.emit('container:check:response', { enabled: false, runtime: undefined, version: '' });
2094
- }
2095
- });
2096
- // List all containers
2097
- socket.on('container:list:request', async () => {
2098
- try {
2099
- const runtime = getContainerRuntime();
2100
- if (!runtime) {
2101
- socket.emit('container:list:response', { success: false, error: 'No container runtime found' });
2102
- return;
2103
- }
2104
- // List all containers including stopped ones
2105
- const output = execSync(`${runtime} ps -a --format "{{json .}}"`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
2106
- const containers = output.trim().split('\n').filter(Boolean).map(line => {
2107
- try {
2108
- const c = JSON.parse(line);
2109
- // Parse ports from the PORTS column (format: "0.0.0.0:8080->80/tcp, 0.0.0.0:9090->9090/tcp")
2110
- const ports = [];
2111
- if (c.Ports) {
2112
- const portMatches = c.Ports.match(/(\d+)->(\d+)/g);
2113
- if (portMatches) {
2114
- portMatches.forEach((p) => {
2115
- const [host, container] = p.split('->').map(Number);
2116
- ports.push({ host, container });
2117
- });
2118
- }
2119
- }
2120
- return {
2121
- containerId: c.ID,
2122
- name: c.Names.replace(/^\//, ''), // Remove leading slash
2123
- image: c.Image,
2124
- status: c.State === 'running' ? 'running' : c.State === 'paused' ? 'paused' : c.Status === 'exited' ? 'exited' : 'stopped',
2125
- ports,
2126
- createdAt: new Date(c.CreatedAt).toISOString()
2127
- };
2128
- }
2129
- catch {
2130
- return null;
2131
- }
2132
- }).filter(Boolean);
2133
- socket.emit('container:list:response', { success: true, containers });
2134
- }
2135
- catch (error) {
2136
- socket.emit('container:list:response', { success: false, error: error.message });
2137
- }
2138
- });
2139
- // Start a new container
2140
- socket.on('container:start:request', async (data) => {
2141
- try {
2142
- const runtime = getContainerRuntime();
2143
- if (!runtime) {
2144
- socket.emit('container:start:response', { success: false, error: 'No container runtime found' });
2145
- return;
2146
- }
2147
- const { name, image, ports = [], env = {}, runAsRoot = false } = data;
2148
- // Get CLI directory path (where this script is running from)
2149
- // Use the same pattern as elsewhere in the file
2150
- const cliDir = path.dirname(CURRENT_FILE);
2151
- // Build docker run command
2152
- let cmd = `${runtime} run -d --name ${name}`;
2153
- // Security: Running as root INSIDE container is safe - it's still isolated from host
2154
- // Root inside container cannot access host filesystem (read-only mounts)
2155
- // Default to root to allow package installation and full container functionality
2156
- // Set runAsRoot: false to run as non-root user (UID 1000) if needed
2157
- if (!runAsRoot) {
2158
- cmd += ' --user 1000:1000';
2159
- }
2160
- // Resource limits
2161
- cmd += ' --memory=8g'; // Limit memory to 8GB
2162
- // Auto-remove on exit (optional - commented out for persistence)
2163
- // cmd += ' --rm'
2164
- // Mount CLI directory into container
2165
- cmd += ` -v "${cliDir}:/opt/ttc:ro"`;
2166
- // Mount entrypoint script
2167
- const entrypointScript = path.join(cliDir, 'container-entrypoint.sh');
2168
- cmd += ` -v "${entrypointScript}:/entrypoint.sh:ro"`;
2169
- // Port mappings
2170
- ports.forEach(p => {
2171
- cmd += ` -p ${p.host}:${p.container}`;
2172
- });
2173
- // Environment variables
2174
- Object.entries(env).forEach(([k, v]) => {
2175
- // Escape quotes in env values
2176
- const escapedValue = String(v).replace(/"/g, '\\"');
2177
- cmd += ` -e ${k}="${escapedValue}"`;
2178
- });
2179
- // Add container-specific environment variables
2180
- cmd += ` -e CONTAINER_NAME="${name}"`;
2181
- cmd += ` -e HOSTNAME="${name}"`;
2182
- // Use entrypoint script
2183
- cmd += ` --entrypoint /bin/sh ${image} /entrypoint.sh`;
2184
- if (foreground) {
2185
- console.log(`Starting container: ${cmd}`);
2186
- }
2187
- // Pull image if not exists, then run
2188
- try {
2189
- execSync(`${runtime} pull ${image}`, { stdio: 'ignore' });
2190
- }
2191
- catch {
2192
- // Pull failed, but might already exist locally
2193
- }
2194
- const output = execSync(cmd, { encoding: 'utf-8', stdio: 'pipe' });
2195
- const containerId = output.trim();
2196
- socket.emit('container:start:response', { success: true, containerId });
2197
- if (foreground) {
2198
- console.log(`✓ Container started: ${containerId}`);
2199
- }
2200
- }
2201
- catch (error) {
2202
- socket.emit('container:start:response', { success: false, error: error.message });
2203
- }
2204
- });
2205
- // Stop a container
2206
- socket.on('container:stop:request', async (data) => {
2207
- try {
2208
- const runtime = getContainerRuntime();
2209
- if (!runtime) {
2210
- socket.emit('container:stop:response', { success: false, error: 'No container runtime found' });
2211
- return;
2212
- }
2213
- const { containerId } = data;
2214
- execSync(`${runtime} stop ${containerId}`, { stdio: 'ignore' });
2215
- socket.emit('container:stop:response', { success: true });
2216
- if (foreground) {
2217
- console.log(`✓ Container stopped: ${containerId}`);
2218
- }
2219
- }
2220
- catch (error) {
2221
- socket.emit('container:stop:response', { success: false, error: error.message });
2222
- }
2223
- });
2224
- // Remove a container
2225
- socket.on('container:remove:request', async (data) => {
2226
- try {
2227
- const runtime = getContainerRuntime();
2228
- if (!runtime) {
2229
- socket.emit('container:remove:response', { success: false, error: 'No container runtime found' });
2230
- return;
2231
- }
2232
- const { containerId } = data;
2233
- // Force remove even if running
2234
- execSync(`${runtime} rm -f ${containerId}`, { stdio: 'ignore' });
2235
- socket.emit('container:remove:response', { success: true });
2236
- if (foreground) {
2237
- console.log(`✓ Container removed: ${containerId}`);
2238
- }
2239
- }
2240
- catch (error) {
2241
- socket.emit('container:remove:response', { success: false, error: error.message });
2242
- }
2243
- });
2244
- // Get container logs
2245
- socket.on('container:logs:request', async (data) => {
2246
- try {
2247
- const runtime = getContainerRuntime();
2248
- if (!runtime) {
2249
- socket.emit('container:logs:response', { success: false, error: 'No container runtime found' });
2250
- return;
2251
- }
2252
- const { containerId, lines = 100 } = data;
2253
- const logs = execSync(`${runtime} logs --tail ${lines} ${containerId}`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
2254
- socket.emit('container:logs:response', { success: true, logs });
2255
- }
2256
- catch (error) {
2257
- socket.emit('container:logs:response', { success: false, error: error.message });
2258
- }
2259
- });
2260
- // Helper function to get available container runtime
2261
- function getContainerRuntime() {
2262
- try {
2263
- execSync('which docker', { stdio: 'ignore' });
2264
- return 'docker';
2265
- }
2266
- catch {
2267
- try {
2268
- execSync('which podman', { stdio: 'ignore' });
2269
- return 'podman';
2270
- }
2271
- catch {
2272
- return null;
2273
- }
2274
- }
2275
- }
761
+ // Cloudflared handlers (extracted to cli/cloudflaredHandlers.ts)
762
+ registerCloudflaredHandlers(socket, foreground);
763
+ // Container handlers (extracted to cli/containerHandlers.ts)
764
+ registerContainerHandlers(socket, foreground, path.dirname(CURRENT_FILE));
2276
765
  socket.on('disconnect', (reason) => {
2277
766
  if (foreground) {
2278
767
  console.log(`\n⚠️ Disconnected: ${reason}`);