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.
Files changed (2) hide show
  1. package/dist/index.js +240 -355
  2. 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.11";
12
+ const VERSION = "0.1.3";
13
13
  // Root directory - sandbox all file operations to this
14
14
  const ROOT_DIR = process.cwd();
15
- // Simple ANSI parser for terminal state
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
- // Clean environment
699
- const cleanEnv = {};
700
- for (const [key, value] of Object.entries(process.env)) {
701
- if (value !== undefined) {
702
- cleanEnv[key] = value;
703
- }
704
- }
705
- cleanEnv['TERM'] = 'xterm-256color';
706
- cleanEnv['COLUMNS'] = cols.toString();
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
- // Process output and send grid state
759
- const processOutput = (data) => {
760
- const str = data.toString();
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: "state",
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('data', processOutput);
782
- proc.stderr?.on('data', processOutput);
783
- proc.on('error', (err) => {
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: code || 0 },
574
+ payload: { terminalId, code },
796
575
  };
797
576
  dataChannel.send(JSON.stringify(msg));
798
577
  }
799
578
  });
800
- // Send initial state
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 session = terminals.get(terminalId);
831
- if (!session)
588
+ const proc = terminals.get(terminalId);
589
+ if (!proc)
832
590
  throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
833
- session.proc.stdin?.write(data);
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
- if (!cols || !rows)
843
- throw Object.assign(new Error("cols and rows are required"), { code: "EINVAL" });
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
- // Resize parser buffer
848
- session.parser.resize(cols, rows);
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 session = terminals.get(terminalId);
879
- if (!session)
611
+ const proc = terminals.get(terminalId);
612
+ if (!proc)
880
613
  throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
881
- session.proc.kill('SIGKILL');
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, session] of terminals) {
1678
- session.proc.kill();
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.12",
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",