lunel-cli 0.1.13 → 0.1.16
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 +442 -276
- package/package.json +2 -3
package/dist/index.js
CHANGED
|
@@ -1,216 +1,80 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { WebSocket } from "ws";
|
|
3
3
|
import qrcode from "qrcode-terminal";
|
|
4
|
+
import { createOpencode } from "@opencode-ai/sdk";
|
|
4
5
|
import Ignore from "ignore";
|
|
5
6
|
const ignore = Ignore.default;
|
|
6
7
|
import * as fs from "fs/promises";
|
|
7
|
-
import * as fsSync from "fs";
|
|
8
8
|
import * as path from "path";
|
|
9
9
|
import * as os from "os";
|
|
10
10
|
import { spawn, execSync } from "child_process";
|
|
11
|
-
import
|
|
12
|
-
import { createServer } from "net";
|
|
13
|
-
import { fileURLToPath } from "url";
|
|
14
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
-
const __dirname = path.dirname(__filename);
|
|
11
|
+
import { createServer, createConnection } from "net";
|
|
16
12
|
const PROXY_URL = process.env.LUNEL_PROXY_URL || "https://gateway.lunel.dev";
|
|
17
|
-
|
|
13
|
+
import { createRequire } from "module";
|
|
14
|
+
const __require = createRequire(import.meta.url);
|
|
15
|
+
const VERSION = __require("../package.json").version;
|
|
18
16
|
// Root directory - sandbox all file operations to this
|
|
19
17
|
const ROOT_DIR = process.cwd();
|
|
20
|
-
//
|
|
21
|
-
class AnsiParser {
|
|
22
|
-
buffer;
|
|
23
|
-
cursorX = 0;
|
|
24
|
-
cursorY = 0;
|
|
25
|
-
cols;
|
|
26
|
-
rows;
|
|
27
|
-
currentFg = '#ffffff';
|
|
28
|
-
currentBg = '#000000';
|
|
29
|
-
scrollbackBuffer = [];
|
|
30
|
-
// ANSI color codes to hex
|
|
31
|
-
colors = {
|
|
32
|
-
0: '#000000', 1: '#cc0000', 2: '#4e9a06', 3: '#c4a000',
|
|
33
|
-
4: '#3465a4', 5: '#75507b', 6: '#06989a', 7: '#d3d7cf',
|
|
34
|
-
8: '#555753', 9: '#ef2929', 10: '#8ae234', 11: '#fce94f',
|
|
35
|
-
12: '#729fcf', 13: '#ad7fa8', 14: '#34e2e2', 15: '#eeeeec',
|
|
36
|
-
};
|
|
37
|
-
constructor(cols, rows) {
|
|
38
|
-
this.cols = cols;
|
|
39
|
-
this.rows = rows;
|
|
40
|
-
this.buffer = this.createEmptyBuffer();
|
|
41
|
-
}
|
|
42
|
-
createEmptyBuffer() {
|
|
43
|
-
return Array.from({ length: this.rows }, () => Array.from({ length: this.cols }, () => ({
|
|
44
|
-
char: ' ',
|
|
45
|
-
fg: '#ffffff',
|
|
46
|
-
bg: '#000000',
|
|
47
|
-
})));
|
|
48
|
-
}
|
|
49
|
-
scrollUp() {
|
|
50
|
-
// Move first line to scrollback
|
|
51
|
-
this.scrollbackBuffer.push(this.buffer.shift());
|
|
52
|
-
// Keep scrollback limited
|
|
53
|
-
if (this.scrollbackBuffer.length > 1000) {
|
|
54
|
-
this.scrollbackBuffer.shift();
|
|
55
|
-
}
|
|
56
|
-
// Add new empty line at bottom
|
|
57
|
-
this.buffer.push(Array.from({ length: this.cols }, () => ({
|
|
58
|
-
char: ' ',
|
|
59
|
-
fg: '#ffffff',
|
|
60
|
-
bg: '#000000',
|
|
61
|
-
})));
|
|
62
|
-
}
|
|
63
|
-
write(data) {
|
|
64
|
-
let i = 0;
|
|
65
|
-
while (i < data.length) {
|
|
66
|
-
const char = data[i];
|
|
67
|
-
// Check for escape sequence
|
|
68
|
-
if (char === '\x1b' && data[i + 1] === '[') {
|
|
69
|
-
// Parse CSI sequence
|
|
70
|
-
let j = i + 2;
|
|
71
|
-
let params = '';
|
|
72
|
-
while (j < data.length && /[0-9;]/.test(data[j])) {
|
|
73
|
-
params += data[j];
|
|
74
|
-
j++;
|
|
75
|
-
}
|
|
76
|
-
const cmd = data[j];
|
|
77
|
-
i = j + 1;
|
|
78
|
-
this.handleCSI(params, cmd);
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
// Handle special characters
|
|
82
|
-
if (char === '\n') {
|
|
83
|
-
this.cursorY++;
|
|
84
|
-
if (this.cursorY >= this.rows) {
|
|
85
|
-
this.scrollUp();
|
|
86
|
-
this.cursorY = this.rows - 1;
|
|
87
|
-
}
|
|
88
|
-
i++;
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
if (char === '\r') {
|
|
92
|
-
this.cursorX = 0;
|
|
93
|
-
i++;
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
if (char === '\b') {
|
|
97
|
-
if (this.cursorX > 0)
|
|
98
|
-
this.cursorX--;
|
|
99
|
-
i++;
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
if (char === '\t') {
|
|
103
|
-
this.cursorX = Math.min(this.cols - 1, (Math.floor(this.cursorX / 8) + 1) * 8);
|
|
104
|
-
i++;
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
if (char === '\x07') { // Bell
|
|
108
|
-
i++;
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
// Skip other control characters
|
|
112
|
-
if (char.charCodeAt(0) < 32) {
|
|
113
|
-
i++;
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
// Regular character
|
|
117
|
-
if (this.cursorX >= this.cols) {
|
|
118
|
-
this.cursorX = 0;
|
|
119
|
-
this.cursorY++;
|
|
120
|
-
if (this.cursorY >= this.rows) {
|
|
121
|
-
this.scrollUp();
|
|
122
|
-
this.cursorY = this.rows - 1;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
if (this.cursorY >= 0 && this.cursorY < this.rows &&
|
|
126
|
-
this.cursorX >= 0 && this.cursorX < this.cols) {
|
|
127
|
-
this.buffer[this.cursorY][this.cursorX] = {
|
|
128
|
-
char,
|
|
129
|
-
fg: this.currentFg,
|
|
130
|
-
bg: this.currentBg,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
this.cursorX++;
|
|
134
|
-
i++;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
handleCSI(params, cmd) {
|
|
138
|
-
const args = params.split(';').map(p => parseInt(p) || 0);
|
|
139
|
-
switch (cmd) {
|
|
140
|
-
case 'A': // Cursor up
|
|
141
|
-
this.cursorY = Math.max(0, this.cursorY - (args[0] || 1));
|
|
142
|
-
break;
|
|
143
|
-
case 'B': // Cursor down
|
|
144
|
-
this.cursorY = Math.min(this.rows - 1, this.cursorY + (args[0] || 1));
|
|
145
|
-
break;
|
|
146
|
-
case 'C': // Cursor forward
|
|
147
|
-
this.cursorX = Math.min(this.cols - 1, this.cursorX + (args[0] || 1));
|
|
148
|
-
break;
|
|
149
|
-
case 'D': // Cursor back
|
|
150
|
-
this.cursorX = Math.max(0, this.cursorX - (args[0] || 1));
|
|
151
|
-
break;
|
|
152
|
-
case 'H': // Cursor position
|
|
153
|
-
case 'f':
|
|
154
|
-
this.cursorY = Math.min(this.rows - 1, Math.max(0, (args[0] || 1) - 1));
|
|
155
|
-
this.cursorX = Math.min(this.cols - 1, Math.max(0, (args[1] || 1) - 1));
|
|
156
|
-
break;
|
|
157
|
-
case 'J': // Erase display
|
|
158
|
-
if (args[0] === 2) {
|
|
159
|
-
this.buffer = this.createEmptyBuffer();
|
|
160
|
-
this.cursorX = 0;
|
|
161
|
-
this.cursorY = 0;
|
|
162
|
-
}
|
|
163
|
-
break;
|
|
164
|
-
case 'K': // Erase line
|
|
165
|
-
for (let x = this.cursorX; x < this.cols; x++) {
|
|
166
|
-
this.buffer[this.cursorY][x] = { char: ' ', fg: this.currentFg, bg: this.currentBg };
|
|
167
|
-
}
|
|
168
|
-
break;
|
|
169
|
-
case 'm': // SGR - Select Graphic Rendition
|
|
170
|
-
for (const arg of args) {
|
|
171
|
-
if (arg === 0) {
|
|
172
|
-
this.currentFg = '#ffffff';
|
|
173
|
-
this.currentBg = '#000000';
|
|
174
|
-
}
|
|
175
|
-
else if (arg >= 30 && arg <= 37) {
|
|
176
|
-
this.currentFg = this.colors[arg - 30] || '#ffffff';
|
|
177
|
-
}
|
|
178
|
-
else if (arg >= 40 && arg <= 47) {
|
|
179
|
-
this.currentBg = this.colors[arg - 40] || '#000000';
|
|
180
|
-
}
|
|
181
|
-
else if (arg >= 90 && arg <= 97) {
|
|
182
|
-
this.currentFg = this.colors[arg - 90 + 8] || '#ffffff';
|
|
183
|
-
}
|
|
184
|
-
else if (arg >= 100 && arg <= 107) {
|
|
185
|
-
this.currentBg = this.colors[arg - 100 + 8] || '#000000';
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
break;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
resize(cols, rows) {
|
|
192
|
-
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' }));
|
|
193
|
-
this.cols = cols;
|
|
194
|
-
this.rows = rows;
|
|
195
|
-
this.buffer = newBuffer;
|
|
196
|
-
this.cursorX = Math.min(this.cursorX, cols - 1);
|
|
197
|
-
this.cursorY = Math.min(this.cursorY, rows - 1);
|
|
198
|
-
}
|
|
199
|
-
getState() {
|
|
200
|
-
return {
|
|
201
|
-
buffer: this.buffer,
|
|
202
|
-
cursorX: this.cursorX,
|
|
203
|
-
cursorY: this.cursorY,
|
|
204
|
-
cols: this.cols,
|
|
205
|
-
rows: this.rows,
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
}
|
|
18
|
+
// Terminal sessions
|
|
209
19
|
const terminals = new Map();
|
|
210
20
|
const processes = new Map();
|
|
211
21
|
const processOutputBuffers = new Map();
|
|
212
22
|
// CPU usage tracking
|
|
213
23
|
let lastCpuInfo = null;
|
|
24
|
+
// OpenCode client
|
|
25
|
+
let opencodeClient = null;
|
|
26
|
+
// Proxy tunnel management
|
|
27
|
+
let currentSessionCode = null;
|
|
28
|
+
const activeTunnels = new Map();
|
|
29
|
+
// Popular development server ports to scan on connect
|
|
30
|
+
const DEV_PORTS = [
|
|
31
|
+
1234, // Parcel
|
|
32
|
+
1313, // Hugo
|
|
33
|
+
2000, // Deno (alt)
|
|
34
|
+
3000, // Next.js / CRA / Remix / Express
|
|
35
|
+
3001, // Next.js (alt)
|
|
36
|
+
3002, // Dev server (alt)
|
|
37
|
+
3003, // Dev server (alt)
|
|
38
|
+
3333, // AdonisJS / Nitro
|
|
39
|
+
4000, // Gatsby / Redwood
|
|
40
|
+
4200, // Angular CLI
|
|
41
|
+
4321, // Astro
|
|
42
|
+
4173, // Vite preview
|
|
43
|
+
4444, // Selenium
|
|
44
|
+
4567, // Sinatra
|
|
45
|
+
5000, // Flask / Sails.js
|
|
46
|
+
5001, // Flask (alt)
|
|
47
|
+
5173, // Vite
|
|
48
|
+
5174, // Vite (alt)
|
|
49
|
+
5175, // Vite (alt)
|
|
50
|
+
5500, // Live Server (VS Code)
|
|
51
|
+
5555, // Prisma Studio
|
|
52
|
+
6006, // Storybook
|
|
53
|
+
7000, // Hapi
|
|
54
|
+
7070, // Dev server
|
|
55
|
+
7777, // Dev server
|
|
56
|
+
8000, // Django / Laravel / Gatsby
|
|
57
|
+
8001, // Django (alt)
|
|
58
|
+
8008, // Dev server
|
|
59
|
+
8010, // Dev server
|
|
60
|
+
8080, // Vue CLI / Webpack Dev Server / Spring Boot
|
|
61
|
+
8081, // Metro Bundler
|
|
62
|
+
8082, // Dev server (alt)
|
|
63
|
+
8100, // Ionic
|
|
64
|
+
8200, // Vault
|
|
65
|
+
8443, // HTTPS dev server
|
|
66
|
+
8787, // Wrangler (Cloudflare Workers)
|
|
67
|
+
8888, // Jupyter Notebook
|
|
68
|
+
8899, // Dev server
|
|
69
|
+
9000, // PHP / SonarQube
|
|
70
|
+
9090, // Prometheus / Cockpit
|
|
71
|
+
9200, // Elasticsearch
|
|
72
|
+
9229, // Node.js debugger
|
|
73
|
+
9292, // Rack (Ruby)
|
|
74
|
+
10000, // Webmin
|
|
75
|
+
19006, // Expo web
|
|
76
|
+
24678, // Vite HMR WebSocket
|
|
77
|
+
];
|
|
214
78
|
// ============================================================================
|
|
215
79
|
// Path Safety
|
|
216
80
|
// ============================================================================
|
|
@@ -674,78 +538,37 @@ async function handleGitDiscard(payload) {
|
|
|
674
538
|
// ============================================================================
|
|
675
539
|
let dataChannel = null;
|
|
676
540
|
function handleTerminalSpawn(payload) {
|
|
541
|
+
const shell = payload.shell || process.env.SHELL || "/bin/sh";
|
|
677
542
|
const cols = payload.cols || 80;
|
|
678
543
|
const rows = payload.rows || 24;
|
|
679
|
-
// Find a working shell
|
|
680
|
-
let shell = payload.shell || process.env.SHELL || '';
|
|
681
|
-
const possibleShells = os.platform() === 'win32'
|
|
682
|
-
? ['powershell.exe', 'cmd.exe']
|
|
683
|
-
: [shell, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean);
|
|
684
|
-
shell = '';
|
|
685
|
-
for (const candidate of possibleShells) {
|
|
686
|
-
if (!candidate)
|
|
687
|
-
continue;
|
|
688
|
-
try {
|
|
689
|
-
if (os.platform() !== 'win32') {
|
|
690
|
-
execSync(`test -x "${candidate}"`, { stdio: 'ignore' });
|
|
691
|
-
}
|
|
692
|
-
shell = candidate;
|
|
693
|
-
break;
|
|
694
|
-
}
|
|
695
|
-
catch {
|
|
696
|
-
// Try next
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
if (!shell) {
|
|
700
|
-
throw Object.assign(new Error('No valid shell found'), { code: 'ENOSHELL' });
|
|
701
|
-
}
|
|
702
544
|
const terminalId = `term-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
703
|
-
|
|
704
|
-
// Create ANSI parser for terminal state
|
|
705
|
-
const parser = new AnsiParser(cols, rows);
|
|
706
|
-
// Spawn PTY with node-pty
|
|
707
|
-
const ptyProcess = pty.spawn(shell, [], {
|
|
708
|
-
name: 'xterm-256color',
|
|
709
|
-
cols,
|
|
710
|
-
rows,
|
|
545
|
+
const proc = spawn(shell, [], {
|
|
711
546
|
cwd: ROOT_DIR,
|
|
712
|
-
env:
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
rows,
|
|
720
|
-
buffer: parser.getState().buffer,
|
|
721
|
-
cursorX: 0,
|
|
722
|
-
cursorY: 0,
|
|
723
|
-
parser,
|
|
547
|
+
env: {
|
|
548
|
+
...process.env,
|
|
549
|
+
TERM: "xterm-256color",
|
|
550
|
+
COLUMNS: cols.toString(),
|
|
551
|
+
LINES: rows.toString(),
|
|
552
|
+
},
|
|
553
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
724
554
|
});
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
const state = parser.getState();
|
|
555
|
+
terminals.set(terminalId, proc);
|
|
556
|
+
// Stream output to app via data channel
|
|
557
|
+
const sendOutput = (data) => {
|
|
729
558
|
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
730
559
|
const msg = {
|
|
731
560
|
v: 1,
|
|
732
561
|
id: `evt-${Date.now()}`,
|
|
733
562
|
ns: "terminal",
|
|
734
|
-
action: "
|
|
735
|
-
payload: {
|
|
736
|
-
terminalId,
|
|
737
|
-
buffer: state.buffer,
|
|
738
|
-
cursorX: state.cursorX,
|
|
739
|
-
cursorY: state.cursorY,
|
|
740
|
-
cols: state.cols,
|
|
741
|
-
rows: state.rows,
|
|
742
|
-
},
|
|
563
|
+
action: "output",
|
|
564
|
+
payload: { terminalId, data: data.toString() },
|
|
743
565
|
};
|
|
744
566
|
dataChannel.send(JSON.stringify(msg));
|
|
745
567
|
}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
|
|
568
|
+
};
|
|
569
|
+
proc.stdout?.on("data", sendOutput);
|
|
570
|
+
proc.stderr?.on("data", sendOutput);
|
|
571
|
+
proc.on("close", (code) => {
|
|
749
572
|
terminals.delete(terminalId);
|
|
750
573
|
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
751
574
|
const msg = {
|
|
@@ -753,12 +576,12 @@ function handleTerminalSpawn(payload) {
|
|
|
753
576
|
id: `evt-${Date.now()}`,
|
|
754
577
|
ns: "terminal",
|
|
755
578
|
action: "exit",
|
|
756
|
-
payload: { terminalId, code
|
|
579
|
+
payload: { terminalId, code },
|
|
757
580
|
};
|
|
758
581
|
dataChannel.send(JSON.stringify(msg));
|
|
759
582
|
}
|
|
760
583
|
});
|
|
761
|
-
return { terminalId
|
|
584
|
+
return { terminalId };
|
|
762
585
|
}
|
|
763
586
|
function handleTerminalWrite(payload) {
|
|
764
587
|
const terminalId = payload.terminalId;
|
|
@@ -767,10 +590,10 @@ function handleTerminalWrite(payload) {
|
|
|
767
590
|
throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
|
|
768
591
|
if (typeof data !== "string")
|
|
769
592
|
throw Object.assign(new Error("data is required"), { code: "EINVAL" });
|
|
770
|
-
const
|
|
771
|
-
if (!
|
|
593
|
+
const proc = terminals.get(terminalId);
|
|
594
|
+
if (!proc)
|
|
772
595
|
throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
|
|
773
|
-
|
|
596
|
+
proc.stdin?.write(data);
|
|
774
597
|
return {};
|
|
775
598
|
}
|
|
776
599
|
function handleTerminalResize(payload) {
|
|
@@ -779,27 +602,21 @@ function handleTerminalResize(payload) {
|
|
|
779
602
|
const rows = payload.rows;
|
|
780
603
|
if (!terminalId)
|
|
781
604
|
throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
const session = terminals.get(terminalId);
|
|
785
|
-
if (!session)
|
|
605
|
+
const proc = terminals.get(terminalId);
|
|
606
|
+
if (!proc)
|
|
786
607
|
throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
// Resize parser buffer
|
|
790
|
-
session.parser.resize(cols, rows);
|
|
791
|
-
session.cols = cols;
|
|
792
|
-
session.rows = rows;
|
|
608
|
+
// Note: For proper PTY resize, you'd need node-pty
|
|
609
|
+
// This is a simplified version
|
|
793
610
|
return {};
|
|
794
611
|
}
|
|
795
612
|
function handleTerminalKill(payload) {
|
|
796
613
|
const terminalId = payload.terminalId;
|
|
797
614
|
if (!terminalId)
|
|
798
615
|
throw Object.assign(new Error("terminalId is required"), { code: "EINVAL" });
|
|
799
|
-
const
|
|
800
|
-
if (!
|
|
616
|
+
const proc = terminals.get(terminalId);
|
|
617
|
+
if (!proc)
|
|
801
618
|
throw Object.assign(new Error("Terminal not found"), { code: "ENOTERM" });
|
|
802
|
-
|
|
619
|
+
proc.kill();
|
|
803
620
|
terminals.delete(terminalId);
|
|
804
621
|
return {};
|
|
805
622
|
}
|
|
@@ -809,7 +626,7 @@ function handleTerminalKill(payload) {
|
|
|
809
626
|
function handleSystemCapabilities() {
|
|
810
627
|
return {
|
|
811
628
|
version: VERSION,
|
|
812
|
-
namespaces: ["fs", "git", "terminal", "processes", "ports", "monitor", "http"],
|
|
629
|
+
namespaces: ["fs", "git", "terminal", "processes", "ports", "monitor", "http", "ai", "proxy"],
|
|
813
630
|
platform: os.platform(),
|
|
814
631
|
rootDir: ROOT_DIR,
|
|
815
632
|
hostname: os.hostname(),
|
|
@@ -1273,6 +1090,267 @@ async function handleHttpRequest(payload) {
|
|
|
1273
1090
|
}
|
|
1274
1091
|
}
|
|
1275
1092
|
// ============================================================================
|
|
1093
|
+
// AI Handlers (OpenCode SDK)
|
|
1094
|
+
// ============================================================================
|
|
1095
|
+
async function handleAiCreateSession(payload) {
|
|
1096
|
+
const title = payload.title || undefined;
|
|
1097
|
+
const response = await opencodeClient.session.create({ body: { title } });
|
|
1098
|
+
return { session: response.data };
|
|
1099
|
+
}
|
|
1100
|
+
async function handleAiListSessions() {
|
|
1101
|
+
const response = await opencodeClient.session.list();
|
|
1102
|
+
return { sessions: response.data };
|
|
1103
|
+
}
|
|
1104
|
+
async function handleAiGetSession(payload) {
|
|
1105
|
+
const id = payload.id;
|
|
1106
|
+
const response = await opencodeClient.session.get({ path: { id } });
|
|
1107
|
+
return { session: response.data };
|
|
1108
|
+
}
|
|
1109
|
+
async function handleAiDeleteSession(payload) {
|
|
1110
|
+
const id = payload.id;
|
|
1111
|
+
await opencodeClient.session.delete({ path: { id } });
|
|
1112
|
+
return {};
|
|
1113
|
+
}
|
|
1114
|
+
async function handleAiGetMessages(payload) {
|
|
1115
|
+
const id = payload.id;
|
|
1116
|
+
const response = await opencodeClient.session.messages({ path: { id } });
|
|
1117
|
+
return { messages: response.data };
|
|
1118
|
+
}
|
|
1119
|
+
async function handleAiPrompt(payload) {
|
|
1120
|
+
const sessionId = payload.sessionId;
|
|
1121
|
+
const text = payload.text;
|
|
1122
|
+
const model = payload.model;
|
|
1123
|
+
// Fire and forget — results stream via SSE events forwarded on data channel
|
|
1124
|
+
opencodeClient.session.prompt({
|
|
1125
|
+
path: { id: sessionId },
|
|
1126
|
+
body: {
|
|
1127
|
+
parts: [{ type: "text", text }],
|
|
1128
|
+
...(model ? { model } : {}),
|
|
1129
|
+
},
|
|
1130
|
+
}).catch((err) => {
|
|
1131
|
+
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
1132
|
+
dataChannel.send(JSON.stringify({
|
|
1133
|
+
v: 1,
|
|
1134
|
+
id: `evt-${Date.now()}`,
|
|
1135
|
+
ns: "ai",
|
|
1136
|
+
action: "event",
|
|
1137
|
+
payload: {
|
|
1138
|
+
type: "prompt_error",
|
|
1139
|
+
properties: { sessionId, error: err.message },
|
|
1140
|
+
},
|
|
1141
|
+
}));
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
1144
|
+
return { ack: true };
|
|
1145
|
+
}
|
|
1146
|
+
async function handleAiAbort(payload) {
|
|
1147
|
+
const id = payload.sessionId;
|
|
1148
|
+
await opencodeClient.session.abort({ path: { id } });
|
|
1149
|
+
return {};
|
|
1150
|
+
}
|
|
1151
|
+
async function handleAiAgents() {
|
|
1152
|
+
const response = await opencodeClient.app.agents();
|
|
1153
|
+
return { agents: response.data };
|
|
1154
|
+
}
|
|
1155
|
+
async function handleAiProviders() {
|
|
1156
|
+
const response = await opencodeClient.config.providers();
|
|
1157
|
+
return { providers: response.data };
|
|
1158
|
+
}
|
|
1159
|
+
async function handleAiSetAuth(payload) {
|
|
1160
|
+
const providerId = payload.providerId;
|
|
1161
|
+
const key = payload.key;
|
|
1162
|
+
await opencodeClient.auth.set({
|
|
1163
|
+
path: { id: providerId },
|
|
1164
|
+
body: { type: "api", key },
|
|
1165
|
+
});
|
|
1166
|
+
return {};
|
|
1167
|
+
}
|
|
1168
|
+
async function handleAiCommand(payload) {
|
|
1169
|
+
const sessionId = payload.sessionId;
|
|
1170
|
+
const command = payload.command;
|
|
1171
|
+
const args = payload.arguments || "";
|
|
1172
|
+
const response = await opencodeClient.session.command({
|
|
1173
|
+
path: { id: sessionId },
|
|
1174
|
+
body: { command, arguments: args },
|
|
1175
|
+
});
|
|
1176
|
+
return { result: response.data };
|
|
1177
|
+
}
|
|
1178
|
+
async function handleAiRevert(payload) {
|
|
1179
|
+
const sessionId = payload.sessionId;
|
|
1180
|
+
const messageId = payload.messageId;
|
|
1181
|
+
await opencodeClient.session.revert({
|
|
1182
|
+
path: { id: sessionId },
|
|
1183
|
+
body: { messageID: messageId },
|
|
1184
|
+
});
|
|
1185
|
+
return {};
|
|
1186
|
+
}
|
|
1187
|
+
async function handleAiUnrevert(payload) {
|
|
1188
|
+
const sessionId = payload.sessionId;
|
|
1189
|
+
await opencodeClient.session.unrevert({ path: { id: sessionId } });
|
|
1190
|
+
return {};
|
|
1191
|
+
}
|
|
1192
|
+
async function handleAiShare(payload) {
|
|
1193
|
+
const sessionId = payload.sessionId;
|
|
1194
|
+
const response = await opencodeClient.session.share({ path: { id: sessionId } });
|
|
1195
|
+
return { share: response.data };
|
|
1196
|
+
}
|
|
1197
|
+
async function handleAiPermissionReply(payload) {
|
|
1198
|
+
const permissionId = payload.permissionId;
|
|
1199
|
+
const sessionId = payload.sessionId;
|
|
1200
|
+
const approved = payload.approved;
|
|
1201
|
+
await opencodeClient.postSessionIdPermissionsPermissionId({
|
|
1202
|
+
path: { id: sessionId, permissionID: permissionId },
|
|
1203
|
+
body: { response: approved ? "once" : "reject" },
|
|
1204
|
+
});
|
|
1205
|
+
return {};
|
|
1206
|
+
}
|
|
1207
|
+
// SSE event forwarding from OpenCode to mobile app
|
|
1208
|
+
async function subscribeToOpenCodeEvents(client) {
|
|
1209
|
+
try {
|
|
1210
|
+
const events = await client.event.subscribe();
|
|
1211
|
+
for await (const event of events.stream) {
|
|
1212
|
+
if (dataChannel && dataChannel.readyState === WebSocket.OPEN) {
|
|
1213
|
+
const msg = {
|
|
1214
|
+
v: 1,
|
|
1215
|
+
id: `evt-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
|
|
1216
|
+
ns: "ai",
|
|
1217
|
+
action: "event",
|
|
1218
|
+
payload: {
|
|
1219
|
+
type: event.type,
|
|
1220
|
+
properties: event.properties,
|
|
1221
|
+
},
|
|
1222
|
+
};
|
|
1223
|
+
dataChannel.send(JSON.stringify(msg));
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
catch (err) {
|
|
1228
|
+
console.error("OpenCode event stream error:", err);
|
|
1229
|
+
setTimeout(() => subscribeToOpenCodeEvents(client), 3000);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
// Proxy Handlers
|
|
1233
|
+
// ============================================================================
|
|
1234
|
+
async function scanDevPorts() {
|
|
1235
|
+
const openPorts = [];
|
|
1236
|
+
const checks = DEV_PORTS.map((port) => {
|
|
1237
|
+
return new Promise((resolve) => {
|
|
1238
|
+
const socket = createConnection({ port, host: "127.0.0.1" });
|
|
1239
|
+
socket.setTimeout(200);
|
|
1240
|
+
socket.on("connect", () => {
|
|
1241
|
+
openPorts.push(port);
|
|
1242
|
+
socket.destroy();
|
|
1243
|
+
resolve();
|
|
1244
|
+
});
|
|
1245
|
+
socket.on("timeout", () => {
|
|
1246
|
+
socket.destroy();
|
|
1247
|
+
resolve();
|
|
1248
|
+
});
|
|
1249
|
+
socket.on("error", () => {
|
|
1250
|
+
resolve();
|
|
1251
|
+
});
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
await Promise.all(checks);
|
|
1255
|
+
return openPorts.sort((a, b) => a - b);
|
|
1256
|
+
}
|
|
1257
|
+
async function handleProxyConnect(payload) {
|
|
1258
|
+
const tunnelId = payload.tunnelId;
|
|
1259
|
+
const port = payload.port;
|
|
1260
|
+
if (!tunnelId)
|
|
1261
|
+
throw Object.assign(new Error("tunnelId is required"), { code: "EINVAL" });
|
|
1262
|
+
if (!port)
|
|
1263
|
+
throw Object.assign(new Error("port is required"), { code: "EINVAL" });
|
|
1264
|
+
if (!currentSessionCode)
|
|
1265
|
+
throw Object.assign(new Error("no active session"), { code: "ENOENT" });
|
|
1266
|
+
// 1. Open TCP connection to the local service
|
|
1267
|
+
const tcpSocket = createConnection({ port, host: "127.0.0.1" });
|
|
1268
|
+
await new Promise((resolve, reject) => {
|
|
1269
|
+
const timeout = setTimeout(() => {
|
|
1270
|
+
tcpSocket.destroy();
|
|
1271
|
+
reject(Object.assign(new Error(`TCP connect timeout to localhost:${port}`), { code: "ETIMEOUT" }));
|
|
1272
|
+
}, 5000);
|
|
1273
|
+
tcpSocket.on("connect", () => {
|
|
1274
|
+
clearTimeout(timeout);
|
|
1275
|
+
resolve();
|
|
1276
|
+
});
|
|
1277
|
+
tcpSocket.on("error", (err) => {
|
|
1278
|
+
clearTimeout(timeout);
|
|
1279
|
+
reject(Object.assign(new Error(`TCP connect failed: ${err.message}`), { code: "ECONNREFUSED" }));
|
|
1280
|
+
});
|
|
1281
|
+
});
|
|
1282
|
+
// 2. Open proxy WebSocket to gateway
|
|
1283
|
+
const wsBase = PROXY_URL.replace(/^http/, "ws");
|
|
1284
|
+
const proxyWsUrl = `${wsBase}/v1/ws/proxy?code=${currentSessionCode}&tunnelId=${tunnelId}&role=cli`;
|
|
1285
|
+
const proxyWs = new WebSocket(proxyWsUrl);
|
|
1286
|
+
await new Promise((resolve, reject) => {
|
|
1287
|
+
const timeout = setTimeout(() => {
|
|
1288
|
+
proxyWs.close();
|
|
1289
|
+
tcpSocket.destroy();
|
|
1290
|
+
reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
|
|
1291
|
+
}, 5000);
|
|
1292
|
+
proxyWs.on("open", () => {
|
|
1293
|
+
clearTimeout(timeout);
|
|
1294
|
+
resolve();
|
|
1295
|
+
});
|
|
1296
|
+
proxyWs.on("error", (err) => {
|
|
1297
|
+
clearTimeout(timeout);
|
|
1298
|
+
tcpSocket.destroy();
|
|
1299
|
+
reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
// 3. Store the tunnel
|
|
1303
|
+
activeTunnels.set(tunnelId, { tunnelId, port, tcpSocket, proxyWs });
|
|
1304
|
+
// 4. Pipe: TCP data -> proxy WS (as binary)
|
|
1305
|
+
tcpSocket.on("data", (chunk) => {
|
|
1306
|
+
if (proxyWs.readyState === WebSocket.OPEN) {
|
|
1307
|
+
proxyWs.send(chunk);
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
// 5. Pipe: proxy WS -> TCP socket (as binary)
|
|
1311
|
+
proxyWs.on("message", (data) => {
|
|
1312
|
+
if (!tcpSocket.destroyed) {
|
|
1313
|
+
tcpSocket.write(data);
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
// 6. Close cascade: TCP closes -> close WS
|
|
1317
|
+
tcpSocket.on("close", () => {
|
|
1318
|
+
activeTunnels.delete(tunnelId);
|
|
1319
|
+
if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
|
|
1320
|
+
proxyWs.close();
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
tcpSocket.on("error", () => {
|
|
1324
|
+
activeTunnels.delete(tunnelId);
|
|
1325
|
+
if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
|
|
1326
|
+
proxyWs.close();
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1329
|
+
// 7. Close cascade: WS closes -> close TCP
|
|
1330
|
+
proxyWs.on("close", () => {
|
|
1331
|
+
activeTunnels.delete(tunnelId);
|
|
1332
|
+
if (!tcpSocket.destroyed) {
|
|
1333
|
+
tcpSocket.destroy();
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
proxyWs.on("error", () => {
|
|
1337
|
+
activeTunnels.delete(tunnelId);
|
|
1338
|
+
if (!tcpSocket.destroyed) {
|
|
1339
|
+
tcpSocket.destroy();
|
|
1340
|
+
}
|
|
1341
|
+
});
|
|
1342
|
+
return { tunnelId, port };
|
|
1343
|
+
}
|
|
1344
|
+
function cleanupAllTunnels() {
|
|
1345
|
+
for (const [, tunnel] of activeTunnels) {
|
|
1346
|
+
tunnel.tcpSocket.destroy();
|
|
1347
|
+
if (tunnel.proxyWs.readyState === WebSocket.OPEN) {
|
|
1348
|
+
tunnel.proxyWs.close();
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
activeTunnels.clear();
|
|
1352
|
+
}
|
|
1353
|
+
// ============================================================================
|
|
1276
1354
|
// Message Router
|
|
1277
1355
|
// ============================================================================
|
|
1278
1356
|
async function processMessage(message) {
|
|
@@ -1460,6 +1538,66 @@ async function processMessage(message) {
|
|
|
1460
1538
|
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
1461
1539
|
}
|
|
1462
1540
|
break;
|
|
1541
|
+
case "ai":
|
|
1542
|
+
switch (action) {
|
|
1543
|
+
case "prompt":
|
|
1544
|
+
result = await handleAiPrompt(payload);
|
|
1545
|
+
break;
|
|
1546
|
+
case "createSession":
|
|
1547
|
+
result = await handleAiCreateSession(payload);
|
|
1548
|
+
break;
|
|
1549
|
+
case "listSessions":
|
|
1550
|
+
result = await handleAiListSessions();
|
|
1551
|
+
break;
|
|
1552
|
+
case "getSession":
|
|
1553
|
+
result = await handleAiGetSession(payload);
|
|
1554
|
+
break;
|
|
1555
|
+
case "deleteSession":
|
|
1556
|
+
result = await handleAiDeleteSession(payload);
|
|
1557
|
+
break;
|
|
1558
|
+
case "getMessages":
|
|
1559
|
+
result = await handleAiGetMessages(payload);
|
|
1560
|
+
break;
|
|
1561
|
+
case "abort":
|
|
1562
|
+
result = await handleAiAbort(payload);
|
|
1563
|
+
break;
|
|
1564
|
+
case "agents":
|
|
1565
|
+
result = await handleAiAgents();
|
|
1566
|
+
break;
|
|
1567
|
+
case "providers":
|
|
1568
|
+
result = await handleAiProviders();
|
|
1569
|
+
break;
|
|
1570
|
+
case "setAuth":
|
|
1571
|
+
result = await handleAiSetAuth(payload);
|
|
1572
|
+
break;
|
|
1573
|
+
case "command":
|
|
1574
|
+
result = await handleAiCommand(payload);
|
|
1575
|
+
break;
|
|
1576
|
+
case "revert":
|
|
1577
|
+
result = await handleAiRevert(payload);
|
|
1578
|
+
break;
|
|
1579
|
+
case "unrevert":
|
|
1580
|
+
result = await handleAiUnrevert(payload);
|
|
1581
|
+
break;
|
|
1582
|
+
case "share":
|
|
1583
|
+
result = await handleAiShare(payload);
|
|
1584
|
+
break;
|
|
1585
|
+
case "permission":
|
|
1586
|
+
result = await handleAiPermissionReply(payload);
|
|
1587
|
+
break;
|
|
1588
|
+
default:
|
|
1589
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
1590
|
+
}
|
|
1591
|
+
break;
|
|
1592
|
+
case "proxy":
|
|
1593
|
+
switch (action) {
|
|
1594
|
+
case "connect":
|
|
1595
|
+
result = await handleProxyConnect(payload);
|
|
1596
|
+
break;
|
|
1597
|
+
default:
|
|
1598
|
+
throw Object.assign(new Error(`Unknown action: ${ns}.${action}`), { code: "EINVAL" });
|
|
1599
|
+
}
|
|
1600
|
+
break;
|
|
1463
1601
|
default:
|
|
1464
1602
|
throw Object.assign(new Error(`Unknown namespace: ${ns}`), { code: "EINVAL" });
|
|
1465
1603
|
}
|
|
@@ -1506,6 +1644,7 @@ function displayQR(code) {
|
|
|
1506
1644
|
});
|
|
1507
1645
|
}
|
|
1508
1646
|
function connectWebSocket(code) {
|
|
1647
|
+
currentSessionCode = code;
|
|
1509
1648
|
const wsBase = PROXY_URL.replace(/^http/, "ws");
|
|
1510
1649
|
const controlUrl = `${wsBase}/v1/ws/cli/control?code=${code}`;
|
|
1511
1650
|
const dataUrl = `${wsBase}/v1/ws/cli/data?code=${code}`;
|
|
@@ -1537,6 +1676,23 @@ function connectWebSocket(code) {
|
|
|
1537
1676
|
}
|
|
1538
1677
|
if (message.type === "peer_connected") {
|
|
1539
1678
|
console.log("App connected!\n");
|
|
1679
|
+
// Scan dev ports and notify app
|
|
1680
|
+
scanDevPorts().then((openPorts) => {
|
|
1681
|
+
if (openPorts.length > 0) {
|
|
1682
|
+
console.log(`Found ${openPorts.length} open dev port(s): ${openPorts.join(", ")}`);
|
|
1683
|
+
}
|
|
1684
|
+
if (controlWs.readyState === WebSocket.OPEN) {
|
|
1685
|
+
controlWs.send(JSON.stringify({
|
|
1686
|
+
v: 1,
|
|
1687
|
+
id: `evt-${Date.now()}`,
|
|
1688
|
+
ns: "proxy",
|
|
1689
|
+
action: "ports_discovered",
|
|
1690
|
+
payload: { ports: openPorts },
|
|
1691
|
+
}));
|
|
1692
|
+
}
|
|
1693
|
+
}).catch((err) => {
|
|
1694
|
+
console.error("Port scan failed:", err);
|
|
1695
|
+
});
|
|
1540
1696
|
return;
|
|
1541
1697
|
}
|
|
1542
1698
|
if (message.type === "peer_disconnected") {
|
|
@@ -1555,6 +1711,7 @@ function connectWebSocket(code) {
|
|
|
1555
1711
|
});
|
|
1556
1712
|
controlWs.on("close", (code, reason) => {
|
|
1557
1713
|
console.log(`\nControl channel disconnected (${code}: ${reason.toString()})`);
|
|
1714
|
+
cleanupAllTunnels();
|
|
1558
1715
|
dataWs.close();
|
|
1559
1716
|
process.exit(0);
|
|
1560
1717
|
});
|
|
@@ -1585,6 +1742,7 @@ function connectWebSocket(code) {
|
|
|
1585
1742
|
});
|
|
1586
1743
|
dataWs.on("close", (code, reason) => {
|
|
1587
1744
|
console.log(`\nData channel disconnected (${code}: ${reason.toString()})`);
|
|
1745
|
+
cleanupAllTunnels();
|
|
1588
1746
|
controlWs.close();
|
|
1589
1747
|
process.exit(0);
|
|
1590
1748
|
});
|
|
@@ -1595,8 +1753,8 @@ function connectWebSocket(code) {
|
|
|
1595
1753
|
process.on("SIGINT", () => {
|
|
1596
1754
|
console.log("\nShutting down...");
|
|
1597
1755
|
// Kill all terminals
|
|
1598
|
-
for (const [id,
|
|
1599
|
-
|
|
1756
|
+
for (const [id, proc] of terminals) {
|
|
1757
|
+
proc.kill();
|
|
1600
1758
|
}
|
|
1601
1759
|
terminals.clear();
|
|
1602
1760
|
// Kill all managed processes
|
|
@@ -1605,6 +1763,7 @@ function connectWebSocket(code) {
|
|
|
1605
1763
|
}
|
|
1606
1764
|
processes.clear();
|
|
1607
1765
|
processOutputBuffers.clear();
|
|
1766
|
+
cleanupAllTunnels();
|
|
1608
1767
|
controlWs.close();
|
|
1609
1768
|
dataWs.close();
|
|
1610
1769
|
process.exit(0);
|
|
@@ -1614,6 +1773,13 @@ async function main() {
|
|
|
1614
1773
|
console.log("Lunel CLI v" + VERSION);
|
|
1615
1774
|
console.log("=".repeat(20) + "\n");
|
|
1616
1775
|
try {
|
|
1776
|
+
// Start OpenCode server + client
|
|
1777
|
+
console.log("Starting OpenCode...");
|
|
1778
|
+
const { client } = await createOpencode();
|
|
1779
|
+
opencodeClient = client;
|
|
1780
|
+
console.log("OpenCode ready.\n");
|
|
1781
|
+
// Subscribe to OpenCode events
|
|
1782
|
+
subscribeToOpenCodeEvents(client);
|
|
1617
1783
|
const code = await createSession();
|
|
1618
1784
|
displayQR(code);
|
|
1619
1785
|
connectWebSocket(code);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lunel-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"author": [
|
|
5
5
|
{
|
|
6
6
|
"name": "Soham Bharambe",
|
|
@@ -25,9 +25,8 @@
|
|
|
25
25
|
"prepublishOnly": "npm run build"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@
|
|
28
|
+
"@opencode-ai/sdk": "^1.1.56",
|
|
29
29
|
"ignore": "^6.0.2",
|
|
30
|
-
"node-pty": "^1.1.0",
|
|
31
30
|
"qrcode-terminal": "^0.12.0",
|
|
32
31
|
"ws": "^8.18.0"
|
|
33
32
|
},
|