@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/agentSession.js +121 -10
- package/dist/app-child.js +163 -1674
- package/dist/appHandlers.js +142 -0
- package/dist/appManager.js +5 -5
- package/dist/appRunner.js +2 -2
- package/dist/benchmark-startup.js +347 -0
- package/dist/cloudflaredHandlers.js +279 -0
- package/dist/containerHandlers.js +193 -0
- package/dist/fsHandlers.js +86 -0
- package/dist/githubHandlers.js +521 -0
- package/dist/index.js +164 -1743
- package/dist/projectAnalyzer.js +10 -3
- package/dist/projectManager.js +1 -1
- package/dist/runnerGenerator.js +2 -3
- package/dist/sessionHandlers.js +271 -0
- package/dist/ttc-cli.tar.gz +0 -0
- package/dist/updateHandlers.js +82 -0
- package/dist/updater.js +2 -5
- package/package.json +3 -3
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 {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
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
|
-
|
|
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
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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('
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
|
798
|
-
|
|
645
|
+
const deviceConfig = await readDiskDeviceConfig();
|
|
646
|
+
authToken = deviceConfig.authToken;
|
|
647
|
+
if (!deviceEmail && deviceConfig.email)
|
|
648
|
+
deviceEmail = deviceConfig.email;
|
|
799
649
|
}
|
|
800
650
|
catch {
|
|
801
|
-
|
|
802
|
-
return;
|
|
651
|
+
// No device config yet
|
|
803
652
|
}
|
|
804
|
-
|
|
653
|
+
}
|
|
654
|
+
if (authToken) {
|
|
805
655
|
try {
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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 (
|
|
833
|
-
|
|
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
|
-
|
|
837
|
-
|
|
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
|
|
848
|
-
|
|
678
|
+
const deviceConfig = await readDiskDeviceConfig();
|
|
679
|
+
deviceEmail = deviceConfig.email;
|
|
849
680
|
}
|
|
850
681
|
catch {
|
|
851
|
-
|
|
852
|
-
return;
|
|
682
|
+
// No device config yet
|
|
853
683
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
875
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
949
|
-
|
|
755
|
+
else {
|
|
756
|
+
clearInterval(heartbeatInterval);
|
|
950
757
|
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
socket?.emit('fs:list:response', { success: false, error: error.message });
|
|
954
|
-
}
|
|
758
|
+
}, 30000);
|
|
759
|
+
socket.heartbeatInterval = heartbeatInterval;
|
|
955
760
|
});
|
|
956
|
-
//
|
|
957
|
-
socket
|
|
958
|
-
|
|
959
|
-
|
|
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}`);
|