@vincentbanana/bananacli 0.4.7 → 0.4.8
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/server/terminal.js +305 -0
- package/dist/server/ui-server.js +64 -100
- package/dist/ui/app.js +482 -147
- package/dist/ui/index.html +22 -19
- package/dist/ui/style.css +254 -70
- package/dist/ui/vendor/addon-fit.js +2 -0
- package/dist/ui/vendor/highlight.min.js +1262 -0
- package/dist/ui/vendor/xterm.css +285 -0
- package/dist/ui/vendor/xterm.js +2 -0
- package/package.json +9 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// 内嵌终端:真实 PTY + 多会话。
|
|
2
|
+
// 用 @lydell/node-pty(自带预编译二进制),Windows 走 ConPTY,输出天然是 UTF-8,
|
|
3
|
+
// 所以中文路径和中文输出不会再乱码,提示符也由 shell 自己打印(D:\banana>)。
|
|
4
|
+
import { existsSync, statSync } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
const isWin = process.platform === 'win32';
|
|
7
|
+
/** 在 PATH 里找一个可执行文件(Windows 会补 .exe)。不依赖 shell,避免启动时开子进程。 */
|
|
8
|
+
function findOnPath(name) {
|
|
9
|
+
const paths = (process.env['PATH'] ?? '').split(isWin ? ';' : ':').filter(Boolean);
|
|
10
|
+
const exts = isWin ? ['', '.exe', '.cmd', '.bat'] : [''];
|
|
11
|
+
for (const dir of paths) {
|
|
12
|
+
for (const ext of exts) {
|
|
13
|
+
const candidate = join(dir, name.toLowerCase().endsWith(ext.toLowerCase()) ? name : name + ext);
|
|
14
|
+
try {
|
|
15
|
+
if (existsSync(candidate) && statSync(candidate).isFile())
|
|
16
|
+
return candidate;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* 无权限的目录跳过 */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/** 探测机器上可用的 shell,顺序即默认优先级。 */
|
|
26
|
+
export function detectShells() {
|
|
27
|
+
const shells = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
const push = (item) => {
|
|
30
|
+
if (!item || seen.has(item.id))
|
|
31
|
+
return;
|
|
32
|
+
seen.add(item.id);
|
|
33
|
+
shells.push(item);
|
|
34
|
+
};
|
|
35
|
+
if (isWin) {
|
|
36
|
+
const pwsh = findOnPath('pwsh');
|
|
37
|
+
push(pwsh ? { id: 'pwsh', name: 'PowerShell 7', file: pwsh, args: ['-NoLogo'] } : null);
|
|
38
|
+
const powershell = findOnPath('powershell');
|
|
39
|
+
push(powershell ? { id: 'powershell', name: 'Windows PowerShell', file: powershell, args: ['-NoLogo'] } : null);
|
|
40
|
+
const comspec = process.env['ComSpec'] ?? findOnPath('cmd');
|
|
41
|
+
push(comspec ? { id: 'cmd', name: '命令提示符', file: comspec, args: [] } : null);
|
|
42
|
+
for (const bash of [
|
|
43
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
44
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
45
|
+
join(process.env['LOCALAPPDATA'] ?? '', 'Programs', 'Git', 'bin', 'bash.exe'),
|
|
46
|
+
]) {
|
|
47
|
+
if (bash && existsSync(bash)) {
|
|
48
|
+
push({ id: 'bash', name: 'Git Bash', file: bash, args: ['--login', '-i'] });
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const wsl = findOnPath('wsl');
|
|
53
|
+
push(wsl ? { id: 'wsl', name: 'WSL', file: wsl, args: [] } : null);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const candidates = [
|
|
57
|
+
{ id: 'zsh', name: 'zsh', file: findOnPath('zsh') },
|
|
58
|
+
{ id: 'bash', name: 'bash', file: findOnPath('bash') },
|
|
59
|
+
{ id: 'sh', name: 'sh', file: findOnPath('sh') ?? '/bin/sh' },
|
|
60
|
+
];
|
|
61
|
+
const preferred = process.env['SHELL'];
|
|
62
|
+
for (const item of candidates) {
|
|
63
|
+
if (!item.file)
|
|
64
|
+
continue;
|
|
65
|
+
push({ id: item.id, name: item.name, file: item.file, args: ['-l'] });
|
|
66
|
+
}
|
|
67
|
+
if (preferred) {
|
|
68
|
+
const found = shells.find((item) => item.file === preferred);
|
|
69
|
+
if (found) {
|
|
70
|
+
shells.splice(shells.indexOf(found), 1);
|
|
71
|
+
shells.unshift(found);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return shells;
|
|
76
|
+
}
|
|
77
|
+
export class TerminalManager {
|
|
78
|
+
sessions = new Map();
|
|
79
|
+
seq = 0;
|
|
80
|
+
shells;
|
|
81
|
+
constructor(shells) {
|
|
82
|
+
const detected = shells ?? detectShells();
|
|
83
|
+
this.shells = detected.length
|
|
84
|
+
? detected
|
|
85
|
+
: [{ id: 'sh', name: 'sh', file: isWin ? (process.env['ComSpec'] ?? 'cmd.exe') : '/bin/sh', args: [] }];
|
|
86
|
+
}
|
|
87
|
+
defaultShell() {
|
|
88
|
+
return this.shells[0];
|
|
89
|
+
}
|
|
90
|
+
find(id) {
|
|
91
|
+
return this.sessions.get(id);
|
|
92
|
+
}
|
|
93
|
+
create(shellId, cwd, cols = 100, rows = 28) {
|
|
94
|
+
const shell = this.shells.find((item) => item.id === shellId) ?? this.defaultShell();
|
|
95
|
+
const id = `t${++this.seq}`;
|
|
96
|
+
const session = new TerminalSession(id, shell, cwd, cols, rows);
|
|
97
|
+
this.sessions.set(id, session);
|
|
98
|
+
return session;
|
|
99
|
+
}
|
|
100
|
+
async dispose() {
|
|
101
|
+
const sessions = [...this.sessions.values()];
|
|
102
|
+
this.sessions.clear();
|
|
103
|
+
await Promise.all(sessions.map((session) => session.dispose()));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export class TerminalSession {
|
|
107
|
+
id;
|
|
108
|
+
shell;
|
|
109
|
+
pty = null;
|
|
110
|
+
clients = new Set();
|
|
111
|
+
buffer = '';
|
|
112
|
+
startedAt = '';
|
|
113
|
+
cwd;
|
|
114
|
+
exitPromise = Promise.resolve();
|
|
115
|
+
exitResolve = null;
|
|
116
|
+
cols;
|
|
117
|
+
rows;
|
|
118
|
+
running = false;
|
|
119
|
+
constructor(id, shell, cwd, cols, rows) {
|
|
120
|
+
this.id = id;
|
|
121
|
+
this.shell = shell;
|
|
122
|
+
this.cwd = existsSync(cwd) ? cwd : process.cwd();
|
|
123
|
+
this.cols = Math.max(20, cols);
|
|
124
|
+
this.rows = Math.max(4, rows);
|
|
125
|
+
this.start();
|
|
126
|
+
}
|
|
127
|
+
info() {
|
|
128
|
+
return { id: this.id, shell: this.shell.id, name: this.shell.name, running: this.running, cwd: this.cwd };
|
|
129
|
+
}
|
|
130
|
+
start() {
|
|
131
|
+
this.startedAt = new Date().toISOString();
|
|
132
|
+
const env = {};
|
|
133
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
134
|
+
if (typeof value === 'string')
|
|
135
|
+
env[key] = value;
|
|
136
|
+
}
|
|
137
|
+
env['TERM'] = 'xterm-256color';
|
|
138
|
+
env['COLORTERM'] = 'truecolor';
|
|
139
|
+
// Windows 终端里让 Python / Node 也走 UTF-8,避免第三方程序自己输出 GBK
|
|
140
|
+
env['PYTHONIOENCODING'] = 'utf-8';
|
|
141
|
+
env['PYTHONUTF8'] = '1';
|
|
142
|
+
try {
|
|
143
|
+
// 延迟到运行时加载:没装到对应平台的预编译包时,服务本身仍然能启动
|
|
144
|
+
const spawnPty = globalThis.__bananaPtySpawn;
|
|
145
|
+
if (!spawnPty)
|
|
146
|
+
throw new Error('PTY 未加载');
|
|
147
|
+
this.pty = spawnPty(this.shell.file, this.shell.args, {
|
|
148
|
+
name: 'xterm-256color',
|
|
149
|
+
cols: this.cols,
|
|
150
|
+
rows: this.rows,
|
|
151
|
+
cwd: this.cwd,
|
|
152
|
+
env,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
this.running = false;
|
|
157
|
+
this.broadcast({
|
|
158
|
+
type: 'error',
|
|
159
|
+
message: `终端启动失败:${error instanceof Error ? error.message : String(error)}\n` +
|
|
160
|
+
'PTY 原生模块可能没装上,试试重新安装:npm install -g @vincentbanana/bananacli',
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const pty = this.pty;
|
|
165
|
+
this.running = true;
|
|
166
|
+
this.exitPromise = new Promise((resolvePromise) => {
|
|
167
|
+
this.exitResolve = resolvePromise;
|
|
168
|
+
});
|
|
169
|
+
pty.onData((data) => {
|
|
170
|
+
this.buffer = (this.buffer + data).slice(-200_000);
|
|
171
|
+
this.broadcast({ type: 'data', data });
|
|
172
|
+
});
|
|
173
|
+
pty.onExit(({ exitCode, signal }) => {
|
|
174
|
+
// restart 时旧进程的退出事件可能晚到,不能把新进程的状态覆盖掉
|
|
175
|
+
if (this.pty !== pty)
|
|
176
|
+
return;
|
|
177
|
+
this.running = false;
|
|
178
|
+
this.pty = null;
|
|
179
|
+
this.exitResolve?.();
|
|
180
|
+
this.exitResolve = null;
|
|
181
|
+
const notice = `\r\n\x1b[90m[进程已退出,代码 ${exitCode ?? 0}]\x1b[0m\r\n`;
|
|
182
|
+
this.buffer += notice;
|
|
183
|
+
this.broadcast({ type: 'data', data: notice });
|
|
184
|
+
this.broadcast({ type: 'exit', code: exitCode ?? 0, signal: signal ?? null });
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
broadcast(payload) {
|
|
188
|
+
const line = JSON.stringify(payload);
|
|
189
|
+
for (const client of this.clients) {
|
|
190
|
+
if (client.ws.readyState === 1)
|
|
191
|
+
client.ws.send(line);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
attach(ws) {
|
|
195
|
+
const client = { ws, alive: true };
|
|
196
|
+
this.clients.add(client);
|
|
197
|
+
ws.send(JSON.stringify({ type: 'ready', ...this.info(), startedAt: this.startedAt }));
|
|
198
|
+
// 重连时把最近的屏幕内容一次性补上,再让前端继续增量渲染
|
|
199
|
+
if (this.buffer)
|
|
200
|
+
ws.send(JSON.stringify({ type: 'data', data: this.buffer }));
|
|
201
|
+
ws.on('message', (raw) => {
|
|
202
|
+
let message;
|
|
203
|
+
try {
|
|
204
|
+
message = JSON.parse(String(raw));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (message.type === 'input' && typeof message.data === 'string')
|
|
210
|
+
this.write(message.data);
|
|
211
|
+
else if (message.type === 'resize')
|
|
212
|
+
this.resize(Number(message.cols) || 0, Number(message.rows) || 0);
|
|
213
|
+
else if (message.type === 'restart')
|
|
214
|
+
this.restart();
|
|
215
|
+
else if (message.type === 'close')
|
|
216
|
+
this.dispose();
|
|
217
|
+
});
|
|
218
|
+
ws.on('close', () => this.clients.delete(client));
|
|
219
|
+
ws.on('error', () => this.clients.delete(client));
|
|
220
|
+
}
|
|
221
|
+
write(data) {
|
|
222
|
+
if (this.pty && this.running)
|
|
223
|
+
this.pty.write(data);
|
|
224
|
+
}
|
|
225
|
+
resize(cols, rows) {
|
|
226
|
+
if (!cols || !rows)
|
|
227
|
+
return;
|
|
228
|
+
this.cols = Math.max(20, Math.floor(cols));
|
|
229
|
+
this.rows = Math.max(4, Math.floor(rows));
|
|
230
|
+
try {
|
|
231
|
+
this.pty?.resize(this.cols, this.rows);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
/* 进程刚退出时 resize 会抛,忽略 */
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
restart() {
|
|
238
|
+
this.disposePty();
|
|
239
|
+
this.buffer = '';
|
|
240
|
+
this.start();
|
|
241
|
+
const pty = this.pty;
|
|
242
|
+
if (pty)
|
|
243
|
+
this.broadcast({ type: 'ready', ...this.info(), startedAt: this.startedAt });
|
|
244
|
+
}
|
|
245
|
+
async dispose() {
|
|
246
|
+
const pty = this.pty;
|
|
247
|
+
if (pty) {
|
|
248
|
+
try {
|
|
249
|
+
pty.kill();
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
/* 已经退出 */
|
|
253
|
+
}
|
|
254
|
+
// 等 PTY 真正退出,避免子进程的 stdout 句柄拖住父进程(npm publish 时会卡住)
|
|
255
|
+
await Promise.race([
|
|
256
|
+
this.exitPromise,
|
|
257
|
+
new Promise((resolvePromise) => setTimeout(resolvePromise, 1500)),
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
260
|
+
this.pty = null;
|
|
261
|
+
this.running = false;
|
|
262
|
+
for (const client of this.clients) {
|
|
263
|
+
try {
|
|
264
|
+
client.ws.close();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
/* 已经断开 */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
this.clients.clear();
|
|
271
|
+
}
|
|
272
|
+
disposePty() {
|
|
273
|
+
try {
|
|
274
|
+
this.pty?.kill();
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
/* 已经退出 */
|
|
278
|
+
}
|
|
279
|
+
this.pty = null;
|
|
280
|
+
this.running = false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** 启动时把 PTY 模块解析一次,失败不影响整个 GUI 服务。 */
|
|
284
|
+
export async function loadPty() {
|
|
285
|
+
try {
|
|
286
|
+
const mod = await import('@lydell/node-pty');
|
|
287
|
+
globalThis.__bananaPtySpawn = mod.spawn;
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
export function normalizeCwd(cwd, fallback) {
|
|
295
|
+
const raw = cwd?.trim();
|
|
296
|
+
if (!raw)
|
|
297
|
+
return fallback;
|
|
298
|
+
const next = resolve(raw);
|
|
299
|
+
try {
|
|
300
|
+
return existsSync(next) && statSync(next).isDirectory() ? next : fallback;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return fallback;
|
|
304
|
+
}
|
|
305
|
+
}
|
package/dist/server/ui-server.js
CHANGED
|
@@ -4,7 +4,10 @@ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
|
4
4
|
import { existsSync } from 'node:fs';
|
|
5
5
|
import { extname, join, resolve, dirname } from 'node:path';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
|
+
import { WebSocketServer } from 'ws';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import { TerminalManager, loadPty, normalizeCwd } from "./terminal.js";
|
|
8
11
|
import { bananaHome } from "../config/config.js";
|
|
9
12
|
import { runTurn } from "../agent/loop.js";
|
|
10
13
|
import { compactMessages } from "../agent/context.js";
|
|
@@ -24,6 +27,12 @@ const MIME = {
|
|
|
24
27
|
'.js': 'text/javascript; charset=utf-8',
|
|
25
28
|
'.svg': 'image/svg+xml',
|
|
26
29
|
};
|
|
30
|
+
const VENDOR_FILES = {
|
|
31
|
+
'xterm.js': '@xterm/xterm/lib/xterm.js',
|
|
32
|
+
'xterm.css': '@xterm/xterm/css/xterm.css',
|
|
33
|
+
'addon-fit.js': '@xterm/addon-fit/lib/addon-fit.js',
|
|
34
|
+
'highlight.min.js': '@highlightjs/cdn-assets/highlight.min.js',
|
|
35
|
+
};
|
|
27
36
|
const IMAGE_TYPES = {
|
|
28
37
|
'.png': 'image/png',
|
|
29
38
|
'.jpg': 'image/jpeg',
|
|
@@ -129,6 +138,31 @@ export async function startUiServer(options) {
|
|
|
129
138
|
res.end();
|
|
130
139
|
});
|
|
131
140
|
});
|
|
141
|
+
// 内嵌终端:WebSocket 双向通道 + 真实 PTY(cmd / pwsh / bash)
|
|
142
|
+
const terminals = new TerminalManager();
|
|
143
|
+
void loadPty();
|
|
144
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
145
|
+
server.on('upgrade', (req, socket, head) => {
|
|
146
|
+
try {
|
|
147
|
+
const url = new URL(req.url ?? '/', `http://${host}`);
|
|
148
|
+
if (url.pathname !== `${prefix}/api/terminal/ws`) {
|
|
149
|
+
socket.destroy();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
153
|
+
const shellId = url.searchParams.get('shell') ?? terminals.defaultShell().id;
|
|
154
|
+
const cwd = normalizeCwd(url.searchParams.get('cwd'), config.workspace);
|
|
155
|
+
const existing = url.searchParams.get('id');
|
|
156
|
+
const session = (existing ? terminals.find(existing) : undefined) ??
|
|
157
|
+
terminals.create(shellId, cwd, Number(url.searchParams.get('cols')) || 100, Number(url.searchParams.get('rows')) || 28);
|
|
158
|
+
ws.send(JSON.stringify({ type: 'id', id: session.id }));
|
|
159
|
+
session.attach(ws);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
socket.destroy();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
132
166
|
async function handle(req, res) {
|
|
133
167
|
const url = new URL(req.url ?? '/', `http://${host}`);
|
|
134
168
|
if (!url.pathname.startsWith(prefix))
|
|
@@ -140,6 +174,13 @@ export async function startUiServer(options) {
|
|
|
140
174
|
return sendFile(res, 'style.css');
|
|
141
175
|
if (path === '/app.js')
|
|
142
176
|
return sendFile(res, 'app.js');
|
|
177
|
+
// 终端与语法高亮的本地资源:只放行固定文件,避免任意路径读取
|
|
178
|
+
if (path.startsWith('/vendor/')) {
|
|
179
|
+
const name = path.slice('/vendor/'.length);
|
|
180
|
+
if (Object.hasOwn(VENDOR_FILES, name))
|
|
181
|
+
return sendFile(res, `vendor/${name}`);
|
|
182
|
+
return notFound(res);
|
|
183
|
+
}
|
|
143
184
|
let session = turn?.session ??
|
|
144
185
|
(activeId ? await store.load(activeId) : null) ??
|
|
145
186
|
(await store.lastActive().then(async (id) => (id ? await store.load(id) : null))) ??
|
|
@@ -338,108 +379,14 @@ export async function startUiServer(options) {
|
|
|
338
379
|
activeId = target.id;
|
|
339
380
|
return chat(body, res, target);
|
|
340
381
|
}
|
|
341
|
-
if (path === '/api/
|
|
342
|
-
|
|
343
|
-
|
|
382
|
+
if (path === '/api/shells' && req.method === 'GET') {
|
|
383
|
+
return json(res, 200, {
|
|
384
|
+
shells: terminals.shells,
|
|
385
|
+
default: terminals.defaultShell().id,
|
|
386
|
+
});
|
|
344
387
|
}
|
|
345
388
|
return notFound(res);
|
|
346
389
|
}
|
|
347
|
-
/**
|
|
348
|
-
* 内嵌终端:把一条命令跑在工作区里,输出按行流式推给界面。
|
|
349
|
-
* 这里刻意不依赖 node-pty(原生编译在 Windows 上太重),用管道 + 目录标记实现,
|
|
350
|
-
* 足以跑 git/npm/测试这类常用命令;cd 之后目录会被记住,Ctrl+C 会杀掉子进程。
|
|
351
|
-
*/
|
|
352
|
-
async function runTerminal(command, res) {
|
|
353
|
-
const cwd = terminalWorkdir();
|
|
354
|
-
const trimmed = command.trim();
|
|
355
|
-
if (!trimmed)
|
|
356
|
-
return json(res, 200, { cwd });
|
|
357
|
-
if (terminalChild)
|
|
358
|
-
return json(res, 409, { error: '上一条命令还在跑,先按停止。' });
|
|
359
|
-
res.writeHead(200, {
|
|
360
|
-
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
361
|
-
'Cache-Control': 'no-cache, no-transform',
|
|
362
|
-
Connection: 'keep-alive',
|
|
363
|
-
'X-Accel-Buffering': 'no',
|
|
364
|
-
});
|
|
365
|
-
const send = (type, payload = {}) => {
|
|
366
|
-
if (!res.writableEnded)
|
|
367
|
-
res.write(`data: ${JSON.stringify({ type, ...payload })}\n\n`);
|
|
368
|
-
};
|
|
369
|
-
send('start', { cwd });
|
|
370
|
-
const isWin = process.platform === 'win32';
|
|
371
|
-
const marker = '__BANANA_CWD__';
|
|
372
|
-
// 命令跑完后回显一次当前目录,用来记住 cd 的结果
|
|
373
|
-
const wrapped = isWin ? `${trimmed} & echo ${marker}%CD%` : `${trimmed}\n echo ${marker}$(pwd)`;
|
|
374
|
-
let child;
|
|
375
|
-
try {
|
|
376
|
-
child = isWin
|
|
377
|
-
? spawn('cmd.exe', ['/d', '/s', '/c', wrapped], { cwd, env: process.env, windowsHide: true })
|
|
378
|
-
: spawn('/bin/sh', ['-c', wrapped], { cwd, env: process.env, windowsHide: true });
|
|
379
|
-
}
|
|
380
|
-
catch (error) {
|
|
381
|
-
send('exit', { code: -1, error: error instanceof Error ? error.message : String(error), cwd });
|
|
382
|
-
res.end();
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
terminalChild = child;
|
|
386
|
-
let tailOut = '';
|
|
387
|
-
let tailErr = '';
|
|
388
|
-
const pump = (text, stream) => {
|
|
389
|
-
let buffer = (stream === 'stdout' ? tailOut : tailErr) + text;
|
|
390
|
-
const lines = buffer.split('\n');
|
|
391
|
-
buffer = lines.pop() ?? '';
|
|
392
|
-
for (const raw of lines) {
|
|
393
|
-
const line = raw.replace(/\r$/, '');
|
|
394
|
-
if (line.startsWith(marker)) {
|
|
395
|
-
const next = line.slice(marker.length).trim();
|
|
396
|
-
if (next && existsSync(next))
|
|
397
|
-
terminalCwd = next;
|
|
398
|
-
continue;
|
|
399
|
-
}
|
|
400
|
-
send('out', { stream, data: line + '\n' });
|
|
401
|
-
}
|
|
402
|
-
if (stream === 'stdout')
|
|
403
|
-
tailOut = buffer;
|
|
404
|
-
else
|
|
405
|
-
tailErr = buffer;
|
|
406
|
-
};
|
|
407
|
-
const flush = () => {
|
|
408
|
-
for (const [buffer, stream] of [[tailOut, 'stdout'], [tailErr, 'stderr']]) {
|
|
409
|
-
if (!buffer)
|
|
410
|
-
continue;
|
|
411
|
-
if (!buffer.startsWith(marker))
|
|
412
|
-
send('out', { stream, data: buffer.replace(/\r$/, '') });
|
|
413
|
-
}
|
|
414
|
-
tailOut = '';
|
|
415
|
-
tailErr = '';
|
|
416
|
-
};
|
|
417
|
-
child.stdout?.setEncoding('utf8');
|
|
418
|
-
child.stderr?.setEncoding('utf8');
|
|
419
|
-
child.stdout?.on('data', (chunk) => pump(chunk, 'stdout'));
|
|
420
|
-
child.stderr?.on('data', (chunk) => pump(chunk, 'stderr'));
|
|
421
|
-
// 前端断开(按停止 / 关页面)时连同子进程一起收掉
|
|
422
|
-
res.on('close', () => {
|
|
423
|
-
if (terminalChild === child)
|
|
424
|
-
terminalChild = null;
|
|
425
|
-
if (!child.killed)
|
|
426
|
-
child.kill();
|
|
427
|
-
});
|
|
428
|
-
await new Promise((resolvePromise) => {
|
|
429
|
-
child.on('error', (error) => {
|
|
430
|
-
send('out', { stream: 'stderr', data: `启动失败:${error.message}\n` });
|
|
431
|
-
});
|
|
432
|
-
child.on('close', (code) => {
|
|
433
|
-
flush();
|
|
434
|
-
if (terminalChild === child)
|
|
435
|
-
terminalChild = null;
|
|
436
|
-
send('exit', { code: code ?? 0, cwd: terminalWorkdir() });
|
|
437
|
-
if (!res.writableEnded)
|
|
438
|
-
res.end();
|
|
439
|
-
resolvePromise();
|
|
440
|
-
});
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
390
|
/**
|
|
444
391
|
* 执行 CLI 命令:命令表只有一份(cli/commands.ts),GUI 只是换了个输出通道。
|
|
445
392
|
* 输出逐行推给前端,行为和终端里一致。
|
|
@@ -672,6 +619,8 @@ export async function startUiServer(options) {
|
|
|
672
619
|
token,
|
|
673
620
|
port,
|
|
674
621
|
close: async () => {
|
|
622
|
+
await terminals.dispose();
|
|
623
|
+
wss.close();
|
|
675
624
|
// 浏览器可能留着 keep-alive 连接,先断开再关,否则 Windows 上会触发 libuv 断言
|
|
676
625
|
server.closeAllConnections?.();
|
|
677
626
|
await new Promise((resolvePromise) => server.close(() => resolvePromise()));
|
|
@@ -726,7 +675,22 @@ function notFound(res) {
|
|
|
726
675
|
async function sendFile(res, name) {
|
|
727
676
|
const { readFile } = await import('node:fs/promises');
|
|
728
677
|
try {
|
|
729
|
-
|
|
678
|
+
let target = join(assetRoot(), name);
|
|
679
|
+
// 源码模式(npm start)下 src/ui 里没有打包拷贝的 vendor;
|
|
680
|
+
// 直接从依赖包读,开发时也能秒开终端,不用先 build。
|
|
681
|
+
if (name.startsWith('vendor/') && !existsSync(target)) {
|
|
682
|
+
const vendorName = name.slice('vendor/'.length);
|
|
683
|
+
const specifier = VENDOR_FILES[vendorName];
|
|
684
|
+
if (specifier) {
|
|
685
|
+
try {
|
|
686
|
+
target = createRequire(import.meta.url).resolve(specifier);
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
/* 依赖缺失,走下面的 404 */
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const file = await readFile(target);
|
|
730
694
|
res.writeHead(200, {
|
|
731
695
|
'Content-Type': MIME[extname(name)] ?? 'application/octet-stream',
|
|
732
696
|
'Content-Length': file.byteLength,
|