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