cicy-desktop 2.1.366 → 2.1.368

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.
@@ -0,0 +1,393 @@
1
+ // Debian 虚拟机控制脚本 —— 首页「Debian」卡片和 desktop 的 QEMU 版 Docker cicy-code 共用这一份。
2
+ // 首页:?raw 打进 bundle,写到 ~/cicy-ai/db/debian-ctl.js,用本机 node(dsh 同款找法 / 便携版)跑;
3
+ // desktop:src/sidecar/qemu-docker.js 把它写到 ~/cicy-ai/db/qemu-vm-ctl.js 再跑。
4
+ // node <ctl> <status|install|start|stop|open|accel|fwd <port>...>
5
+ // 两边版本可能不同步,所以只认同一套磁盘约定(vmDir 下的文件 + 端口),谁先开机谁当 runner。
6
+ // 只打一行 JSON 到 stdout。不靠 WSL:QEMU 便携版 + Debian 官方云镜像,
7
+ // 有 WHPX(Windows 虚拟机监控程序平台)就用硬件加速,没有就退回纯软件模拟(TCG),
8
+ // 所以不要管理员也能开箱用;「开启加速」才需要一次管理员 + 重启。
9
+ // 后台活(install-run / run)一律用 Start-Process -WindowStyle Hidden 起 node:
10
+ // 这样后台 node 自己有一个被隐藏的控制台,它起的 curl / qemu-img / qemu 都继承它,
11
+ // 不会每个子进程各弹一个黑窗口(dsh 那次的教训)。
12
+ // 首页不发 desktop 版就能更新这里;改版本号/端口/源时同步改 DEB_* 常量。
13
+ const fs = require("fs"), path = require("path"), os = require("os"), cp = require("child_process"), net = require("net"), http = require("http"), crypto = require("crypto");
14
+ const OP = process.argv[2] || "status";
15
+
16
+ const QEMU_VER = "11.1.0";
17
+ const QEMU_ZIP = "cicy-qemu-" + QEMU_VER + "-win-x64.zip";
18
+ const DEB_SERIAL = "20261017-2634";
19
+ const DEB_IMG = "debian-13-genericcloud-amd64-" + DEB_SERIAL + ".qcow2";
20
+ // 官方 SHA512SUMS 里的值(CI 转存前也按它校验过)—— 不管从哪个源下,落盘后都按它验。
21
+ const DEB_SHA512 = "ec584ccabfef7120b4c4f7482adf0e615894d9292cedec446b64194b951ebe5dd97c336b5dcbae458333764b0edd516334506017f33c4f2020ef8d38b569ffe3";
22
+ const R2 = "https://r2.deepfetch.de5.net/releases";
23
+ const QEMU_SRCS = [R2 + "/qemu/" + QEMU_ZIP];
24
+ // R2 上是两片(wrangler 单文件上限 300MiB):数组 = 分片,按序下完拼回;字符串 = 整个文件。
25
+ const DEB_SRCS = [[R2 + "/debian/" + DEB_IMG + ".p0", R2 + "/debian/" + DEB_IMG + ".p1"], "https://cloud.debian.org/images/cloud/trixie/" + DEB_SERIAL + "/" + DEB_IMG];
26
+ // 宿主侧端口全部只绑 127.0.0.1(hostfwd 写死),外网/局域网碰不到。
27
+ const TTY_PORT = 17681, QMP_PORT = 17682, SEED_PORT = 17683, SSH_PORT = 17622;
28
+ // cloud-init 的 instance-id:改 user-data 内容时 +1,已装好的虚拟机下次开机会按新配置重跑一遍
29
+ // (users/write_files/runcmd 都是幂等的,数据不动)。2 = 注入 desktop 用的 SSH 公钥。
30
+ const PROVISION = 2;
31
+ const DISK_SIZE = "40G";
32
+
33
+ const home = os.homedir();
34
+ const WIN = process.platform === "win32";
35
+ const LAD = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
36
+ const qemuDir = path.join(LAD, "cicy-qemu");
37
+ const dlDir = path.join(LAD, "cicy-qemu-dl");
38
+ const vmDir = path.join(home, "cicy-ai", "vm", "debian");
39
+ const logDir = path.join(home, "cicy-ai", "logs");
40
+ for (const d of [vmDir, logDir, dlDir]) { try { fs.mkdirSync(d, { recursive: true }); } catch {} }
41
+ const F = {
42
+ base: path.join(vmDir, DEB_IMG), disk: path.join(vmDir, "disk.qcow2"), state: path.join(vmDir, "state.json"),
43
+ console: path.join(logDir, "debian-console.log"), qemuLog: path.join(logDir, "debian-qemu.log"), runLog: path.join(logDir, "debian-run.log"),
44
+ install: path.join(vmDir, "install.json"), runPid: path.join(vmDir, "run.pid"), qemuPid: path.join(vmDir, "qemu.pid"),
45
+ key: path.join(vmDir, "id_rsa"), pub: path.join(vmDir, "id_rsa.pub"),
46
+ zip: path.join(dlDir, QEMU_ZIP), img: path.join(dlDir, DEB_IMG),
47
+ };
48
+ const qemuExe = path.join(qemuDir, "qemu-system-x86_64.exe"), qemuImg = path.join(qemuDir, "qemu-img.exe"), ttydBin = path.join(qemuDir, "guest", "ttyd.x86_64");
49
+ const CURL = WIN ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "curl.exe") : "curl";
50
+ const TAR = WIN ? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe") : "tar";
51
+
52
+ function out(o) { process.stdout.write(JSON.stringify(o) + "\n"); }
53
+ function readJson(f) { try { return JSON.parse(fs.readFileSync(f, "utf8").replace(/^\uFEFF/, "")); } catch { return null; } }
54
+ function writeJson(f, o) { try { fs.writeFileSync(f, JSON.stringify(o, null, 2)); } catch {} }
55
+ function pidAlive(n) { if (!n) return false; try { process.kill(n, 0); return true; } catch { return false; } }
56
+ function readPid(f) { try { const n = Number(fs.readFileSync(f, "utf8").trim()); return n > 0 ? n : 0; } catch { return 0; } }
57
+ function size(f) { try { return fs.statSync(f).size; } catch { return 0; } }
58
+ function tailOf(file, max) { try { const st = fs.statSync(file); const len = Math.min(st.size, max); if (!len) return ""; const fd = fs.openSync(file, "r"); try { const b = Buffer.alloc(len); fs.readSync(fd, b, 0, len, st.size - len); return b.toString("utf8"); } finally { fs.closeSync(fd); } } catch { return ""; } }
59
+ function log(msg) { try { fs.appendFileSync(F.runLog, new Date().toISOString() + " " + msg + "\n"); } catch {} }
60
+ function state() {
61
+ let s = readJson(F.state);
62
+ if (!s || !s.token) { s = { token: crypto.randomBytes(16).toString("hex"), created: Date.now() }; writeJson(F.state, s); }
63
+ ensureKey();
64
+ return s;
65
+ }
66
+ // desktop 进虚拟机跑 docker 用的 SSH 密钥(RSA PKCS1 PEM:ssh2 和 OpenSSH 都认)。
67
+ // 公钥按 OpenSSH 线格式手拼:string "ssh-rsa" + mpint e + mpint n。
68
+ function sshString(b) { const l = Buffer.alloc(4); l.writeUInt32BE(b.length); return Buffer.concat([l, b]); }
69
+ function sshMpint(b) { if (b[0] & 0x80) b = Buffer.concat([Buffer.from([0]), b]); return sshString(b); }
70
+ function ensureKey() {
71
+ if (fs.existsSync(F.key) && fs.existsSync(F.pub)) return;
72
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 3072 });
73
+ const jwk = publicKey.export({ format: "jwk" });
74
+ const blob = Buffer.concat([sshString(Buffer.from("ssh-rsa")), sshMpint(Buffer.from(jwk.e, "base64url")), sshMpint(Buffer.from(jwk.n, "base64url"))]);
75
+ fs.writeFileSync(F.key, privateKey.export({ type: "pkcs1", format: "pem" }), { mode: 0o600 });
76
+ fs.writeFileSync(F.pub, "ssh-rsa " + blob.toString("base64") + " cicy-desktop\n");
77
+ }
78
+ function pubKey() { try { return fs.readFileSync(F.pub, "utf8").trim(); } catch { return ""; } }
79
+ function installed() { return fs.existsSync(qemuExe) && fs.existsSync(qemuImg) && fs.existsSync(F.base) && fs.existsSync(F.disk); }
80
+
81
+ // 后台起一个隐藏的 node 跑本脚本的某个 op(见文件头:为什么必须 Start-Process Hidden)。
82
+ function spawnHidden(op, logFile) {
83
+ const q = (x) => "'" + String(x).replace(/'/g, "''") + "'";
84
+ if (WIN) {
85
+ const pidOut = path.join(vmDir, "spawn-" + op + ".pid");
86
+ const cmd = "$p = Start-Process -FilePath " + q(process.execPath) + " -ArgumentList @(" + [__filename, op].map((a) => q('"' + a + '"')).join(",") + ")"
87
+ + " -WindowStyle Hidden -RedirectStandardOutput " + q(logFile) + " -RedirectStandardError " + q(logFile + ".err")
88
+ + " -WorkingDirectory " + q(vmDir) + " -PassThru; Set-Content -Path " + q(pidOut) + " -Value $p.Id";
89
+ // Start-Process 带重定向时用继承句柄的 CreateProcess:后台 node 会顺手继承 powershell 的
90
+ // stdout —— 若那是管道,spawnSync 要等后台进程退出才 EOF(= install/start 卡到超时)。
91
+ // 所以 powershell 的 stdio 全给 ignore(NUL),PID 走文件。
92
+ try { fs.unlinkSync(pidOut); } catch {}
93
+ cp.spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", cmd], { stdio: "ignore", timeout: 60000, windowsHide: true });
94
+ return readPid(pidOut);
95
+ }
96
+ const fd = fs.openSync(logFile, "a");
97
+ const c = cp.spawn(process.execPath, [__filename, op], { detached: true, windowsHide: true, stdio: ["ignore", fd, fd], cwd: vmDir });
98
+ c.unref(); try { fs.closeSync(fd); } catch {}
99
+ return c.pid;
100
+ }
101
+
102
+ function hashFile(f, algo) {
103
+ return new Promise((res) => {
104
+ const h = crypto.createHash(algo), s = fs.createReadStream(f);
105
+ s.on("data", (b) => h.update(b)); s.on("end", () => res(h.digest("hex"))); s.on("error", () => res(""));
106
+ });
107
+ }
108
+ function curlText(url) {
109
+ const r = cp.spawnSync(CURL, ["-fsSL", "--max-time", "30", url], { encoding: "utf8", windowsHide: true, timeout: 40000 });
110
+ return r.status === 0 ? String(r.stdout || "").trim() : "";
111
+ }
112
+ function curlTo(u, dest) {
113
+ log("download " + u);
114
+ const r = cp.spawnSync(CURL, ["-L", "-s", "-S", "-f", "-C", "-", "-o", dest + ".part", "--speed-limit", "100000", "--speed-time", "30", "--retry", "2", u], { encoding: "utf8", windowsHide: true, timeout: 3 * 3600 * 1000 });
115
+ log("curl exit=" + r.status + " bytes=" + size(dest + ".part") + " " + String(r.stderr || "").trim().slice(-200));
116
+ if (r.status === 0) { try { fs.renameSync(dest + ".part", dest); } catch { try { fs.copyFileSync(dest + ".part", dest); fs.unlinkSync(dest + ".part"); } catch {} } return fs.existsSync(dest); }
117
+ // 33 = 服务器不支持续传 / 416 已下完但不认:清掉,下一个源从头来
118
+ if (r.status === 33 || /416/.test(String(r.stderr || ""))) { try { fs.unlinkSync(dest + ".part"); } catch {} }
119
+ return false;
120
+ }
121
+ // 分片源:每片单独续传下到 dest.pN,全齐了按序拼成 dest。进度条看的是 dest.part,
122
+ // 所以拼接前先把已下完的片的大小累计到 prog.base,status 用 base + 当前片 .part。
123
+ function downloadParts(parts, dest, prog) {
124
+ let base = 0;
125
+ for (let i = 0; i < parts.length; i++) {
126
+ const pf = dest + ".p" + i;
127
+ prog({ file: pf, base });
128
+ if (!fs.existsSync(pf) && !curlTo(parts[i], pf)) return false;
129
+ base += size(pf);
130
+ }
131
+ const fd = fs.openSync(dest + ".part", "w");
132
+ try { for (let i = 0; i < parts.length; i++) { const b = fs.readFileSync(dest + ".p" + i); fs.writeSync(fd, b); } } finally { fs.closeSync(fd); }
133
+ fs.renameSync(dest + ".part", dest);
134
+ for (let i = 0; i < parts.length; i++) { try { fs.unlinkSync(dest + ".p" + i); } catch {} }
135
+ prog({ file: dest, base: 0 });
136
+ return true;
137
+ }
138
+ // 断点续传下载;一个源 30s 内低于 100KB/s 就换下一个。返回用了哪个源。
139
+ function download(srcs, dest, prog) {
140
+ for (const u of srcs) {
141
+ if (Array.isArray(u)) {
142
+ prog({ src: u[0] });
143
+ if (downloadParts(u, dest, prog)) return u[0];
144
+ continue;
145
+ }
146
+ prog({ src: u, file: dest, base: 0 });
147
+ if (curlTo(u, dest)) return u;
148
+ }
149
+ return "";
150
+ }
151
+
152
+ // ---- install-run:真正干活的后台进程,进度写 install.json,status 读它 ----
153
+ async function installRun() {
154
+ const st = { pid: process.pid, phase: "qemu", started: Date.now(), error: "" };
155
+ const prog = (o) => { Object.assign(st, o); writeJson(F.install, st); };
156
+ prog({});
157
+ try {
158
+ if (!(fs.existsSync(qemuExe) && fs.existsSync(qemuImg) && fs.existsSync(ttydBin))) {
159
+ prog({ phase: "qemu", file: F.zip, total: 44400000 });
160
+ const want = curlText(QEMU_SRCS[0] + ".sha256").split(/\s+/)[0];
161
+ if (!(fs.existsSync(F.zip) && want && (await hashFile(F.zip, "sha256")) === want)) {
162
+ try { fs.unlinkSync(F.zip); } catch {}
163
+ if (!download(QEMU_SRCS, F.zip, prog)) return prog({ error: "qemu_download" });
164
+ if (want && (await hashFile(F.zip, "sha256")) !== want) { try { fs.unlinkSync(F.zip); } catch {} return prog({ error: "qemu_checksum" }); }
165
+ }
166
+ prog({ phase: "extract" });
167
+ const tmp = path.join(dlDir, "x");
168
+ fs.rmSync(tmp, { recursive: true, force: true }); fs.mkdirSync(tmp, { recursive: true });
169
+ const r = cp.spawnSync(TAR, ["-xf", F.zip, "-C", tmp], { encoding: "utf8", windowsHide: true, timeout: 600000 });
170
+ if (r.status !== 0 || !fs.existsSync(path.join(tmp, "cicy-qemu", "qemu-system-x86_64.exe"))) return prog({ error: "qemu_extract", detail: String(r.stderr || "").slice(-300) });
171
+ fs.rmSync(qemuDir, { recursive: true, force: true });
172
+ fs.renameSync(path.join(tmp, "cicy-qemu"), qemuDir);
173
+ fs.rmSync(tmp, { recursive: true, force: true }); try { fs.unlinkSync(F.zip); } catch {}
174
+ }
175
+ if (!fs.existsSync(F.base)) {
176
+ prog({ phase: "image", file: F.img, total: 342818816 });
177
+ if (!(fs.existsSync(F.img) && (await hashFile(F.img, "sha512")) === DEB_SHA512)) {
178
+ try { fs.unlinkSync(F.img); } catch {}
179
+ if (!download(DEB_SRCS, F.img, prog)) return prog({ error: "image_download" });
180
+ prog({ phase: "verify" });
181
+ if ((await hashFile(F.img, "sha512")) !== DEB_SHA512) { try { fs.unlinkSync(F.img); } catch {} return prog({ error: "image_checksum" }); }
182
+ }
183
+ try { fs.renameSync(F.img, F.base); } catch { fs.copyFileSync(F.img, F.base); fs.unlinkSync(F.img); }
184
+ }
185
+ if (!fs.existsSync(F.disk)) {
186
+ prog({ phase: "disk" });
187
+ // 底盘只读,用户数据全在 overlay;以后换 Debian 底盘只要 rebase,不丢数据。
188
+ const r = cp.spawnSync(qemuImg, ["create", "-f", "qcow2", "-F", "qcow2", "-b", path.basename(F.base), F.disk, DISK_SIZE], { encoding: "utf8", windowsHide: true, timeout: 120000, cwd: vmDir });
189
+ if (r.status !== 0) return prog({ error: "disk_create", detail: String(r.stderr || r.stdout || "").slice(-300) });
190
+ }
191
+ state();
192
+ prog({ phase: "done", done: Date.now() });
193
+ } catch (e) { prog({ error: "exception", detail: String(e && e.message || e).slice(0, 300) }); }
194
+ }
195
+
196
+ // ---- cloud-init NoCloud 种子:虚拟机经 slirp 的 10.0.2.2 访问宿主 127.0.0.1:SEED_PORT ----
197
+ function userData(tok) {
198
+ return [
199
+ "#cloud-config",
200
+ "hostname: cicy-debian",
201
+ "timezone: Asia/Shanghai",
202
+ "users:",
203
+ " - name: cicy",
204
+ " shell: /bin/bash",
205
+ " groups: [sudo]",
206
+ " sudo: ALL=(ALL) NOPASSWD:ALL",
207
+ " lock_passwd: true",
208
+ " ssh_authorized_keys:",
209
+ " - " + pubKey(),
210
+ "ssh_pwauth: false",
211
+ "write_files:",
212
+ " - path: /etc/systemd/system/cicy-ttyd.service",
213
+ " content: |",
214
+ " [Unit]",
215
+ " Description=cicy web terminal",
216
+ " After=network-online.target",
217
+ " [Service]",
218
+ " User=cicy",
219
+ " WorkingDirectory=/home/cicy",
220
+ " Environment=HOME=/home/cicy",
221
+ " ExecStart=/usr/local/bin/ttyd -W -p 7681 -b /" + tok + " -t titleFixed=Debian -t fontSize=14 bash -l",
222
+ " Restart=always",
223
+ " RestartSec=2",
224
+ " [Install]",
225
+ " WantedBy=multi-user.target",
226
+ "runcmd:",
227
+ " - [python3, -c, \"import urllib.request as u; open('/usr/local/bin/ttyd','wb').write(u.urlopen('http://10.0.2.2:" + SEED_PORT + "/ttyd', timeout=120).read())\"]",
228
+ " - [chmod, '0755', /usr/local/bin/ttyd]",
229
+ " - [systemctl, daemon-reload]",
230
+ " - [systemctl, enable, --now, cicy-ttyd.service]",
231
+ "",
232
+ ].join("\n");
233
+ }
234
+ function seedServer(tok) {
235
+ const srv = http.createServer((req, res) => {
236
+ const u = String(req.url || "").split("?")[0];
237
+ log("seed " + req.method + " " + u);
238
+ if (u === "/meta-data") { res.end("instance-id: cicy-debian-" + PROVISION + "\nlocal-hostname: cicy-debian\n"); return; }
239
+ if (u === "/user-data") { res.end(userData(tok)); return; }
240
+ if (u === "/vendor-data" || u === "/network-config") { res.end(""); return; }
241
+ if (u === "/ttyd") { res.setHeader("content-type", "application/octet-stream"); fs.createReadStream(ttydBin).pipe(res); return; }
242
+ res.statusCode = 404; res.end();
243
+ });
244
+ srv.on("error", (e) => log("seed server error: " + e.message));
245
+ srv.listen(SEED_PORT, "127.0.0.1");
246
+ return srv;
247
+ }
248
+
249
+ function vmSize() {
250
+ const totalMb = Math.floor(os.totalmem() / 1048576), cpus = os.cpus().length || 2;
251
+ const mem = Math.max(1024, Math.min(4096, Math.floor(totalMb / 4 / 256) * 256));
252
+ return { mem, smp: Math.max(1, Math.min(4, Math.floor(cpus / 2))) };
253
+ }
254
+ // ---- run:后台常驻,开种子服务 + 拉起 qemu,qemu 退出它就退出 ----
255
+ function run() {
256
+ const s = state(), { mem, smp } = vmSize();
257
+ fs.writeFileSync(F.runPid, String(process.pid));
258
+ writeJson(F.state, Object.assign(readJson(F.state) || s, { bootProvision: PROVISION }));
259
+ const srv = seedServer(s.token);
260
+ const args = [
261
+ "-name", "cicy-debian",
262
+ "-accel", "whpx,kernel-irqchip=off", "-accel", "tcg,thread=multi",
263
+ "-m", String(mem), "-smp", String(smp),
264
+ "-drive", "file=" + F.disk + ",if=virtio,cache=writeback,discard=unmap",
265
+ "-netdev", "user,id=n0,hostfwd=tcp:127.0.0.1:" + TTY_PORT + "-:7681,hostfwd=tcp:127.0.0.1:" + SSH_PORT + "-:22",
266
+ "-device", "virtio-net-pci,netdev=n0",
267
+ "-smbios", "type=1,serial=ds=nocloud;s=http://10.0.2.2:" + SEED_PORT + "/",
268
+ "-qmp", "tcp:127.0.0.1:" + QMP_PORT + ",server=on,wait=off",
269
+ "-display", "none", "-vga", "none",
270
+ "-serial", "file:" + F.console,
271
+ ];
272
+ log("qemu " + JSON.stringify(args));
273
+ const lfd = fs.openSync(F.qemuLog, "w");
274
+ const q = cp.spawn(qemuExe, args, { cwd: qemuDir, windowsHide: true, stdio: ["ignore", lfd, lfd] });
275
+ fs.writeFileSync(F.qemuPid, String(q.pid));
276
+ q.on("exit", (code) => { log("qemu exit " + code); try { fs.unlinkSync(F.qemuPid); } catch {} try { fs.unlinkSync(F.runPid); } catch {} srv.close(); process.exit(0); });
277
+ q.on("error", (e) => { log("qemu spawn error " + e.message); process.exit(1); });
278
+ }
279
+
280
+ function qmp(cmds, timeoutMs) {
281
+ return new Promise((res) => {
282
+ const sock = net.connect(QMP_PORT, "127.0.0.1"); let buf = "", sent = false; const replies = [];
283
+ const done = (v) => { try { sock.destroy(); } catch {} res(v); };
284
+ const t = setTimeout(() => done(replies.length ? replies : null), timeoutMs || 5000);
285
+ sock.on("data", (d) => {
286
+ buf += d.toString("utf8");
287
+ let i; while ((i = buf.indexOf("\n")) >= 0) {
288
+ const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); if (!line) continue;
289
+ let j; try { j = JSON.parse(line); } catch { continue; }
290
+ if (j.QMP && !sent) { sent = true; for (const c of [{ execute: "qmp_capabilities" }, ...cmds]) sock.write(JSON.stringify(c) + "\n"); continue; }
291
+ if ("return" in j || "error" in j) { replies.push(j); if (replies.length === cmds.length + 1) { clearTimeout(t); done(replies.slice(1)); } }
292
+ }
293
+ });
294
+ sock.on("error", () => { clearTimeout(t); done(null); });
295
+ });
296
+ }
297
+ function httpOk(port, p) {
298
+ return new Promise((res) => {
299
+ const r = http.get({ host: "127.0.0.1", port, path: p, timeout: 3000 }, (x) => { x.resume(); res(x.statusCode === 200); });
300
+ r.on("timeout", () => { r.destroy(); res(false); }); r.on("error", () => res(false));
301
+ });
302
+ }
303
+ function accelOf() {
304
+ const t = tailOf(F.qemuLog, 32 * 1024);
305
+ if (!t && !pidAlive(readPid(F.qemuPid))) return "";
306
+ // qemu 依次试 -accel:whpx 起不来会打一行 WHPX 报错再退到 tcg
307
+ return /whpx/i.test(t) && /(fail|error|no accelerator|not supported|unavailable)/i.test(t) ? "tcg" : "whpx";
308
+ }
309
+ function whpxFeature() { return WIN && fs.existsSync(path.join(process.env.SystemRoot || "C:\\Windows", "System32", "WinHvPlatform.dll")); }
310
+ // 串口日志最后一行可读的话 = 开机进度(给卡片「启动中」下面那一行小字)
311
+ function bootLine() {
312
+ const t = tailOf(F.console, 8 * 1024).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").split(/\r?\n/).map((l) => l.trim()).filter((l) => l && /[A-Za-z]/.test(l));
313
+ return (t.pop() || "").slice(0, 160);
314
+ }
315
+
316
+ async function status() {
317
+ const inst = readJson(F.install), instPid = inst && inst.pid;
318
+ const installing = !!(inst && !inst.done && !inst.error && pidAlive(instPid));
319
+ let bytes = 0; if (installing && inst.file) bytes = (inst.base || 0) + (size(inst.file + ".part") || size(inst.file));
320
+ const qpid = readPid(F.qemuPid), running = pidAlive(qpid);
321
+ const s = readJson(F.state);
322
+ const ready = running && s && s.token ? await httpOk(TTY_PORT, "/" + s.token + "/") : false;
323
+ return {
324
+ ok: true, platform: process.platform, installed: installed(), installing,
325
+ phase: inst && inst.phase, bytes, total: inst && inst.total, src: inst && inst.src,
326
+ installError: inst && !installing && inst.error ? [inst.error, inst.detail].filter(Boolean).join(" · ") : "",
327
+ running, ready, accel: running ? accelOf() : "", whpxFeature: whpxFeature(),
328
+ boot: running && !ready ? bootLine() : "", ...vmSize(), sshPort: SSH_PORT, qmpPort: QMP_PORT,
329
+ provision: running ? (readJson(F.state) || {}).bootProvision || 0 : 0, key: F.key,
330
+ };
331
+ }
332
+ async function install() {
333
+ const s = await status();
334
+ if (s.installed) return { ok: true, already: true };
335
+ if (s.installing) return { ok: true, installing: true };
336
+ try { fs.unlinkSync(F.install); } catch {}
337
+ const pid = spawnHidden("install-run", path.join(logDir, "debian-install.log"));
338
+ return pid ? { ok: true, pid } : { ok: false, error: "spawn_failed" };
339
+ }
340
+ async function start() {
341
+ if (!installed()) return { ok: false, error: "not_installed" };
342
+ if (pidAlive(readPid(F.qemuPid))) return { ok: true, already: true };
343
+ for (const p of [TTY_PORT, QMP_PORT, SEED_PORT, SSH_PORT]) {
344
+ const busy = await new Promise((res) => { const c = net.connect(p, "127.0.0.1"); c.on("connect", () => { c.destroy(); res(true); }); c.on("error", () => res(false)); });
345
+ if (busy) return { ok: false, error: "port_in_use", detail: "127.0.0.1:" + p };
346
+ }
347
+ try { fs.writeFileSync(F.console, ""); } catch {}
348
+ const pid = spawnHidden("run", F.runLog + ".out");
349
+ if (!pid) return { ok: false, error: "spawn_failed" };
350
+ for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 500)); if (pidAlive(readPid(F.qemuPid))) return { ok: true, pid }; }
351
+ return { ok: false, error: "qemu_not_started", tail: tailOf(F.qemuLog, 1200) + tailOf(F.runLog, 800) };
352
+ }
353
+ async function stop() {
354
+ const qpid = readPid(F.qemuPid);
355
+ if (!pidAlive(qpid)) return { ok: true, already: true };
356
+ await qmp([{ execute: "system_powerdown" }], 5000); // 先 ACPI 关机,让 Debian 自己落盘
357
+ for (let i = 0; i < 60 && pidAlive(qpid); i++) await new Promise((r) => setTimeout(r, 1000));
358
+ if (pidAlive(qpid)) { await qmp([{ execute: "quit" }], 3000); await new Promise((r) => setTimeout(r, 1500)); }
359
+ if (pidAlive(qpid)) { try { process.kill(qpid); } catch {} }
360
+ return { ok: !pidAlive(qpid) };
361
+ }
362
+ async function open() {
363
+ const s = readJson(F.state);
364
+ if (!s || !s.token || !(await httpOk(TTY_PORT, "/" + s.token + "/"))) return { ok: false, error: "not_ready" };
365
+ return { ok: true, url: "http://127.0.0.1:" + TTY_PORT + "/" + s.token + "/" };
366
+ }
367
+ // 运行时加端口转发(宿主 127.0.0.1:P → 虚拟机 :P),走 QMP 的 HMP hostfwd_add。
368
+ // 开机才有效、关机即失;重复添加 qemu 会报错,当作已存在。只绑 127.0.0.1。
369
+ async function fwd(ports) {
370
+ const want = ports.map(Number).filter((p) => Number.isInteger(p) && p > 0 && p < 65536);
371
+ if (!pidAlive(readPid(F.qemuPid))) return { ok: false, error: "not_running" };
372
+ const r = await qmp(want.map((p) => ({ execute: "human-monitor-command", arguments: { "command-line": "hostfwd_add n0 tcp:127.0.0.1:" + p + "-:" + p } })), 8000);
373
+ if (!r) return { ok: false, error: "qmp_unreachable" };
374
+ return { ok: true, ports: want, replies: r.map((x) => String((x && x.return) || (x && x.error && x.error.desc) || "").trim()) };
375
+ }
376
+ // 开启 WHPX:弹 UAC,管理员 PowerShell 打开「Windows 虚拟机监控程序平台」,之后要重启电脑。
377
+ function accel() {
378
+ if (!WIN) return { ok: false, error: "windows_only" };
379
+ const inner = "Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform -All -NoRestart | Out-Null";
380
+ const cmd = "$p = Start-Process powershell -Verb RunAs -WindowStyle Hidden -Wait -PassThru -ArgumentList @('-NoProfile','-Command','" + inner.replace(/'/g, "''") + "'); $p.ExitCode";
381
+ const r = cp.spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", cmd], { encoding: "utf8", timeout: 600000, windowsHide: true });
382
+ const code = Number(String(r.stdout || "").trim().split(/\s+/).pop());
383
+ if (r.status !== 0 || code !== 0) return { ok: false, error: "enable_failed", detail: String(r.stderr || r.stdout || "").trim().slice(-300) };
384
+ return { ok: true, needReboot: true };
385
+ }
386
+
387
+ (async () => {
388
+ try {
389
+ if (OP === "install-run") return await installRun();
390
+ if (OP === "run") return run();
391
+ out(OP === "install" ? await install() : OP === "start" ? await start() : OP === "stop" ? await stop() : OP === "open" ? await open() : OP === "accel" ? accel() : OP === "fwd" ? await fwd(process.argv.slice(3)) : await status());
392
+ } catch (e) { out({ ok: false, error: "exception", detail: String(e && e.message || e).slice(0, 300) }); }
393
+ })();
@@ -0,0 +1,55 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert");
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ // linux-backend 在 require 时不做事,init() 才判定;每个用例换一个临时 HOME + 清模块缓存。
8
+ function fresh({ platform = "win32", files = {}, env = {} } = {}) {
9
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), "lb-"));
10
+ for (const [rel, body] of Object.entries(files)) {
11
+ const f = path.join(home, rel); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, body);
12
+ }
13
+ const saved = { platform: process.platform, home: os.homedir, env: { ...process.env } };
14
+ Object.defineProperty(process, "platform", { value: platform });
15
+ os.homedir = () => home;
16
+ delete process.env.CICY_DOCKER_APP_PORT; delete process.env.CICY_SIDECAR_PORT; delete process.env.CICY_LINUX_BACKEND;
17
+ Object.assign(process.env, env);
18
+ delete require.cache[require.resolve("../src/sidecar/linux-backend")];
19
+ const lb = require("../src/sidecar/linux-backend");
20
+ const restore = () => { Object.defineProperty(process, "platform", { value: saved.platform }); os.homedir = saved.home; process.env = saved.env; };
21
+ return { lb, home, restore };
22
+ }
23
+
24
+ test("new Windows machine (no WSL install) → qemu on :8009, and the choice is persisted", () => {
25
+ const { lb, home, restore } = fresh();
26
+ try {
27
+ assert.strictEqual(lb.init(), "qemu");
28
+ assert.strictEqual(process.env.CICY_DOCKER_APP_PORT, "8009");
29
+ const j = JSON.parse(fs.readFileSync(path.join(home, "cicy-ai", "db", "linux-backend.json"), "utf8"));
30
+ assert.strictEqual(j.backend, "qemu");
31
+ } finally { restore(); }
32
+ });
33
+
34
+ test("old machine with a WSL docker status on :8008 stays on wsl and keeps :8008", () => {
35
+ const { lb, restore } = fresh({ files: { "cicy-ai/db/docker-status.json": JSON.stringify({ installed: true, port: 8008 }) } });
36
+ try {
37
+ assert.strictEqual(lb.init(), "wsl");
38
+ assert.strictEqual(process.env.CICY_DOCKER_APP_PORT, undefined);
39
+ } finally { restore(); }
40
+ });
41
+
42
+ test("a persisted decision wins over later evidence (no flip-flopping)", () => {
43
+ const { lb, restore } = fresh({ files: {
44
+ "cicy-ai/db/linux-backend.json": JSON.stringify({ backend: "qemu" }),
45
+ "cicy-ai/db/docker-status.json": JSON.stringify({ installed: true, port: 8008 }),
46
+ } });
47
+ try { assert.strictEqual(lb.init(), "qemu"); } finally { restore(); }
48
+ });
49
+
50
+ test("CICY_LINUX_BACKEND forces the backend; non-Windows is native", () => {
51
+ let f = fresh({ env: { CICY_LINUX_BACKEND: "wsl" } });
52
+ try { assert.strictEqual(f.lb.init(), "wsl"); } finally { f.restore(); }
53
+ f = fresh({ platform: "darwin" });
54
+ try { assert.strictEqual(f.lb.init(), "native"); assert.strictEqual(process.env.CICY_DOCKER_APP_PORT, undefined); } finally { f.restore(); }
55
+ });