lunel-cli 0.1.12 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +240 -355
- package/package.json +2 -3
package/dist/index.js
CHANGED
|
@@ -7,205 +7,69 @@ import * as fs from "fs/promises";
|
|
|
7
7
|
import * as path from "path";
|
|
8
8
|
import * as os from "os";
|
|
9
9
|
import { spawn, execSync } from "child_process";
|
|
10
|
-
import { createServer } from "net";
|
|
10
|
+
import { createServer, createConnection } from "net";
|
|
11
11
|
const PROXY_URL = process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev";
|
|
12
|
-
const VERSION = "0.1.
|
|
12
|
+
const VERSION = "0.1.3";
|
|
13
13
|
// Root directory - sandbox all file operations to this
|
|
14
14
|
const ROOT_DIR = process.cwd();
|
|
15
|
-
//
|
|
16
|
-
class AnsiParser {
|
|
17
|
-
buffer;
|
|
18
|
-
cursorX = 0;
|
|
19
|
-
cursorY = 0;
|
|
20
|
-
cols;
|
|
21
|
-
rows;
|
|
22
|
-
currentFg = '#ffffff';
|
|
23
|
-
currentBg = '#000000';
|
|
24
|
-
scrollbackBuffer = [];
|
|
25
|
-
// ANSI color codes to hex
|
|
26
|
-
colors = {
|
|
27
|
-
0: '#000000', 1: '#cc0000', 2: '#4e9a06', 3: '#c4a000',
|
|
28
|
-
4: '#3465a4', 5: '#75507b', 6: '#06989a', 7: '#d3d7cf',
|
|
29
|
-
8: '#555753', 9: '#ef2929', 10: '#8ae234', 11: '#fce94f',
|
|
30
|
-
12: '#729fcf', 13: '#ad7fa8', 14: '#34e2e2', 15: '#eeeeec',
|
|
31
|
-
};
|
|
32
|
-
constructor(cols, rows) {
|
|
33
|
-
this.cols = cols;
|
|
34
|
-
this.rows = rows;
|
|
35
|
-
this.buffer = this.createEmptyBuffer();
|
|
36
|
-
}
|
|
37
|
-
createEmptyBuffer() {
|
|
38
|
-
return Array.from({ length: this.rows }, () => Array.from({ length: this.cols }, () => ({
|
|
39
|
-
char: ' ',
|
|
40
|
-
fg: '#ffffff',
|
|
41
|
-
bg: '#000000',
|
|
42
|
-
})));
|
|
43
|
-
}
|
|
44
|
-
scrollUp() {
|
|
45
|
-
// Move first line to scrollback
|
|
46
|
-
this.scrollbackBuffer.push(this.buffer.shift());
|
|
47
|
-
// Keep scrollback limited
|
|
48
|
-
if (this.scrollbackBuffer.length > 1000) {
|
|
49
|
-
this.scrollbackBuffer.shift();
|
|
50
|
-
}
|
|
51
|
-
// Add new empty line at bottom
|
|
52
|
-
this.buffer.push(Array.from({ length: this.cols }, () => ({
|
|
53
|
-
char: ' ',
|
|
54
|
-
fg: '#ffffff',
|
|
55
|
-
bg: '#000000',
|
|
56
|
-
})));
|
|
57
|
-
}
|
|
58
|
-
write(data) {
|
|
59
|
-
let i = 0;
|
|
60
|
-
while (i < data.length) {
|
|
61
|
-
const char = data[i];
|
|
62
|
-
// Check for escape sequence
|
|
63
|
-
if (char === '\x1b' && data[i + 1] === '[') {
|
|
64
|
-
// Parse CSI sequence
|
|
65
|
-
let j = i + 2;
|
|
66
|
-
let params = '';
|
|
67
|
-
while (j < data.length && /[0-9;]/.test(data[j])) {
|
|
68
|
-
params += data[j];
|
|
69
|
-
j++;
|
|
70
|
-
}
|
|
71
|
-
const cmd = data[j];
|
|
72
|
-
i = j + 1;
|
|
73
|
-
this.handleCSI(params, cmd);
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
76
|
-
// Handle special characters
|
|
77
|
-
if (char === '\n') {
|
|
78
|
-
this.cursorY++;
|
|
79
|
-
if (this.cursorY >= this.rows) {
|
|
80
|
-
this.scrollUp();
|
|
81
|
-
this.cursorY = this.rows - 1;
|
|
82
|
-
}
|
|
83
|
-
i++;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
if (char === '\r') {
|
|
87
|
-
this.cursorX = 0;
|
|
88
|
-
i++;
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if (char === '\b') {
|
|
92
|
-
if (this.cursorX > 0)
|
|
93
|
-
this.cursorX--;
|
|
94
|
-
i++;
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
if (char === '\t') {
|
|
98
|
-
this.cursorX = Math.min(this.cols - 1, (Math.floor(this.cursorX / 8) + 1) * 8);
|
|
99
|
-
i++;
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
if (char === '\x07') { // Bell
|
|
103
|
-
i++;
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
// Skip other control characters
|
|
107
|
-
if (char.charCodeAt(0) < 32) {
|
|
108
|
-
i++;
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
// Regular character
|
|
112
|
-
if (this.cursorX >= this.cols) {
|
|
113
|
-
this.cursorX = 0;
|
|
114
|
-
this.cursorY++;
|
|
115
|
-
if (this.cursorY >= this.rows) {
|
|
116
|
-
this.scrollUp();
|
|
117
|
-
this.cursorY = this.rows - 1;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
if (this.cursorY >= 0 && this.cursorY < this.rows &&
|
|
121
|
-
this.cursorX >= 0 && this.cursorX < this.cols) {
|
|
122
|
-
this.buffer[this.cursorY][this.cursorX] = {
|
|
123
|
-
char,
|
|
124
|
-
fg: this.currentFg,
|
|
125
|
-
bg: this.currentBg,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
this.cursorX++;
|
|
129
|
-
i++;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
handleCSI(params, cmd) {
|
|
133
|
-
const args = params.split(';').map(p => parseInt(p) || 0);
|
|
134
|
-
switch (cmd) {
|
|
135
|
-
case 'A': // Cursor up
|
|
136
|
-
this.cursorY = Math.max(0, this.cursorY - (args[0] || 1));
|
|
137
|
-
break;
|
|
138
|
-
case 'B': // Cursor down
|
|
139
|
-
this.cursorY = Math.min(this.rows - 1, this.cursorY + (args[0] || 1));
|
|
140
|
-
break;
|
|
141
|
-
case 'C': // Cursor forward
|
|
142
|
-
this.cursorX = Math.min(this.cols - 1, this.cursorX + (args[0] || 1));
|
|
143
|
-
break;
|
|
144
|
-
case 'D': // Cursor back
|
|
145
|
-
this.cursorX = Math.max(0, this.cursorX - (args[0] || 1));
|
|
146
|
-
break;
|
|
147
|
-
case 'H': // Cursor position
|
|
148
|
-
case 'f':
|
|
149
|
-
this.cursorY = Math.min(this.rows - 1, Math.max(0, (args[0] || 1) - 1));
|
|
150
|
-
this.cursorX = Math.min(this.cols - 1, Math.max(0, (args[1] || 1) - 1));
|
|
151
|
-
break;
|
|
152
|
-
case 'J': // Erase display
|
|
153
|
-
if (args[0] === 2) {
|
|
154
|
-
this.buffer = this.createEmptyBuffer();
|
|
155
|
-
this.cursorX = 0;
|
|
156
|
-
this.cursorY = 0;
|
|
157
|
-
}
|
|
158
|
-
break;
|
|
159
|
-
case 'K': // Erase line
|
|
160
|
-
for (let x = this.cursorX; x < this.cols; x++) {
|
|
161
|
-
this.buffer[this.cursorY][x] = { char: ' ', fg: this.currentFg, bg: this.currentBg };
|
|
162
|
-
}
|
|
163
|
-
break;
|
|
164
|
-
case 'm': // SGR - Select Graphic Rendition
|
|
165
|
-
for (const arg of args) {
|
|
166
|
-
if (arg === 0) {
|
|
167
|
-
this.currentFg = '#ffffff';
|
|
168
|
-
this.currentBg = '#000000';
|
|
169
|
-
}
|
|
170
|
-
else if (arg >= 30 && arg <= 37) {
|
|
171
|
-
this.currentFg = this.colors[arg - 30] || '#ffffff';
|
|
172
|
-
}
|
|
173
|
-
else if (arg >= 40 && arg <= 47) {
|
|
174
|
-
this.currentBg = this.colors[arg - 40] || '#000000';
|
|
175
|
-
}
|
|
176
|
-
else if (arg >= 90 && arg <= 97) {
|
|
177
|
-
this.currentFg = this.colors[arg - 90 + 8] || '#ffffff';
|
|
178
|
-
}
|
|
179
|
-
else if (arg >= 100 && arg <= 107) {
|
|
180
|
-
this.currentBg = this.colors[arg - 100 + 8] || '#000000';
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
break;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
resize(cols, rows) {
|
|
187
|
-
const newBuffer = Array.from({ length: rows }, (_, y) => Array.from({ length: cols }, (_, x) => (y < this.rows && x < this.cols) ? this.buffer[y][x] : { char: ' ', fg: '#ffffff', bg: '#000000' }));
|
|
188
|
-
this.cols = cols;
|
|
189
|
-
this.rows = rows;
|
|
190
|
-
this.buffer = newBuffer;
|
|
191
|
-
this.cursorX = Math.min(this.cursorX, cols - 1);
|
|
192
|
-
this.cursorY = Math.min(this.cursorY, rows - 1);
|
|
193
|
-
}
|
|
194
|
-
getState() {
|
|
195
|
-
return {
|
|
196
|
-
buffer: this.buffer,
|
|
197
|
-
cursorX: this.cursorX,
|
|
198
|
-
cursorY: this.cursorY,
|
|
199
|
-
cols: this.cols,
|
|
200
|
-
rows: this.rows,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
}
|
|
15
|
+
// Terminal sessions
|
|
204
16
|
const terminals = new Map();
|
|
205
17
|
const processes = new Map();
|
|
206
18
|
const processOutputBuffers = new Map();
|
|
207
19
|
// CPU usage tracking
|
|
208
20
|
let lastCpuInfo = null;
|
|
21
|
+
// Proxy tunnel management
|
|
22
|
+
let currentSessionCode = null;
|
|
23
|
+
const activeTunnels = new Map();
|
|
24
|
+
// Popular development server ports to scan on connect
|
|
25
|
+
const DEV_PORTS = [
|
|
26
|
+
1234, // Parcel
|
|
27
|
+
1313, // Hugo
|
|
28
|
+
2000, // Deno (alt)
|
|
29
|
+
3000, // Next.js / CRA / Remix / Express
|
|
30
|
+
3001, // Next.js (alt)
|
|
31
|
+
3002, // Dev server (alt)
|
|
32
|
+
3003, // Dev server (alt)
|
|
33
|
+
3333, // AdonisJS / Nitro
|
|
34
|
+
4000, // Gatsby / Redwood
|
|
35
|
+
4200, // Angular CLI
|
|
36
|
+
4321, // Astro
|
|
37
|
+
4173, // Vite preview
|
|
38
|
+
4444, // Selenium
|
|
39
|
+
4567, // Sinatra
|
|
40
|
+
5000, // Flask / Sails.js
|
|
41
|
+
5001, // Flask (alt)
|
|
42
|
+
5173, // Vite
|
|
43
|
+
5174, // Vite (alt)
|
|
44
|
+
5175, // Vite (alt)
|
|
45
|
+
5500, // Live Server (VS Code)
|
|
46
|
+
5555, // Prisma Studio
|
|
47
|
+
6006, // Storybook
|
|
48
|
+
7000, // Hapi
|
|
49
|
+
7070, // Dev server
|
|
50
|
+
7777, // Dev server
|
|
51
|
+
8000, // Django / Laravel / Gatsby
|
|
52
|
+
8001, // Django (alt)
|
|
53
|
+
8008, // Dev server
|
|
54
|
+
8010, // Dev server
|
|
55
|
+
8080, // Vue CLI / Webpack Dev Server / Spring Boot
|
|
56
|
+
8081, // Metro Bundler
|
|
57
|
+
8082, // Dev server (alt)
|
|
58
|
+
8100, // Ionic
|
|
59
|
+
8200, // Vault
|
|
60
|
+
8443, // HTTPS dev server
|
|
61
|
+
8787, // Wrangler (Cloudflare Workers)
|
|
62
|
+
8888, // Jupyter Notebook
|
|
63
|
+
8899, // Dev server
|
|
64
|
+
9000, // PHP / SonarQube
|
|
65
|
+
9090, // Prometheus / Cockpit
|
|
66
|
+
9200, // Elasticsearch
|
|
67
|
+
9229, // Node.js debugger
|
|
68
|
+
9292, // Rack (Ruby)
|
|
69
|
+
10000, // Webmin
|
|
70
|
+
19006, // Expo web
|
|
71
|
+
24678, // Vite HMR WebSocket
|
|
72
|
+
];
|
|
209
73
|
// ============================================================================
|
|
210
74
|
// Path Safety
|
|
211
75
|
// ============================================================================
|
|
@@ -669,122 +533,37 @@ async function handleGitDiscard(payload) {
|
|
|
669
533
|
// ============================================================================
|
|
670
534
|
let dataChannel = null;
|
|
671
535
|
function handleTerminalSpawn(payload) {
|
|
536
|
+
const shell = payload.shell || process.env.SHELL || "/bin/sh";
|
|
672
537
|
const cols = payload.cols || 80;
|
|
673
538
|
const rows = payload.rows || 24;
|
|
674
|
-
// Find a working shell
|
|
675
|
-
let shell = payload.shell || process.env.SHELL || '';
|
|
676
|
-
const possibleShells = os.platform() === 'win32'
|
|
677
|
-
? ['powershell.exe', 'cmd.exe']
|
|
678
|
-
: [shell, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
|
|
679
|
-
shell = '';
|
|
680
|
-
for (const candidate of possibleShells) {
|
|
681
|
-
if (!candidate)
|
|
682
|
-
continue;
|
|
683
|
-
try {
|
|
684
|
-
if (os.platform() !== 'win32') {
|
|
685
|
-
execSync(`test -x "${candidate}"`, { stdio: 'ignore' });
|
|
686
|
-
}
|
|
687
|
-
shell = candidate;
|
|
688
|
-
break;
|
|
689
|
-
}
|
|
690
|
-
catch {
|
|
691
|
-
// Try next
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
if (!shell) {
|
|
695
|
-
throw Object.assign(new Error('No valid shell found'), { code: 'ENOSHELL' });
|
|
696
|
-
}
|
|
697
539
|
const terminalId = `term-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
cleanEnv['LINES'] = rows.toString();
|
|
708
|
-
// Create ANSI parser for terminal state
|
|
709
|
-
const parser = new AnsiParser(cols, rows);
|
|
710
|
-
// Spawn shell directly with interactive flag
|
|
711
|
-
let proc;
|
|
712
|
-
const isWindows = os.platform() === 'win32';
|
|
713
|
-
console.log(`[Terminal] Spawning shell: ${shell} in ${ROOT_DIR}`);
|
|
714
|
-
// Force a simple prompt and disable fancy terminal features
|
|
715
|
-
cleanEnv['PS1'] = '$ ';
|
|
716
|
-
cleanEnv['PROMPT'] = '$ ';
|
|
717
|
-
cleanEnv['TERM'] = 'dumb';
|
|
718
|
-
if (isWindows) {
|
|
719
|
-
proc = spawn(shell, [], {
|
|
720
|
-
cwd: ROOT_DIR,
|
|
721
|
-
env: cleanEnv,
|
|
722
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
723
|
-
shell: true,
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
else {
|
|
727
|
-
// Spawn shell with flags to disable rc files and work non-interactively
|
|
728
|
-
const shellName = path.basename(shell);
|
|
729
|
-
let args = [];
|
|
730
|
-
if (shellName === 'zsh') {
|
|
731
|
-
// -f: no rc files, -s: read from stdin
|
|
732
|
-
args = ['-f', '-s'];
|
|
733
|
-
}
|
|
734
|
-
else if (shellName === 'bash') {
|
|
735
|
-
// --norc --noprofile: no rc files, -s: read from stdin
|
|
736
|
-
args = ['--norc', '--noprofile', '-s'];
|
|
737
|
-
}
|
|
738
|
-
else {
|
|
739
|
-
args = ['-s'];
|
|
740
|
-
}
|
|
741
|
-
proc = spawn(shell, args, {
|
|
742
|
-
cwd: ROOT_DIR,
|
|
743
|
-
env: cleanEnv,
|
|
744
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
console.log(`[Terminal] Spawned with PID: ${proc.pid}`);
|
|
748
|
-
terminals.set(terminalId, {
|
|
749
|
-
proc,
|
|
750
|
-
shell,
|
|
751
|
-
cols,
|
|
752
|
-
rows,
|
|
753
|
-
buffer: parser.getState().buffer,
|
|
754
|
-
cursorX: 0,
|
|
755
|
-
cursorY: 0,
|
|
756
|
-
parser,
|
|
540
|
+
const proc = spawn(shell, [], {
|
|
541
|
+
cwd: ROOT_DIR,
|
|
542
|
+
env: {
|
|
543
|
+
...process.env,
|
|
544
|
+
TERM: "xterm-256color",
|
|
545
|
+
COLUMNS: cols.toString(),
|
|
546
|
+
LINES: rows.toString(),
|
|
547
|
+
},
|
|
548
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
757
549
|
});
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
parser.write(str);
|
|
762
|
-
const state = parser.getState();
|
|
550
|
+
terminals.set(terminalId, proc);
|
|
551
|
+
// Stream output to app via data channel
|
|
552
|
+
const sendOutput = (data) => {
|
|
763
553
|
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
764
554
|
const msg = {
|
|
765
555
|
v: 1,
|
|
766
556
|
id: `evt-${Date.now()}`,
|
|
767
557
|
ns: "terminal",
|
|
768
|
-
action: "
|
|
769
|
-
payload: {
|
|
770
|
-
terminalId,
|
|
771
|
-
buffer: state.buffer,
|
|
772
|
-
cursorX: state.cursorX,
|
|
773
|
-
cursorY: state.cursorY,
|
|
774
|
-
cols: state.cols,
|
|
775
|
-
rows: state.rows,
|
|
776
|
-
},
|
|
558
|
+
action: "output",
|
|
559
|
+
payload: { terminalId, data: data.toString() },
|
|
777
560
|
};
|
|
778
561
|
dataChannel.send(JSON.stringify(msg));
|
|
779
562
|
}
|
|
780
563
|
};
|
|
781
|
-
proc.stdout?.on(
|
|
782
|
-
proc.stderr?.on(
|
|
783
|
-
proc.on(
|
|
784
|
-
console.error(`[Terminal] Process error:`, err);
|
|
785
|
-
});
|
|
786
|
-
proc.on('close', (code) => {
|
|
787
|
-
console.log(`[Terminal] Process closed with code: ${code}`);
|
|
564
|
+
proc.stdout?.on("data", sendOutput);
|
|
565
|
+
proc.stderr?.on("data", sendOutput);
|
|
566
|
+
proc.on("close", (code) => {
|
|
788
567
|
terminals.delete(terminalId);
|
|
789
568
|
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
790
569
|
const msg = {
|
|
@@ -792,33 +571,12 @@ function handleTerminalSpawn(payload) {
|
|
|
792
571
|
id: `evt-${Date.now()}`,
|
|
793
572
|
ns: "terminal",
|
|
794
573
|
action: "exit",
|
|
795
|
-
payload: { terminalId, code
|
|
574
|
+
payload: { terminalId, code },
|
|
796
575
|
};
|
|
797
576
|
dataChannel.send(JSON.stringify(msg));
|
|
798
577
|
}
|
|
799
578
|
});
|
|
800
|
-
|
|
801
|
-
setTimeout(() => {
|
|
802
|
-
const state = parser.getState();
|
|
803
|
-
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
804
|
-
const msg = {
|
|
805
|
-
v: 1,
|
|
806
|
-
id: `evt-${Date.now()}`,
|
|
807
|
-
ns: "terminal",
|
|
808
|
-
action: "state",
|
|
809
|
-
payload: {
|
|
810
|
-
terminalId,
|
|
811
|
-
buffer: state.buffer,
|
|
812
|
-
cursorX: state.cursorX,
|
|
813
|
-
cursorY: state.cursorY,
|
|
814
|
-
cols: state.cols,
|
|
815
|
-
rows: state.rows,
|
|
816
|
-
},
|
|
817
|
-
};
|
|
818
|
-
dataChannel.send(JSON.stringify(msg));
|
|
819
|
-
}
|
|
820
|
-
}, 100);
|
|
821
|
-
return { terminalId, shell, cols, rows };
|
|
579
|
+
return { terminalId };
|
|
822
580
|
}
|
|
823
581
|
function handleTerminalWrite(payload) {
|
|
824
582
|
const terminalId = payload.terminalId;
|
|
@@ -827,10 +585,10 @@ function handleTerminalWrite(payload) {
|
|
|
827
585
|
throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
|
|
828
586
|
if (typeof data !== "string")
|
|
829
587
|
throw Object.assign(new Error("data is required"), { code: "EINVAL" });
|
|
830
|
-
const
|
|
831
|
-
if (!
|
|
588
|
+
const proc = terminals.get(terminalId);
|
|
589
|
+
if (!proc)
|
|
832
590
|
throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
|
|
833
|
-
|
|
591
|
+
proc.stdin?.write(data);
|
|
834
592
|
return {};
|
|
835
593
|
}
|
|
836
594
|
function handleTerminalResize(payload) {
|
|
@@ -839,46 +597,21 @@ function handleTerminalResize(payload) {
|
|
|
839
597
|
const rows = payload.rows;
|
|
840
598
|
if (!terminalId)
|
|
841
599
|
throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
const session = terminals.get(terminalId);
|
|
845
|
-
if (!session)
|
|
600
|
+
const proc = terminals.get(terminalId);
|
|
601
|
+
if (!proc)
|
|
846
602
|
throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
session.cols = cols;
|
|
850
|
-
session.rows = rows;
|
|
851
|
-
// Send SIGWINCH to process (resize signal) - won't work without real PTY
|
|
852
|
-
// But we update our internal state
|
|
853
|
-
// Send updated state
|
|
854
|
-
const state = session.parser.getState();
|
|
855
|
-
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
856
|
-
const msg = {
|
|
857
|
-
v: 1,
|
|
858
|
-
id: `evt-${Date.now()}`,
|
|
859
|
-
ns: "terminal",
|
|
860
|
-
action: "state",
|
|
861
|
-
payload: {
|
|
862
|
-
terminalId,
|
|
863
|
-
buffer: state.buffer,
|
|
864
|
-
cursorX: state.cursorX,
|
|
865
|
-
cursorY: state.cursorY,
|
|
866
|
-
cols: state.cols,
|
|
867
|
-
rows: state.rows,
|
|
868
|
-
},
|
|
869
|
-
};
|
|
870
|
-
dataChannel.send(JSON.stringify(msg));
|
|
871
|
-
}
|
|
603
|
+
// Note: For proper PTY resize, you'd need node-pty
|
|
604
|
+
// This is a simplified version
|
|
872
605
|
return {};
|
|
873
606
|
}
|
|
874
607
|
function handleTerminalKill(payload) {
|
|
875
608
|
const terminalId = payload.terminalId;
|
|
876
609
|
if (!terminalId)
|
|
877
610
|
throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
|
|
878
|
-
const
|
|
879
|
-
if (!
|
|
611
|
+
const proc = terminals.get(terminalId);
|
|
612
|
+
if (!proc)
|
|
880
613
|
throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
|
|
881
|
-
|
|
614
|
+
proc.kill();
|
|
882
615
|
terminals.delete(terminalId);
|
|
883
616
|
return {};
|
|
884
617
|
}
|
|
@@ -888,7 +621,7 @@ function handleTerminalKill(payload) {
|
|
|
888
621
|
function handleSystemCapabilities() {
|
|
889
622
|
return {
|
|
890
623
|
version: VERSION,
|
|
891
|
-
namespaces: ["fs", "git", "terminal", "processes", "ports", "monitor", "http"],
|
|
624
|
+
namespaces: ["fs", "git", "terminal", "processes", "ports", "monitor", "http", "proxy"],
|
|
892
625
|
platform: os.platform(),
|
|
893
626
|
rootDir: ROOT_DIR,
|
|
894
627
|
hostname: os.hostname(),
|
|
@@ -1352,6 +1085,128 @@ async function handleHttpRequest(payload) {
|
|
|
1352
1085
|
}
|
|
1353
1086
|
}
|
|
1354
1087
|
// ============================================================================
|
|
1088
|
+
// Proxy Handlers
|
|
1089
|
+
// ============================================================================
|
|
1090
|
+
async function scanDevPorts() {
|
|
1091
|
+
const openPorts = [];
|
|
1092
|
+
const checks = DEV_PORTS.map((port) => {
|
|
1093
|
+
return new Promise((resolve) => {
|
|
1094
|
+
const socket = createConnection({ port, host: "127.0.0.1" });
|
|
1095
|
+
socket.setTimeout(200);
|
|
1096
|
+
socket.on("connect", () => {
|
|
1097
|
+
openPorts.push(port);
|
|
1098
|
+
socket.destroy();
|
|
1099
|
+
resolve();
|
|
1100
|
+
});
|
|
1101
|
+
socket.on("timeout", () => {
|
|
1102
|
+
socket.destroy();
|
|
1103
|
+
resolve();
|
|
1104
|
+
});
|
|
1105
|
+
socket.on("error", () => {
|
|
1106
|
+
resolve();
|
|
1107
|
+
});
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
await Promise.all(checks);
|
|
1111
|
+
return openPorts.sort((a, b) => a - b);
|
|
1112
|
+
}
|
|
1113
|
+
async function handleProxyConnect(payload) {
|
|
1114
|
+
const tunnelId = payload.tunnelId;
|
|
1115
|
+
const port = payload.port;
|
|
1116
|
+
if (!tunnelId)
|
|
1117
|
+
throw Object.assign(new Error("tunnelId is required"), { code: "EINVAL" });
|
|
1118
|
+
if (!port)
|
|
1119
|
+
throw Object.assign(new Error("port is required"), { code: "EINVAL" });
|
|
1120
|
+
if (!currentSessionCode)
|
|
1121
|
+
throw Object.assign(new Error("no active session"), { code: "ENOENT" });
|
|
1122
|
+
// 1. Open TCP connection to the local service
|
|
1123
|
+
const tcpSocket = createConnection({ port, host: "127.0.0.1" });
|
|
1124
|
+
await new Promise((resolve, reject) => {
|
|
1125
|
+
const timeout = setTimeout(() => {
|
|
1126
|
+
tcpSocket.destroy();
|
|
1127
|
+
reject(Object.assign(new Error(`TCP connect timeout to localhost:${port}`), { code: "ETIMEOUT" }));
|
|
1128
|
+
}, 5000);
|
|
1129
|
+
tcpSocket.on("connect", () => {
|
|
1130
|
+
clearTimeout(timeout);
|
|
1131
|
+
resolve();
|
|
1132
|
+
});
|
|
1133
|
+
tcpSocket.on("error", (err) => {
|
|
1134
|
+
clearTimeout(timeout);
|
|
1135
|
+
reject(Object.assign(new Error(`TCP connect failed: ${err.message}`), { code: "ECONNREFUSED" }));
|
|
1136
|
+
});
|
|
1137
|
+
});
|
|
1138
|
+
// 2. Open proxy WebSocket to gateway
|
|
1139
|
+
const wsBase = PROXY_URL.replace(/^http/, "ws");
|
|
1140
|
+
const proxyWsUrl = `${wsBase}/v1/ws/proxy?code=${currentSessionCode}&tunnelId=${tunnelId}&role=cli`;
|
|
1141
|
+
const proxyWs = new WebSocket(proxyWsUrl);
|
|
1142
|
+
await new Promise((resolve, reject) => {
|
|
1143
|
+
const timeout = setTimeout(() => {
|
|
1144
|
+
proxyWs.close();
|
|
1145
|
+
tcpSocket.destroy();
|
|
1146
|
+
reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
|
|
1147
|
+
}, 5000);
|
|
1148
|
+
proxyWs.on("open", () => {
|
|
1149
|
+
clearTimeout(timeout);
|
|
1150
|
+
resolve();
|
|
1151
|
+
});
|
|
1152
|
+
proxyWs.on("error", (err) => {
|
|
1153
|
+
clearTimeout(timeout);
|
|
1154
|
+
tcpSocket.destroy();
|
|
1155
|
+
reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
// 3. Store the tunnel
|
|
1159
|
+
activeTunnels.set(tunnelId, { tunnelId, port, tcpSocket, proxyWs });
|
|
1160
|
+
// 4. Pipe: TCP data -> proxy WS (as binary)
|
|
1161
|
+
tcpSocket.on("data", (chunk) => {
|
|
1162
|
+
if (proxyWs.readyState === WebSocket.OPEN) {
|
|
1163
|
+
proxyWs.send(chunk);
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
// 5. Pipe: proxy WS -> TCP socket (as binary)
|
|
1167
|
+
proxyWs.on("message", (data) => {
|
|
1168
|
+
if (!tcpSocket.destroyed) {
|
|
1169
|
+
tcpSocket.write(data);
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
// 6. Close cascade: TCP closes -> close WS
|
|
1173
|
+
tcpSocket.on("close", () => {
|
|
1174
|
+
activeTunnels.delete(tunnelId);
|
|
1175
|
+
if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
|
|
1176
|
+
proxyWs.close();
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
tcpSocket.on("error", () => {
|
|
1180
|
+
activeTunnels.delete(tunnelId);
|
|
1181
|
+
if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
|
|
1182
|
+
proxyWs.close();
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
// 7. Close cascade: WS closes -> close TCP
|
|
1186
|
+
proxyWs.on("close", () => {
|
|
1187
|
+
activeTunnels.delete(tunnelId);
|
|
1188
|
+
if (!tcpSocket.destroyed) {
|
|
1189
|
+
tcpSocket.destroy();
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
proxyWs.on("error", () => {
|
|
1193
|
+
activeTunnels.delete(tunnelId);
|
|
1194
|
+
if (!tcpSocket.destroyed) {
|
|
1195
|
+
tcpSocket.destroy();
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
return { tunnelId, port };
|
|
1199
|
+
}
|
|
1200
|
+
function cleanupAllTunnels() {
|
|
1201
|
+
for (const [, tunnel] of activeTunnels) {
|
|
1202
|
+
tunnel.tcpSocket.destroy();
|
|
1203
|
+
if (tunnel.proxyWs.readyState === WebSocket.OPEN) {
|
|
1204
|
+
tunnel.proxyWs.close();
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
activeTunnels.clear();
|
|
1208
|
+
}
|
|
1209
|
+
// ============================================================================
|
|
1355
1210
|
// Message Router
|
|
1356
1211
|
// ============================================================================
|
|
1357
1212
|
async function processMessage(message) {
|
|
@@ -1539,6 +1394,15 @@ async function processMessage(message) {
|
|
|
1539
1394
|
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
1540
1395
|
}
|
|
1541
1396
|
break;
|
|
1397
|
+
case "proxy":
|
|
1398
|
+
switch (action) {
|
|
1399
|
+
case "connect":
|
|
1400
|
+
result = await handleProxyConnect(payload);
|
|
1401
|
+
break;
|
|
1402
|
+
default:
|
|
1403
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
1404
|
+
}
|
|
1405
|
+
break;
|
|
1542
1406
|
default:
|
|
1543
1407
|
throw Object.assign(new Error(`Unknown namespace: ${ns}`), { code: "EINVAL" });
|
|
1544
1408
|
}
|
|
@@ -1585,6 +1449,7 @@ function displayQR(code) {
|
|
|
1585
1449
|
});
|
|
1586
1450
|
}
|
|
1587
1451
|
function connectWebSocket(code) {
|
|
1452
|
+
currentSessionCode = code;
|
|
1588
1453
|
const wsBase = PROXY_URL.replace(/^http/, "ws");
|
|
1589
1454
|
const controlUrl = `${wsBase}/v1/ws/cli/control?code=${code}`;
|
|
1590
1455
|
const dataUrl = `${wsBase}/v1/ws/cli/data?code=${code}`;
|
|
@@ -1616,6 +1481,23 @@ function connectWebSocket(code) {
|
|
|
1616
1481
|
}
|
|
1617
1482
|
if (message.type === "peer_connected") {
|
|
1618
1483
|
console.log("App connected!\n");
|
|
1484
|
+
// Scan dev ports and notify app
|
|
1485
|
+
scanDevPorts().then((openPorts) => {
|
|
1486
|
+
if (openPorts.length > 0) {
|
|
1487
|
+
console.log(`Found ${openPorts.length} open dev port(s): ${openPorts.join(", ")}`);
|
|
1488
|
+
}
|
|
1489
|
+
if (controlWs.readyState === WebSocket.OPEN) {
|
|
1490
|
+
controlWs.send(JSON.stringify({
|
|
1491
|
+
v: 1,
|
|
1492
|
+
id: `evt-${Date.now()}`,
|
|
1493
|
+
ns: "proxy",
|
|
1494
|
+
action: "ports_discovered",
|
|
1495
|
+
payload: { ports: openPorts },
|
|
1496
|
+
}));
|
|
1497
|
+
}
|
|
1498
|
+
}).catch((err) => {
|
|
1499
|
+
console.error("Port scan failed:", err);
|
|
1500
|
+
});
|
|
1619
1501
|
return;
|
|
1620
1502
|
}
|
|
1621
1503
|
if (message.type === "peer_disconnected") {
|
|
@@ -1634,6 +1516,7 @@ function connectWebSocket(code) {
|
|
|
1634
1516
|
});
|
|
1635
1517
|
controlWs.on("close", (code, reason) => {
|
|
1636
1518
|
console.log(`\nControl channel disconnected (${code}: ${reason.toString()})`);
|
|
1519
|
+
cleanupAllTunnels();
|
|
1637
1520
|
dataWs.close();
|
|
1638
1521
|
process.exit(0);
|
|
1639
1522
|
});
|
|
@@ -1664,6 +1547,7 @@ function connectWebSocket(code) {
|
|
|
1664
1547
|
});
|
|
1665
1548
|
dataWs.on("close", (code, reason) => {
|
|
1666
1549
|
console.log(`\nData channel disconnected (${code}: ${reason.toString()})`);
|
|
1550
|
+
cleanupAllTunnels();
|
|
1667
1551
|
controlWs.close();
|
|
1668
1552
|
process.exit(0);
|
|
1669
1553
|
});
|
|
@@ -1674,8 +1558,8 @@ function connectWebSocket(code) {
|
|
|
1674
1558
|
process.on("SIGINT", () => {
|
|
1675
1559
|
console.log("\nShutting down...");
|
|
1676
1560
|
// Kill all terminals
|
|
1677
|
-
for (const [id,
|
|
1678
|
-
|
|
1561
|
+
for (const [id, proc] of terminals) {
|
|
1562
|
+
proc.kill();
|
|
1679
1563
|
}
|
|
1680
1564
|
terminals.clear();
|
|
1681
1565
|
// Kill all managed processes
|
|
@@ -1684,6 +1568,7 @@ function connectWebSocket(code) {
|
|
|
1684
1568
|
}
|
|
1685
1569
|
processes.clear();
|
|
1686
1570
|
processOutputBuffers.clear();
|
|
1571
|
+
cleanupAllTunnels();
|
|
1687
1572
|
controlWs.close();
|
|
1688
1573
|
dataWs.close();
|
|
1689
1574
|
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lunel-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"author": [
|
|
5
5
|
{
|
|
6
6
|
"name": "Soham Bharambe",
|
|
@@ -27,8 +27,7 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"ignore": "^6.0.2",
|
|
29
29
|
"qrcode-terminal": "^0.12.0",
|
|
30
|
-
"ws": "^8.18.0"
|
|
31
|
-
"@xterm/headless": "^5.5.0"
|
|
30
|
+
"ws": "^8.18.0"
|
|
32
31
|
},
|
|
33
32
|
"devDependencies": {
|
|
34
33
|
"@types/node": "^20.0.0",
|