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