cicy-desktop 2.1.69 → 2.1.71
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/package.json +7 -7
- package/src/backends/homepage-react/assets/index-BpljolQs.js +365 -0
- package/src/backends/homepage-react/assets/{index-BniEbx_j.css → index-C9AZlTew.css} +1 -1
- package/src/backends/homepage-react/index.html +2 -2
- package/src/backends/local-teams.js +42 -4
- package/src/backends/sidecar-ipc.js +23 -1
- package/src/i18n/locales/en.json +9 -7
- package/src/i18n/locales/zh-CN.json +9 -7
- package/src/sidecar/cicy-code.js +54 -111
- package/src/sidecar/localbin.js +133 -0
- package/src/sidecar/native.js +4 -2
- package/src/sidecar/runtime.js +7 -3
- package/workers/render/src/App.css +156 -10
- package/workers/render/src/App.jsx +254 -39
- package/.env.dev +0 -7
- package/src/backends/homepage-react/assets/index-B8gGhz8B.js +0 -365
- package/workers/render.bak.20260528-2338/DESIGN_v2.md +0 -254
- package/workers/render.bak.20260528-2338/index.html +0 -12
- package/workers/render.bak.20260528-2338/package-lock.json +0 -827
- package/workers/render.bak.20260528-2338/package.json +0 -19
- package/workers/render.bak.20260528-2338/public/_headers +0 -5
- package/workers/render.bak.20260528-2338/public/manifest.json +0 -6
- package/workers/render.bak.20260528-2338/src/App.css +0 -224
- package/workers/render.bak.20260528-2338/src/App.jsx +0 -1028
- package/workers/render.bak.20260528-2338/src/api.js +0 -285
- package/workers/render.bak.20260528-2338/src/cicycode-ops.js +0 -222
- package/workers/render.bak.20260528-2338/src/components/BackendCard.css +0 -299
- package/workers/render.bak.20260528-2338/src/components/BackendCard.jsx +0 -133
- package/workers/render.bak.20260528-2338/src/components/BackendModal.css +0 -161
- package/workers/render.bak.20260528-2338/src/components/BackendModal.jsx +0 -199
- package/workers/render.bak.20260528-2338/src/components/Button.css +0 -72
- package/workers/render.bak.20260528-2338/src/components/Button.jsx +0 -37
- package/workers/render.bak.20260528-2338/src/components/Card.css +0 -42
- package/workers/render.bak.20260528-2338/src/components/Card.jsx +0 -21
- package/workers/render.bak.20260528-2338/src/components/Icon.jsx +0 -30
- package/workers/render.bak.20260528-2338/src/components/Menu.css +0 -55
- package/workers/render.bak.20260528-2338/src/components/Menu.jsx +0 -91
- package/workers/render.bak.20260528-2338/src/components/SidecarBanner.css +0 -79
- package/workers/render.bak.20260528-2338/src/components/SidecarBanner.jsx +0 -84
- package/workers/render.bak.20260528-2338/src/components/StatusChip.css +0 -19
- package/workers/render.bak.20260528-2338/src/components/StatusChip.jsx +0 -31
- package/workers/render.bak.20260528-2338/src/components/Toast.css +0 -31
- package/workers/render.bak.20260528-2338/src/components/Toast.jsx +0 -23
- package/workers/render.bak.20260528-2338/src/components/WslSetupBanner.css +0 -464
- package/workers/render.bak.20260528-2338/src/components/WslSetupBanner.jsx +0 -716
- package/workers/render.bak.20260528-2338/src/dockerInstaller.js +0 -0
- package/workers/render.bak.20260528-2338/src/i18n/en.json +0 -116
- package/workers/render.bak.20260528-2338/src/i18n/fr.json +0 -116
- package/workers/render.bak.20260528-2338/src/i18n/index.js +0 -69
- package/workers/render.bak.20260528-2338/src/i18n/ja.json +0 -116
- package/workers/render.bak.20260528-2338/src/i18n/zh-CN.json +0 -121
- package/workers/render.bak.20260528-2338/src/main.js +0 -475
- package/workers/render.bak.20260528-2338/src/main.jsx +0 -18
- package/workers/render.bak.20260528-2338/src/style.css +0 -275
- package/workers/render.bak.20260528-2338/src/styles/base.css +0 -98
- package/workers/render.bak.20260528-2338/src/styles/tokens.css +0 -90
- package/workers/render.bak.20260528-2338/src/tos.js +0 -72
- package/workers/render.bak.20260528-2338/src/worker.js +0 -40
- package/workers/render.bak.20260528-2338/src/wslInstaller.js +0 -1563
- package/workers/render.bak.20260528-2338/vite.config.js +0 -36
- package/workers/render.bak.20260528-2338/wrangler.toml +0 -17
|
@@ -1,1563 +0,0 @@
|
|
|
1
|
-
// Renderer-side Windows WSL installer.
|
|
2
|
-
//
|
|
3
|
-
// Drives the entire wsl install flow from the React renderer using
|
|
4
|
-
// `window.electronRPC("exec_shell", { command })`. This means future
|
|
5
|
-
// fixes (apt mirror probing, distro selection, cicy-code download,
|
|
6
|
-
// version verify) ship via a CF Worker deploy with NO .exe rebuild.
|
|
7
|
-
//
|
|
8
|
-
// await windowsInstall({ onProgress, signal }) → { ok, version }
|
|
9
|
-
//
|
|
10
|
-
// onProgress event shape (matches main-process sidecar:progress):
|
|
11
|
-
// { phase, message, progress?, version?, network? }
|
|
12
|
-
//
|
|
13
|
-
// Phases (in order):
|
|
14
|
-
// detecting — network probe
|
|
15
|
-
// checking — fetch latest manifest
|
|
16
|
-
// downloading — staging the cicy-code binary on Windows
|
|
17
|
-
// checking-wsl — wsl --status / -l -v
|
|
18
|
-
// installing-wsl — wsl --install -d Ubuntu (long, may need reboot)
|
|
19
|
-
// waiting-distro — first-boot of Ubuntu after fresh install
|
|
20
|
-
// configuring-apt — fix /etc/apt/sources.list to a reachable mirror
|
|
21
|
-
// installing-cicy-code— cp + chmod + --version
|
|
22
|
-
// installing-deps — apt-install unzip/jq/CJK fonts so cicy-code's
|
|
23
|
-
// first-launch baseTools check passes instantly
|
|
24
|
-
// picking-agents — pause for user to choose claude / codex / opencode
|
|
25
|
-
// (no pre-install — cicy-code installs lazily on first
|
|
26
|
-
// agent create with the picked set passed via --agents)
|
|
27
|
-
// starting — boot cicy-code --agents=<csv> + wait for /api/health 200
|
|
28
|
-
// mounting-files — Desktop shortcut + Quick Access pin to WSL home
|
|
29
|
-
// done — final ok
|
|
30
|
-
|
|
31
|
-
// ── tunables ──────────────────────────────────────────────────────────
|
|
32
|
-
const DOCKER_DISTROS = new Set([
|
|
33
|
-
"docker-desktop",
|
|
34
|
-
"docker-desktop-data",
|
|
35
|
-
"docker-desktop-bootstrap",
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
const PREFERRED_DISTROS = [
|
|
39
|
-
"Ubuntu", "Ubuntu-24.04", "Ubuntu-22.04", "Ubuntu-20.04", "Debian",
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
// gh proxy mirrors. Pinned to ones that work from APAC (Myanmar, etc).
|
|
43
|
-
const GH_MIRRORS = [
|
|
44
|
-
"https://ghproxy.net/",
|
|
45
|
-
"https://gh-proxy.com/",
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
// Apt mirror candidates by network.
|
|
49
|
-
const APT_MIRRORS = {
|
|
50
|
-
cn: ["https://mirrors.aliyun.com/ubuntu", "https://mirrors.tuna.tsinghua.edu.cn/ubuntu", "http://archive.ubuntu.com/ubuntu"],
|
|
51
|
-
global: ["http://archive.ubuntu.com/ubuntu", "https://mirrors.aliyun.com/ubuntu"],
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const DEFAULT_TIMEOUTS = {
|
|
55
|
-
shellShort: 10_000,
|
|
56
|
-
shellMed: 30_000,
|
|
57
|
-
shellLong: 120_000,
|
|
58
|
-
download: 120_000,
|
|
59
|
-
wslInstall: 15 * 60_000,
|
|
60
|
-
wslBoot: 180_000,
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
// ── shell helpers ─────────────────────────────────────────────────────
|
|
64
|
-
// Module-level emit so every sh/ps/wslBash call can push a `{kind:"log"}`
|
|
65
|
-
// event without threading the callback through every helper signature.
|
|
66
|
-
// Set by windowsInstall() before any shell call runs.
|
|
67
|
-
let _emit = () => {};
|
|
68
|
-
function setEmit(fn) { _emit = fn || (() => {}); }
|
|
69
|
-
|
|
70
|
-
// Trim a multi-line shell payload (cmd, stdout, stderr) into a few short
|
|
71
|
-
// log lines. Keeps output readable inside a small in-app log panel.
|
|
72
|
-
function summarize(text, { maxLines = 8, maxChars = 600 } = {}) {
|
|
73
|
-
if (!text) return "";
|
|
74
|
-
let s = String(text).replace(/\u0000/g, "").replace(/\r/g, "").trim();
|
|
75
|
-
if (!s) return "";
|
|
76
|
-
if (s.length > maxChars) s = s.slice(0, maxChars) + "…";
|
|
77
|
-
const lines = s.split("\n");
|
|
78
|
-
if (lines.length <= maxLines) return lines.join("\n");
|
|
79
|
-
const head = lines.slice(0, maxLines - 1).join("\n");
|
|
80
|
-
return `${head}\n… (+${lines.length - maxLines + 1} more lines)`;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// One-line preview of a long command (PowerShell scripts can be 80+ lines).
|
|
84
|
-
function previewCmd(cmd) {
|
|
85
|
-
if (!cmd) return "";
|
|
86
|
-
const oneLine = String(cmd).replace(/\s+/g, " ").trim();
|
|
87
|
-
return oneLine.length > 200 ? oneLine.slice(0, 200) + "…" : oneLine;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function assertRPC() {
|
|
91
|
-
if (typeof window === "undefined" || typeof window.electronRPC !== "function") {
|
|
92
|
-
throw new Error("electronRPC unavailable — open this page inside cicy-desktop's homepage window (v2.1.12+)");
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function sh(cmd, { timeoutMs = DEFAULT_TIMEOUTS.shellMed, silent = false } = {}) {
|
|
97
|
-
assertRPC();
|
|
98
|
-
if (!silent) _emit({ kind: "log", text: `$ ${previewCmd(cmd)}` });
|
|
99
|
-
// cicy-desktop's main exec_shell handler doesn't honour the timeout_ms
|
|
100
|
-
// hint — child_process.exec runs without a kill timer, so a hung
|
|
101
|
-
// command (e.g. `wsl --install` waiting on Microsoft Store) just blocks
|
|
102
|
-
// forever. Race the RPC against a renderer-side timer so callers can
|
|
103
|
-
// actually fall back. The OS-side child stays around (we have no clean
|
|
104
|
-
// way to signal it from the renderer), but the JS promise resolves so
|
|
105
|
-
// the installer can proceed to the next mirror / strategy.
|
|
106
|
-
const rpcCall = window.electronRPC("exec_shell", { command: cmd, timeout_ms: timeoutMs });
|
|
107
|
-
const r = await Promise.race([
|
|
108
|
-
rpcCall,
|
|
109
|
-
new Promise((resolve) => setTimeout(
|
|
110
|
-
() => resolve({ content: [{ type: "text", text: JSON.stringify({ stdout: "", stderr: `client-side timeout after ${timeoutMs}ms`, exitCode: 124 }) }], isError: true }),
|
|
111
|
-
timeoutMs + 5_000, // grace period over the requested timeout
|
|
112
|
-
)),
|
|
113
|
-
]);
|
|
114
|
-
// electronRPC returns the raw MCP shape: { content: [{type:"text",text:"<json>"}] }
|
|
115
|
-
// homepage-preload's `tx()` flattens for window.cicy.system.*; raw RPC needs us to parse.
|
|
116
|
-
let parsed = r;
|
|
117
|
-
if (r && r.content) {
|
|
118
|
-
const txt = (r.content || []).map(c => c.text).filter(Boolean).join("");
|
|
119
|
-
try { parsed = JSON.parse(txt); }
|
|
120
|
-
catch { parsed = { ok: true, stdout: txt, stderr: "", exitCode: 0 }; }
|
|
121
|
-
}
|
|
122
|
-
const out = {
|
|
123
|
-
ok: (parsed.exitCode || 0) === 0,
|
|
124
|
-
stdout: clean(parsed.stdout),
|
|
125
|
-
stderr: clean(parsed.stderr),
|
|
126
|
-
code: parsed.exitCode || 0,
|
|
127
|
-
};
|
|
128
|
-
const stdoutSummary = summarize(out.stdout);
|
|
129
|
-
const stderrSummary = summarize(out.stderr);
|
|
130
|
-
if (stdoutSummary) _emit({ kind: "log", text: stdoutSummary });
|
|
131
|
-
if (stderrSummary) _emit({ kind: "log", text: `! ${stderrSummary}` });
|
|
132
|
-
if (!out.ok && !stderrSummary && !stdoutSummary) {
|
|
133
|
-
_emit({ kind: "log", text: `! exit ${out.code}` });
|
|
134
|
-
}
|
|
135
|
-
return out;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function clean(s) {
|
|
139
|
-
// PowerShell + WSL bridge output occasionally has stray NULs and CRs.
|
|
140
|
-
return String(s || "").replace(/\u0000/g, "").replace(/\r/g, "");
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function ps(scriptText, opts = {}) {
|
|
144
|
-
// Avoid quoting hell by passing the script as base64 through -EncodedCommand.
|
|
145
|
-
// PowerShell expects UTF-16LE base64.
|
|
146
|
-
// silent: skip the log preview entirely — used by tight polling loops
|
|
147
|
-
// (e.g. download progress) where every call would otherwise spam the
|
|
148
|
-
// shell log with a 5-line script preview per tick.
|
|
149
|
-
if (!opts.silent) {
|
|
150
|
-
_emit({ kind: "log", text: `$ powershell:\n${summarize(scriptText, { maxLines: 5, maxChars: 400 })}` });
|
|
151
|
-
}
|
|
152
|
-
// Force PowerShell to emit UTF-8 to stdout/stderr so cicy-desktop's
|
|
153
|
-
// exec_shell (which decodes as UTF-8) doesn't garble Chinese / box-drawing
|
|
154
|
-
// characters from `wsl --list`, `chcp` etc. The chcp call mirrors the same
|
|
155
|
-
// setting at the cmd level for any nested invocations.
|
|
156
|
-
const wrapped =
|
|
157
|
-
`[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;\n` +
|
|
158
|
-
`$OutputEncoding = [System.Text.Encoding]::UTF8;\n` +
|
|
159
|
-
`chcp 65001 > $null;\n` +
|
|
160
|
-
scriptText;
|
|
161
|
-
const utf16 = new Uint16Array(wrapped.length);
|
|
162
|
-
for (let i = 0; i < wrapped.length; i++) utf16[i] = wrapped.charCodeAt(i);
|
|
163
|
-
const b64 = btoa(String.fromCharCode(...new Uint8Array(utf16.buffer)));
|
|
164
|
-
return sh(`powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${b64}`, { ...opts, silent: true });
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// ── fast cmd.exe / curl.exe helpers ─────────────────────────────────────
|
|
168
|
-
// On some Windows machines AV real-time scanning throttles powershell.exe
|
|
169
|
-
// startup to 30–60 s, which blows past every install-step timeout. cmd.exe
|
|
170
|
-
// and curl.exe (native since Win10 1803) start instantly, so all network
|
|
171
|
-
// fetches and simple file ops go through them instead of PowerShell.
|
|
172
|
-
|
|
173
|
-
// Run a cmd.exe builtin. exec_shell already runs inside a shell, so the
|
|
174
|
-
// nested `cmd /d /c "…"` is unwrapped by cmd's /s parsing (verified).
|
|
175
|
-
async function cmdExec(cmdLine, opts = {}) {
|
|
176
|
-
if (!opts.silent) _emit({ kind: "log", text: `$ cmd: ${previewCmd(cmdLine)}` });
|
|
177
|
-
return sh(`cmd /d /c "${cmdLine}"`, { ...opts, silent: true });
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// File size in bytes, or -1 if the file is missing.
|
|
181
|
-
async function fileSize(path) {
|
|
182
|
-
const p = path.replace(/"/g, "");
|
|
183
|
-
const r = await cmdExec(
|
|
184
|
-
`if exist "${p}" (for %I in ("${p}") do @echo SZ=%~zI) else (echo NONE)`,
|
|
185
|
-
{ timeoutMs: 12_000, silent: true },
|
|
186
|
-
);
|
|
187
|
-
const m = (r.stdout || "").match(/SZ=(\d+)/);
|
|
188
|
-
return m ? parseInt(m[1], 10) : -1;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
async function makeDir(path) {
|
|
192
|
-
const p = path.replace(/"/g, "");
|
|
193
|
-
return cmdExec(`if not exist "${p}" mkdir "${p}"`, { timeoutMs: 12_000, silent: true });
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function delFile(path) {
|
|
197
|
-
const p = path.replace(/"/g, "");
|
|
198
|
-
return cmdExec(`del /f /q "${p}" 2>nul & exit /b 0`, { timeoutMs: 12_000, silent: true });
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Fetch text from the first reachable URL via curl.exe. { ok, body, url }.
|
|
202
|
-
async function curlText(urls, { connectTimeout = 10, maxTime = 25, timeoutMs = 35_000 } = {}) {
|
|
203
|
-
for (const u of (Array.isArray(urls) ? urls : [urls])) {
|
|
204
|
-
_emit({ kind: "log", text: `$ curl ${previewCmd(u)}` });
|
|
205
|
-
const r = await sh(
|
|
206
|
-
`curl.exe -fsSL --connect-timeout ${connectTimeout} --max-time ${maxTime} "${u}"`,
|
|
207
|
-
{ timeoutMs, silent: true },
|
|
208
|
-
);
|
|
209
|
-
if (r.ok && r.stdout && r.stdout.trim()) return { ok: true, body: r.stdout.trim(), url: u };
|
|
210
|
-
_emit({ kind: "log", text: `! curl miss (${u.split("/")[2] || u})` });
|
|
211
|
-
}
|
|
212
|
-
return { ok: false };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Order URLs fastest-first via sequential curl HEAD probes (each starts
|
|
216
|
-
// instantly, so even 4 probes finish in a few seconds). Unreachable URLs
|
|
217
|
-
// drop to the back rather than being removed — they stay as last-resort.
|
|
218
|
-
async function curlOrder(urls, { connectTimeout = 8, maxTime = 14, timeoutMs = 22_000 } = {}) {
|
|
219
|
-
const timed = [];
|
|
220
|
-
for (const u of urls) {
|
|
221
|
-
const r = await sh(
|
|
222
|
-
`curl.exe -sI -o NUL --connect-timeout ${connectTimeout} --max-time ${maxTime} -w "%{http_code} %{time_total}" "${u}"`,
|
|
223
|
-
{ timeoutMs, silent: true },
|
|
224
|
-
);
|
|
225
|
-
const m = (r.stdout || "").trim().match(/(\d{3})\s+([\d.]+)/);
|
|
226
|
-
const code = m ? parseInt(m[1], 10) : 0;
|
|
227
|
-
if (r.ok && code >= 200 && code < 400) timed.push({ u, t: parseFloat(m[2]) || 999 });
|
|
228
|
-
}
|
|
229
|
-
timed.sort((a, b) => a.t - b.t);
|
|
230
|
-
const ok = timed.map((x) => x.u);
|
|
231
|
-
return ok.length ? [...ok, ...urls.filter((u) => !ok.includes(u))] : urls;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async function wslBash(distro, script, opts = {}) {
|
|
235
|
-
// Ship the bash script through base64 to avoid Windows quoting hell.
|
|
236
|
-
// Use TextEncoder for proper UTF-8.
|
|
237
|
-
_emit({ kind: "log", text: `$ wsl ${distro}:\n${summarize(script, { maxLines: 5, maxChars: 400 })}` });
|
|
238
|
-
const bytes = new TextEncoder().encode(script);
|
|
239
|
-
let bin = "";
|
|
240
|
-
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
241
|
-
const b64 = btoa(bin);
|
|
242
|
-
return sh(`wsl -d ${distro} -- bash -c "echo ${b64} | base64 -d | bash -l"`, { ...opts, silent: true });
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
async function wslExec(distro, args, opts = {}) {
|
|
246
|
-
const escaped = args.map(a => `"${String(a).replace(/"/g, '\\"')}"`).join(" ");
|
|
247
|
-
return sh(`wsl -d ${distro} -e ${escaped}`, opts);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// ── network detection ─────────────────────────────────────────────────
|
|
251
|
-
// Cached for the page session so a retry resumes instantly instead of
|
|
252
|
-
// re-spending ~30s on probes. Only a confident cn/global result is cached;
|
|
253
|
-
// "unknown" (likely a transient probe failure) is always re-tried.
|
|
254
|
-
let _cachedNetwork = null;
|
|
255
|
-
// Agent selection cached for the page session (resume path — see picking-agents).
|
|
256
|
-
let _cachedAgents = null;
|
|
257
|
-
async function detectNetwork() {
|
|
258
|
-
if (_cachedNetwork) return _cachedNetwork;
|
|
259
|
-
// curl.exe HEAD probes — start instantly (powershell.exe can take 30–60 s
|
|
260
|
-
// to spawn under AV scanning). baidu first → cn fast path; google fallback
|
|
261
|
-
// distinguishes global vs offline.
|
|
262
|
-
// Generous --max-time: on AV-heavy machines curl's own TLS+spawn can take
|
|
263
|
-
// 8–12 s even for a fast endpoint. Too tight a limit aborts mid-handshake
|
|
264
|
-
// and reports http_code 000 (looks like "no network").
|
|
265
|
-
const cn = await sh(
|
|
266
|
-
`curl.exe -sI -o NUL --connect-timeout 8 --max-time 14 -w "%{http_code}" "https://www.baidu.com/"`,
|
|
267
|
-
{ timeoutMs: 22_000, silent: true },
|
|
268
|
-
);
|
|
269
|
-
if (cn.ok && /^2\d\d/.test((cn.stdout || "").trim())) return (_cachedNetwork = "cn");
|
|
270
|
-
const gl = await sh(
|
|
271
|
-
`curl.exe -sI -o NUL --connect-timeout 8 --max-time 14 -w "%{http_code}" "https://www.google.com/generate_204"`,
|
|
272
|
-
{ timeoutMs: 22_000, silent: true },
|
|
273
|
-
);
|
|
274
|
-
if (gl.ok && /^20[04]/.test((gl.stdout || "").trim())) return (_cachedNetwork = "global");
|
|
275
|
-
return "unknown";
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ── manifest fetch ────────────────────────────────────────────────────
|
|
279
|
-
function manifestUrls(network) {
|
|
280
|
-
const base = "https://github.com/cicy-ai/cicy-code/releases/latest/download/manifest.json";
|
|
281
|
-
return (network === "cn" || network === "unknown")
|
|
282
|
-
? [...GH_MIRRORS.map(m => m + base), base]
|
|
283
|
-
: [base, ...GH_MIRRORS.map(m => m + base)];
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
async function fetchLatestManifest(network) {
|
|
287
|
-
const urls = manifestUrls(network);
|
|
288
|
-
const r = await curlText(urls, { connectTimeout: 10, maxTime: 20, timeoutMs: 30_000 });
|
|
289
|
-
if (!r.ok) return { ok: false, error: "no reachable manifest" };
|
|
290
|
-
try {
|
|
291
|
-
const m = JSON.parse(r.body);
|
|
292
|
-
if (!m.version || !m.assets) return { ok: false, error: "manifest malformed" };
|
|
293
|
-
return { ok: true, version: m.version, assets: m.assets, sizes: m.sizes || {} };
|
|
294
|
-
} catch (e) {
|
|
295
|
-
return { ok: false, error: "json parse: " + e.message };
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// ── download with mirror race ────────────────────────────────────────
|
|
300
|
-
async function downloadStaged({ assetUrl, network, dstPath, expectSize = 0, expectMin = 1_000_000 }) {
|
|
301
|
-
// 0. Reuse exact cached file if present and size matches expected.
|
|
302
|
-
if (expectSize && expectSize > 0) {
|
|
303
|
-
const localSize = await fileSize(dstPath);
|
|
304
|
-
if (localSize === expectSize) {
|
|
305
|
-
_emit({ kind: "log", text: `(reusing cached download: ${(localSize / 1024 / 1024).toFixed(1)} MB)` });
|
|
306
|
-
return { ok: true, size: localSize, url: dstPath, path: dstPath, reused: true };
|
|
307
|
-
}
|
|
308
|
-
if (localSize > 0) {
|
|
309
|
-
_emit({ kind: "log", text: `(cached file size ${localSize} != expected ${expectSize}, redownloading)` });
|
|
310
|
-
await delFile(dstPath);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// 1. Build URL list with mirrors first if cn-ish.
|
|
315
|
-
// network=="unknown" likely means we're behind a firewall / GFW that
|
|
316
|
-
// blocks both google and baidu probes — better to lead with mirrors
|
|
317
|
-
// than with the (probably-slow) GitHub direct URL.
|
|
318
|
-
const urls = (network === "cn" || network === "unknown")
|
|
319
|
-
? [...GH_MIRRORS.map(m => m + assetUrl), assetUrl]
|
|
320
|
-
: [assetUrl, ...GH_MIRRORS.map(m => m + assetUrl)];
|
|
321
|
-
|
|
322
|
-
// 2. Order mirrors fastest-first (curl HEAD probes).
|
|
323
|
-
const ordered = await curlOrder(urls);
|
|
324
|
-
|
|
325
|
-
// 3. Sequential download from fastest, fall through on failure.
|
|
326
|
-
await makeDir(dstPath.replace(/[\\\/][^\\\/]*$/, ""));
|
|
327
|
-
for (const url of ordered) {
|
|
328
|
-
// Download into a per-attempt .part file so a stale earlier download
|
|
329
|
-
// process that still has a write lock on `dst` can't block us.
|
|
330
|
-
const partPath = dstPath + `.part-${Date.now()}`;
|
|
331
|
-
_emit({ kind: "log", text: `$ curl ↓ ${previewCmd(url)}` });
|
|
332
|
-
const dl = await sh(
|
|
333
|
-
`curl.exe -fL --connect-timeout 15 --max-time 600 -o "${partPath}" "${url}"`,
|
|
334
|
-
{ timeoutMs: DEFAULT_TIMEOUTS.download, silent: true },
|
|
335
|
-
);
|
|
336
|
-
if (!dl.ok) { await delFile(partPath); continue; }
|
|
337
|
-
const size = await fileSize(partPath);
|
|
338
|
-
if (size < expectMin) { await delFile(partPath); continue; }
|
|
339
|
-
// If manifest gave us an exact size, reject mismatched bytes — a
|
|
340
|
-
// truncated or HTML error page from a flaky mirror wouldn't trip
|
|
341
|
-
// expectMin but would fail size equality.
|
|
342
|
-
if (expectSize && expectSize > 0 && size !== expectSize) {
|
|
343
|
-
_emit({ kind: "log", text: `(downloaded ${size} != expected ${expectSize}, trying next mirror)` });
|
|
344
|
-
await delFile(partPath);
|
|
345
|
-
continue;
|
|
346
|
-
}
|
|
347
|
-
// Atomically place the .part at dst; if dst is locked, keep the .part
|
|
348
|
-
// path — the rest of the installer only cares about the bytes.
|
|
349
|
-
const mv = await cmdExec(
|
|
350
|
-
`move /y "${partPath}" "${dstPath}"`,
|
|
351
|
-
{ timeoutMs: 12_000, silent: true },
|
|
352
|
-
);
|
|
353
|
-
const finalPath = mv.ok ? dstPath : partPath;
|
|
354
|
-
return { ok: true, size, url, path: finalPath };
|
|
355
|
-
}
|
|
356
|
-
return { ok: false, error: "all mirrors failed" };
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// ── WSL detection ─────────────────────────────────────────────────────
|
|
360
|
-
async function checkWslStatus() {
|
|
361
|
-
const status = await sh("wsl --status", { timeoutMs: 20_000 });
|
|
362
|
-
if (!status.ok) return { installed: false, supported: true };
|
|
363
|
-
|
|
364
|
-
// Detect default version
|
|
365
|
-
const m = status.stdout.match(/Default Version:\s*(\d)/i);
|
|
366
|
-
const defaultVer = m ? parseInt(m[1], 10) : 2;
|
|
367
|
-
|
|
368
|
-
const list = await sh("wsl -l -v", { timeoutMs: 20_000 });
|
|
369
|
-
if (!list.ok || !list.stdout.trim()) {
|
|
370
|
-
return { installed: true, supported: true, hasDistro: false, defaultVer };
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const distros = [];
|
|
374
|
-
let defaultDistro = null;
|
|
375
|
-
for (const raw of list.stdout.split(/\r?\n/)) {
|
|
376
|
-
const isDefault = raw.trimStart().startsWith("*");
|
|
377
|
-
const stripped = raw.replace(/^\s*\*?\s*/, "").trim();
|
|
378
|
-
if (!stripped || /^NAME\b/i.test(stripped)) continue;
|
|
379
|
-
const parts = stripped.split(/\s+/);
|
|
380
|
-
if (parts.length < 3) continue;
|
|
381
|
-
const [name, state, version] = parts;
|
|
382
|
-
distros.push({ name, state, version: parseInt(version, 10) || 1 });
|
|
383
|
-
if (isDefault) defaultDistro = name;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const usable = pickUsableDistro(distros);
|
|
387
|
-
return {
|
|
388
|
-
installed: true,
|
|
389
|
-
supported: true,
|
|
390
|
-
hasDistro: usable !== null,
|
|
391
|
-
distros,
|
|
392
|
-
defaultDistro,
|
|
393
|
-
usableDistro: usable,
|
|
394
|
-
defaultVer,
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function pickUsableDistro(distros) {
|
|
399
|
-
for (const want of PREFERRED_DISTROS) {
|
|
400
|
-
const found = distros.find(d => d.name.toLowerCase() === want.toLowerCase());
|
|
401
|
-
if (found) return found.name;
|
|
402
|
-
}
|
|
403
|
-
for (const d of distros) {
|
|
404
|
-
if (!DOCKER_DISTROS.has(d.name.toLowerCase())) return d.name;
|
|
405
|
-
}
|
|
406
|
-
return null;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// ── pick install drive ─────────────────────────────────────────────────
|
|
410
|
-
// Default WSL install path (Microsoft Store + `wsl --install -d Ubuntu`)
|
|
411
|
-
// always lands the ext4.vhdx under %LOCALAPPDATA%, which is on C:. On
|
|
412
|
-
// machines where C: is a small SSD (typical OEM laptops with separate D:
|
|
413
|
-
// data drives), this fills the system drive within months as Docker
|
|
414
|
-
// images, agent CLIs, and code accumulate inside WSL.
|
|
415
|
-
//
|
|
416
|
-
// Smart-pick algorithm — we score each fixed-NTFS drive and pick the
|
|
417
|
-
// winner, rather than blindly taking max-free-space. The score combines:
|
|
418
|
-
//
|
|
419
|
-
// 1. Non-system bonus (+50 GB equivalent). Prefer D:/E:/etc. over C:
|
|
420
|
-
// when they have meaningful headroom — keeps the system drive
|
|
421
|
-
// lean and avoids the "Windows can't update because C: is full"
|
|
422
|
-
// class of bugs.
|
|
423
|
-
// 2. Absolute free GB. Bigger is better; this is the dominant signal
|
|
424
|
-
// once the bonus is applied.
|
|
425
|
-
// 3. SSD bonus (+30 GB equivalent). Win32_PhysicalMedia distinguishes
|
|
426
|
-
// SSD (MediaType=4) from HDD (MediaType=3). WSL on SSD is several
|
|
427
|
-
// multiples faster for compile / docker-build workloads.
|
|
428
|
-
//
|
|
429
|
-
// A drive must have ≥10 GB free to be considered. Selection must be
|
|
430
|
-
// bulletproof — it gates the whole install and the user expects zero manual
|
|
431
|
-
// drive picking. Two rules learned the hard way:
|
|
432
|
-
// 1. The Storage module (Get-PhysicalDisk / Get-Partition) is NEVER on the
|
|
433
|
-
// critical path. It can be slow or hang (RAID, VMs, a wedged Storage
|
|
434
|
-
// service); a hang there used to time out the whole picker → bogus
|
|
435
|
-
// "C: 0 GB" fallback → install aborted with a 31 GB D: sitting right
|
|
436
|
-
// there. SSD detection is now a separate, optional scoring bonus.
|
|
437
|
-
// 2. Drives are enumerated via two independent mechanisms (CIM, then
|
|
438
|
-
// wmic) so one flaky/slow shell can't sink selection. wmic stays
|
|
439
|
-
// responsive even on a PowerShell wedged after a botched WSL import.
|
|
440
|
-
// Install path is always `<drive>:\CiCy\WSL\Ubuntu\` — a visible top-level
|
|
441
|
-
// location, not buried in AppData.
|
|
442
|
-
async function pickInstallDrive() {
|
|
443
|
-
const MIN_GB = 10;
|
|
444
|
-
const drives = await enumerateFixedNtfsDrives();
|
|
445
|
-
|
|
446
|
-
if (drives === null) {
|
|
447
|
-
// Both enumeration mechanisms failed (transiently wedged shell). Don't
|
|
448
|
-
// hard-fail a likely-installable machine on a flaky probe — proceed
|
|
449
|
-
// optimistically on C:; the rootfs import surfaces a real ENOSPC if
|
|
450
|
-
// space is genuinely lacking.
|
|
451
|
-
return { letter: "C", freeGb: 0, totalGb: 0, isSSD: false, all: [],
|
|
452
|
-
isSystemDrive: true, probeFailed: true, installDir: `C:\\CiCy\\WSL\\Ubuntu` };
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
const ssd = await detectSsdLetters(); // best-effort tiebreaker, never blocks
|
|
456
|
-
const scored = drives
|
|
457
|
-
.map((d) => {
|
|
458
|
-
const isNonSystem = d.Letter !== "C";
|
|
459
|
-
const isSSD = ssd.has(d.Letter);
|
|
460
|
-
// Non-system bonus (50) > SSD bonus (30): prefer a roomy data drive
|
|
461
|
-
// over the system SSD when both are comparable.
|
|
462
|
-
return { ...d, IsSSD: isSSD, IsNonSystem: isNonSystem,
|
|
463
|
-
Score: Math.round((d.FreeGB + (isNonSystem ? 50 : 0) + (isSSD ? 30 : 0)) * 10) / 10 };
|
|
464
|
-
})
|
|
465
|
-
.filter((d) => d.FreeGB >= MIN_GB)
|
|
466
|
-
.sort((a, b) => b.Score - a.Score || b.FreeGB - a.FreeGB);
|
|
467
|
-
|
|
468
|
-
if (scored.length === 0) {
|
|
469
|
-
// Enumeration worked but no fixed NTFS drive has ≥ MIN_GB free. Genuine
|
|
470
|
-
// low disk — report C:'s actual free space in the error.
|
|
471
|
-
const cFree = (drives.find((d) => d.Letter === "C") || {}).FreeGB || 0;
|
|
472
|
-
return { letter: "C", freeGb: cFree, totalGb: 0, isSSD: false, all: [],
|
|
473
|
-
isSystemDrive: true, lowDisk: true, installDir: `C:\\CiCy\\WSL\\Ubuntu` };
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
const best = scored[0];
|
|
477
|
-
return {
|
|
478
|
-
letter: best.Letter,
|
|
479
|
-
freeGb: best.FreeGB,
|
|
480
|
-
totalGb: best.TotalGB,
|
|
481
|
-
isSSD: best.IsSSD,
|
|
482
|
-
score: best.Score,
|
|
483
|
-
all: scored, // objects keep .Letter/.FreeGB for downstream checks
|
|
484
|
-
isSystemDrive: best.Letter === "C",
|
|
485
|
-
installDir: `${best.Letter}:\\CiCy\\WSL\\Ubuntu`,
|
|
486
|
-
};
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Enumerate fixed (DriveType=3) NTFS drives as [{Letter, FreeGB, TotalGB}].
|
|
490
|
-
// Returns null only when BOTH mechanisms fail to produce any answer.
|
|
491
|
-
async function enumerateFixedNtfsDrives() {
|
|
492
|
-
const toObj = (letter, freeBytes, sizeBytes) => ({
|
|
493
|
-
Letter: String(letter).replace(":", "").trim(),
|
|
494
|
-
FreeGB: Math.round((Number(freeBytes) / 1073741824) * 10) / 10,
|
|
495
|
-
TotalGB: Math.round((Number(sizeBytes) / 1073741824) * 10) / 10,
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
// Mechanism 1 — wmic via cmd.exe. Starts instantly (powershell.exe can
|
|
499
|
-
// take 30–60 s to spawn under AV scanning). /format:list gives robust
|
|
500
|
-
// key=value blocks. Deprecated on 24H2+ but present on most machines.
|
|
501
|
-
try {
|
|
502
|
-
const r = await sh(
|
|
503
|
-
`wmic logicaldisk where "DriveType=3" get DeviceID,FileSystem,FreeSpace,Size /format:list`,
|
|
504
|
-
{ timeoutMs: 15_000, silent: true }
|
|
505
|
-
);
|
|
506
|
-
if (r.ok && r.stdout) {
|
|
507
|
-
const out = [];
|
|
508
|
-
for (const block of r.stdout.split(/\n\s*\n/)) {
|
|
509
|
-
const kv = {};
|
|
510
|
-
for (const line of block.split("\n")) {
|
|
511
|
-
const i = line.indexOf("=");
|
|
512
|
-
if (i > 0) kv[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
513
|
-
}
|
|
514
|
-
if (kv.DeviceID && kv.FileSystem === "NTFS" && kv.FreeSpace) {
|
|
515
|
-
out.push(toObj(kv.DeviceID, kv.FreeSpace, kv.Size));
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
if (out.length) return out;
|
|
519
|
-
}
|
|
520
|
-
} catch {}
|
|
521
|
-
|
|
522
|
-
// Mechanism 2 — CIM via PowerShell. Fallback for 24H2+ where wmic is gone.
|
|
523
|
-
// Pipe-delimited rows so we never depend on ConvertTo-Json quirks.
|
|
524
|
-
try {
|
|
525
|
-
const r = await ps(`
|
|
526
|
-
$rows = @()
|
|
527
|
-
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction SilentlyContinue |
|
|
528
|
-
Where-Object { $_.FileSystem -eq "NTFS" } |
|
|
529
|
-
ForEach-Object { $rows += ($_.DeviceID.TrimEnd(":") + "|" + [int64]$_.FreeSpace + "|" + [int64]$_.Size) }
|
|
530
|
-
Write-Output ("ROWS:" + ($rows -join ";"))
|
|
531
|
-
`, { timeoutMs: 45_000 });
|
|
532
|
-
if (r.ok && r.stdout && r.stdout.indexOf("ROWS:") >= 0) {
|
|
533
|
-
const body = r.stdout.slice(r.stdout.indexOf("ROWS:") + 5).trim();
|
|
534
|
-
const out = [];
|
|
535
|
-
for (const part of body.split(";")) {
|
|
536
|
-
const [letter, free, size] = part.split("|");
|
|
537
|
-
if (letter && free) out.push(toObj(letter, free, size));
|
|
538
|
-
}
|
|
539
|
-
if (out.length) return out;
|
|
540
|
-
}
|
|
541
|
-
} catch {}
|
|
542
|
-
|
|
543
|
-
return null;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Best-effort SSD detection — a scoring tiebreaker only. Uses wmic (cmd,
|
|
547
|
-
// instant) since the only reliable SSD signal (Get-PhysicalDisk MediaType)
|
|
548
|
-
// needs PowerShell, which can be minutes-slow under AV scanning. wmic can't
|
|
549
|
-
// distinguish SSD/HDD reliably, so we simply skip the bonus on machines
|
|
550
|
-
// where it'd cost a 40 s+ PowerShell spawn. Returns a Set (may be empty);
|
|
551
|
-
// drive selection still works on free-space + non-system-drive scoring.
|
|
552
|
-
async function detectSsdLetters() {
|
|
553
|
-
return new Set();
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// ── WSL install ───────────────────────────────────────────────────────
|
|
557
|
-
async function installWsl(network, installDrive, emit) {
|
|
558
|
-
// Helper: a "failed" install often actually succeeded — `wsl --install`
|
|
559
|
-
// can register the distro then hang on Microsoft Store metadata, or
|
|
560
|
-
// report ERROR_ALREADY_EXISTS because a previous attempt left it
|
|
561
|
-
// installed. Always re-check the distro list before declaring failure.
|
|
562
|
-
const verifyDistroPresent = async () => {
|
|
563
|
-
const wsl = await checkWslStatus();
|
|
564
|
-
if (wsl.usableDistro) {
|
|
565
|
-
emit({ phase: "installing-wsl", message: `Ubuntu already installed (${wsl.usableDistro})`, status: "done" });
|
|
566
|
-
return true;
|
|
567
|
-
}
|
|
568
|
-
return false;
|
|
569
|
-
};
|
|
570
|
-
|
|
571
|
-
// Prefer rootfs-import whenever we have a non-system drive available.
|
|
572
|
-
// Rationale: `wsl --install -d Ubuntu` (Microsoft Store) always lands
|
|
573
|
-
// the vhdx under %LOCALAPPDATA% on C: and there's no flag to redirect.
|
|
574
|
-
// Rootfs-import is the only path that lets us pick the install
|
|
575
|
-
// directory, so if the best drive is D:/E:/etc., we always use it.
|
|
576
|
-
// Also covers CN / restricted networks where the MS Store path blocks
|
|
577
|
-
// for 10+ min hitting raw.githubusercontent.com.
|
|
578
|
-
const preferRootfs = !installDrive.isSystemDrive || network === "cn" || network === "unknown";
|
|
579
|
-
if (preferRootfs) {
|
|
580
|
-
if (await verifyDistroPresent()) return { ok: true, method: "already-installed" };
|
|
581
|
-
const where = installDrive.isSystemDrive
|
|
582
|
-
? "from mirrors (faster than Microsoft Store)"
|
|
583
|
-
: `to ${installDrive.letter}: drive (${installDrive.freeGb} GB free)`;
|
|
584
|
-
emit({ phase: "installing-wsl", message: `Importing Ubuntu rootfs ${where}…` });
|
|
585
|
-
const ir = await importUbuntuFromRootfs(network, installDrive, emit);
|
|
586
|
-
if (ir.ok) return { ok: true, method: "rootfs-import", installDir: installDrive.installDir };
|
|
587
|
-
if (await verifyDistroPresent()) return { ok: true, method: "already-installed" };
|
|
588
|
-
// Mirrors all unreachable — only fall back to MS Store if we're
|
|
589
|
-
// landing on C: anyway (Store always picks C:). For non-system
|
|
590
|
-
// drives, fail loudly so the user knows we can't honor their
|
|
591
|
-
// disk choice rather than silently dumping to C:.
|
|
592
|
-
if (!installDrive.isSystemDrive) {
|
|
593
|
-
return { ok: false, error: ir.error || "All rootfs mirrors unreachable; cannot install to chosen drive" };
|
|
594
|
-
}
|
|
595
|
-
emit({ phase: "installing-wsl", message: "Rootfs import failed, falling back to Microsoft Store…" });
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
emit({ phase: "installing-wsl", message: "Installing WSL2 + Ubuntu (5–10 min, requires admin)…" });
|
|
599
|
-
// global network with C: as chosen drive: try Microsoft Store install
|
|
600
|
-
// first (uses GitHub direct), web-download if it fails.
|
|
601
|
-
const r = await sh(`wsl --install --no-launch -d Ubuntu`, { timeoutMs: 8 * 60_000 });
|
|
602
|
-
if (r.ok || (await verifyDistroPresent())) return { ok: true };
|
|
603
|
-
|
|
604
|
-
emit({ phase: "installing-wsl", message: "Microsoft Store install failed, retrying with --web-download…" });
|
|
605
|
-
const r2 = await sh(`wsl --install --web-download --no-launch -d Ubuntu`, { timeoutMs: 8 * 60_000 });
|
|
606
|
-
if (r2.ok || (await verifyDistroPresent())) return { ok: true };
|
|
607
|
-
|
|
608
|
-
// Last resort: rootfs import (only reached for global+system-drive
|
|
609
|
-
// path — non-system already tried it above).
|
|
610
|
-
emit({ phase: "installing-wsl", message: "Falling back to direct rootfs import…" });
|
|
611
|
-
const ir = await importUbuntuFromRootfs(network, installDrive, emit);
|
|
612
|
-
if (ir.ok) return { ok: true, method: "rootfs-import", installDir: installDrive.installDir };
|
|
613
|
-
return { ok: false, error: r2.stderr || ir.error || `wsl --install exit ${r2.code}` };
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// Direct rootfs install: download Ubuntu rootfs from cloud-images
|
|
617
|
-
// mirrors (NJU > USTC > tuna > cloud-images) and `wsl --import` to the
|
|
618
|
-
// caller-chosen install directory. Bypasses Microsoft Store + the
|
|
619
|
-
// DistributionInfo manifest fetch (raw.githubusercontent.com), so this
|
|
620
|
-
// path also works behind firewalls that block raw.githubusercontent.
|
|
621
|
-
// `installDrive.installDir` controls where the ext4.vhdx ends up — we
|
|
622
|
-
// surface this as a top-level `<drive>:\CiCy\WSL\Ubuntu\` path so the
|
|
623
|
-
// user can find their WSL files from File Explorer without spelunking
|
|
624
|
-
// through AppData.
|
|
625
|
-
async function importUbuntuFromRootfs(network, installDrive, emit) {
|
|
626
|
-
const baseMirrors = network === "cn"
|
|
627
|
-
? [
|
|
628
|
-
"https://mirror.nju.edu.cn/ubuntu-cloud-images/wsl/jammy/current",
|
|
629
|
-
"https://mirrors.ustc.edu.cn/ubuntu-cloud-images/wsl/jammy/current",
|
|
630
|
-
"https://mirrors.tuna.tsinghua.edu.cn/ubuntu-cloud-images/wsl/jammy/current",
|
|
631
|
-
"https://cloud-images.ubuntu.com/wsl/jammy/current",
|
|
632
|
-
]
|
|
633
|
-
: [
|
|
634
|
-
"https://cloud-images.ubuntu.com/wsl/jammy/current",
|
|
635
|
-
"https://mirror.nju.edu.cn/ubuntu-cloud-images/wsl/jammy/current",
|
|
636
|
-
"https://mirrors.ustc.edu.cn/ubuntu-cloud-images/wsl/jammy/current",
|
|
637
|
-
];
|
|
638
|
-
const fileName = "ubuntu-jammy-wsl-amd64-ubuntu22.04lts.rootfs.tar.gz";
|
|
639
|
-
const urls = baseMirrors.map(b => `${b}/${fileName}`);
|
|
640
|
-
|
|
641
|
-
emit({ phase: "installing-wsl", message: "Picking fastest Ubuntu rootfs mirror…" });
|
|
642
|
-
|
|
643
|
-
// curl HEAD probes → fastest mirror first.
|
|
644
|
-
const ordered = await curlOrder(urls);
|
|
645
|
-
|
|
646
|
-
emit({ phase: "installing-wsl", message: "Downloading Ubuntu rootfs (~350MB, may take a few minutes)…", status: "running", progress: 0 });
|
|
647
|
-
|
|
648
|
-
// Resolve %TEMP% once so the JS poller can stat the exact file curl writes.
|
|
649
|
-
const tarName = "ubuntu-jammy-wsl-amd64.tar.gz";
|
|
650
|
-
const tmpProbe = await cmdExec(`echo %TEMP%`, { timeoutMs: 12_000, silent: true });
|
|
651
|
-
const tmpDir = (tmpProbe.stdout || "").trim() || "C:\\Windows\\Temp";
|
|
652
|
-
const tarPath = `${tmpDir}\\${tarName}`;
|
|
653
|
-
|
|
654
|
-
// Ubuntu 22.04 rootfs is ~350 MB across mirrors. Progress-bar denominator
|
|
655
|
-
// only — we don't reject on size here, just sanity-check >50 MB after.
|
|
656
|
-
const ROOTFS_BYTES = 350 * 1024 * 1024;
|
|
657
|
-
|
|
658
|
-
await makeDir(installDrive.installDir);
|
|
659
|
-
await delFile(tarPath); // drop any stale/locked leftover before fetching
|
|
660
|
-
|
|
661
|
-
// Download via curl (sequential mirror fallthrough), then wsl --import.
|
|
662
|
-
// curl runs as its own RPC so the JS poller can read the growing file.
|
|
663
|
-
// `downloading` gates the size poller: once the tarball is fully fetched
|
|
664
|
-
// we flip it off so the poller stops spamming "下载中 …%" over the
|
|
665
|
-
// (unmeterable, multi-minute) import — otherwise the bar looks frozen at
|
|
666
|
-
// 93% and the user thinks it hung.
|
|
667
|
-
let downloading = true;
|
|
668
|
-
const downloadPromise = (async () => {
|
|
669
|
-
let fetched = false;
|
|
670
|
-
for (const u of ordered) {
|
|
671
|
-
_emit({ kind: "log", text: `$ curl ↓ ${previewCmd(u)}` });
|
|
672
|
-
const dl = await sh(
|
|
673
|
-
`curl.exe -fL --connect-timeout 15 --max-time 1800 -o "${tarPath}" "${u}"`,
|
|
674
|
-
{ timeoutMs: 32 * 60_000, silent: true },
|
|
675
|
-
);
|
|
676
|
-
if (dl.ok && (await fileSize(tarPath)) > 50_000_000) { fetched = true; break; }
|
|
677
|
-
}
|
|
678
|
-
if (!fetched) { downloading = false; await delFile(tarPath); return { ok: false, error: "no reachable rootfs mirror" }; }
|
|
679
|
-
downloading = false;
|
|
680
|
-
emit({ phase: "installing-wsl", message: "正在导入运行环境…", status: "running", progress: 0.98 });
|
|
681
|
-
const imp = await sh(
|
|
682
|
-
`wsl --import Ubuntu "${installDrive.installDir}" "${tarPath}" --version 2`,
|
|
683
|
-
{ timeoutMs: 20 * 60_000 },
|
|
684
|
-
);
|
|
685
|
-
await delFile(tarPath);
|
|
686
|
-
if (!imp.ok) return { ok: false, error: imp.stderr || imp.stdout || `wsl --import exit ${imp.code}` };
|
|
687
|
-
return { ok: true };
|
|
688
|
-
})();
|
|
689
|
-
|
|
690
|
-
// Poll the temp file size while the download runs → live progress bar.
|
|
691
|
-
// Capped at 0.98 so the bar stays active until `wsl --import` finishes.
|
|
692
|
-
let downloadDone = false;
|
|
693
|
-
const poller = (async () => {
|
|
694
|
-
while (!downloadDone) {
|
|
695
|
-
await new Promise((res) => setTimeout(res, 3000));
|
|
696
|
-
if (downloadDone) break;
|
|
697
|
-
if (!downloading) continue; // import in progress — don't overwrite its message
|
|
698
|
-
try {
|
|
699
|
-
const bytes = await fileSize(tarPath);
|
|
700
|
-
if (bytes > 0) {
|
|
701
|
-
const pct = Math.min(0.98, bytes / ROOTFS_BYTES);
|
|
702
|
-
const mb = (bytes / 1024 / 1024).toFixed(1);
|
|
703
|
-
emit({
|
|
704
|
-
phase: "installing-wsl",
|
|
705
|
-
message: `Downloading Ubuntu rootfs… ${mb} MB`,
|
|
706
|
-
status: "running",
|
|
707
|
-
progress: pct,
|
|
708
|
-
});
|
|
709
|
-
}
|
|
710
|
-
} catch {/* swallow — poller failures shouldn't break install */}
|
|
711
|
-
}
|
|
712
|
-
})();
|
|
713
|
-
|
|
714
|
-
const r = await downloadPromise;
|
|
715
|
-
downloadDone = true;
|
|
716
|
-
await poller; // let the loop exit cleanly on next sleep tick
|
|
717
|
-
if (!r.ok) return { ok: false, error: r.error || "rootfs-import failed" };
|
|
718
|
-
return { ok: true, method: "rootfs-import" };
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
async function waitForDistroReady(distro, emit, deadlineMs = DEFAULT_TIMEOUTS.wslBoot) {
|
|
722
|
-
const start = Date.now();
|
|
723
|
-
let shutdownTries = 0;
|
|
724
|
-
while (Date.now() - start < deadlineMs) {
|
|
725
|
-
const r = await wslExec(distro, ["true"], { timeoutMs: 20_000 });
|
|
726
|
-
if (r.ok) return { ok: true };
|
|
727
|
-
// A freshly imported distro very often throws `Wsl/Service/E_UNEXPECTED`
|
|
728
|
-
// (or just hangs) until the WSL2 lightweight VM is cycled once. A single
|
|
729
|
-
// `wsl --shutdown` reliably clears it. We do this proactively on the
|
|
730
|
-
// first failure rather than burning the whole deadline retrying a VM
|
|
731
|
-
// that will never come up on its own. Bounded to 2 cycles.
|
|
732
|
-
const out = (r.stderr || "") + (r.stdout || "");
|
|
733
|
-
const stuck = /E_UNEXPECTED|Wsl\/Service|HRESULT|0x8/i.test(out);
|
|
734
|
-
if (shutdownTries < 2 && (stuck || Date.now() - start > 20_000)) {
|
|
735
|
-
shutdownTries++;
|
|
736
|
-
emit({ phase: "waiting-distro", message: "正在重启运行环境…", status: "running" });
|
|
737
|
-
await sh("wsl --shutdown", { timeoutMs: 30_000 });
|
|
738
|
-
await new Promise(res => setTimeout(res, 6_000));
|
|
739
|
-
continue;
|
|
740
|
-
}
|
|
741
|
-
emit({ phase: "waiting-distro", message: "正在启动运行环境…", status: "running" });
|
|
742
|
-
await new Promise(res => setTimeout(res, 3_000));
|
|
743
|
-
}
|
|
744
|
-
return { ok: false, error: `${distro} did not boot in ${Math.round(deadlineMs / 1000)}s` };
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// ── apt sources fix ──────────────────────────────────────────────────
|
|
748
|
-
async function ensureAptSourcesReachable(distro, network, emit) {
|
|
749
|
-
emit({ phase: "configuring-apt", message: "Probing apt mirror reachability…" });
|
|
750
|
-
|
|
751
|
-
// Detect codename + sources file format (jammy uses sources.list, noble uses /etc/apt/sources.list.d/ubuntu.sources)
|
|
752
|
-
const probe = await wslBash(distro, `set -e
|
|
753
|
-
. /etc/os-release
|
|
754
|
-
echo "CODENAME=$VERSION_CODENAME"
|
|
755
|
-
if [ -s /etc/apt/sources.list ]; then
|
|
756
|
-
echo "FILE=/etc/apt/sources.list"
|
|
757
|
-
elif [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
|
758
|
-
echo "FILE=/etc/apt/sources.list.d/ubuntu.sources"
|
|
759
|
-
else
|
|
760
|
-
echo "FILE=/etc/apt/sources.list"
|
|
761
|
-
fi
|
|
762
|
-
CUR=$(grep -m1 -oE 'https?://[^ /]+' /etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null | head -1 | sed 's/.*: *//') || true
|
|
763
|
-
echo "CUR=$CUR"
|
|
764
|
-
if [ -n "$CUR" ] && curl -fsI --max-time 5 "$CUR" >/dev/null 2>&1; then
|
|
765
|
-
echo "REACHABLE=1"
|
|
766
|
-
else
|
|
767
|
-
echo "REACHABLE=0"
|
|
768
|
-
fi`, { timeoutMs: 25_000 });
|
|
769
|
-
|
|
770
|
-
if (!probe.ok) return { ok: false, error: "probe failed: " + probe.stderr };
|
|
771
|
-
|
|
772
|
-
const env = Object.fromEntries(probe.stdout.split("\n").map(l => {
|
|
773
|
-
const i = l.indexOf("="); return i < 0 ? [l, ""] : [l.slice(0, i), l.slice(i + 1)];
|
|
774
|
-
}));
|
|
775
|
-
|
|
776
|
-
if (env.REACHABLE === "1") {
|
|
777
|
-
emit({ phase: "configuring-apt", message: `apt mirror reachable: ${env.CUR}` });
|
|
778
|
-
return { ok: true, mirror: env.CUR, changed: false };
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// Pick a reachable mirror from candidates.
|
|
782
|
-
const candidates = APT_MIRRORS[network] || APT_MIRRORS.global;
|
|
783
|
-
const probeScript = candidates.map(c =>
|
|
784
|
-
`if curl -fsI --max-time 5 "${c}/dists/${env.CODENAME}/Release" >/dev/null 2>&1; then echo "${c}"; exit 0; fi`
|
|
785
|
-
).join("\n");
|
|
786
|
-
const pick = await wslBash(distro, probeScript + "\nexit 1", { timeoutMs: 35_000 });
|
|
787
|
-
if (!pick.ok || !pick.stdout) return { ok: false, error: "no reachable apt mirror" };
|
|
788
|
-
const mirror = pick.stdout.trim().split(/\r?\n/).pop();
|
|
789
|
-
|
|
790
|
-
// Rewrite sources.list (works for jammy). For noble and later, also detect/replace ubuntu.sources.
|
|
791
|
-
const codename = env.CODENAME || "jammy";
|
|
792
|
-
const newList = [
|
|
793
|
-
`deb ${mirror} ${codename} main restricted universe multiverse`,
|
|
794
|
-
`deb ${mirror} ${codename}-updates main restricted universe multiverse`,
|
|
795
|
-
`deb ${mirror} ${codename}-backports main restricted universe multiverse`,
|
|
796
|
-
`deb ${mirror} ${codename}-security main restricted universe multiverse`,
|
|
797
|
-
].join("\n");
|
|
798
|
-
|
|
799
|
-
const w = await wslBash(distro, `set -e
|
|
800
|
-
CONTENT='${newList.replace(/'/g, `'\\''`)}'
|
|
801
|
-
write_file() {
|
|
802
|
-
local f=$1
|
|
803
|
-
if [ -w "$f" ] || [ ! -e "$f" ]; then echo "$CONTENT" > "$f";
|
|
804
|
-
elif command -v sudo >/dev/null 2>&1; then echo "$CONTENT" | sudo tee "$f" >/dev/null;
|
|
805
|
-
else return 1; fi
|
|
806
|
-
}
|
|
807
|
-
# Disable any existing deb822 sources (Noble+) so our deb-style takes effect.
|
|
808
|
-
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
|
809
|
-
if [ -w /etc/apt/sources.list.d/ubuntu.sources ]; then
|
|
810
|
-
mv /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list.d/ubuntu.sources.bak 2>/dev/null
|
|
811
|
-
elif command -v sudo >/dev/null 2>&1; then
|
|
812
|
-
sudo mv /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list.d/ubuntu.sources.bak 2>/dev/null
|
|
813
|
-
fi
|
|
814
|
-
fi
|
|
815
|
-
write_file /etc/apt/sources.list || { echo "no-write-access" >&2; exit 1; }
|
|
816
|
-
echo "MIRROR=${mirror}"`, { timeoutMs: 10_000 });
|
|
817
|
-
|
|
818
|
-
if (!w.ok) return { ok: false, error: w.stderr || "write failed" };
|
|
819
|
-
emit({ phase: "configuring-apt", message: `apt mirror switched to ${mirror}` });
|
|
820
|
-
return { ok: true, mirror, changed: true };
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
// ── cicy-code install into wsl ────────────────────────────────────────
|
|
824
|
-
async function installCicyCodeIntoWsl(distro, hostStagePath, expectVersion, emit) {
|
|
825
|
-
emit({ phase: "installing-cicy-code", message: `Installing cicy-code v${expectVersion} into ${distro}…`, version: expectVersion });
|
|
826
|
-
|
|
827
|
-
// Translate Windows path to wsl path.
|
|
828
|
-
const tr = await sh(`wsl -d ${distro} -e wslpath -a "${hostStagePath.replace(/\\/g, "\\\\")}"`, { timeoutMs: 10_000 });
|
|
829
|
-
if (!tr.ok) return { ok: false, error: "wslpath failed: " + tr.stderr };
|
|
830
|
-
const wslPath = tr.stdout.trim();
|
|
831
|
-
|
|
832
|
-
const script = `set -eu
|
|
833
|
-
|
|
834
|
-
# ── 1) Create a non-root default user ────────────────────────────────
|
|
835
|
-
# cicy-code refuses to start as root after bootstrap, so a fresh
|
|
836
|
-
# Ubuntu image (which has only root) can't host it. Create a "cicy"
|
|
837
|
-
# user with passwordless sudo and the same UID 1000 convention every
|
|
838
|
-
# Ubuntu setup uses. Idempotent — skips if already there.
|
|
839
|
-
if ! id cicy >/dev/null 2>&1; then
|
|
840
|
-
useradd -m -s /bin/bash cicy
|
|
841
|
-
usermod -aG sudo cicy
|
|
842
|
-
printf 'cicy ALL=(ALL) NOPASSWD:ALL\\n' > /etc/sudoers.d/90-cicy
|
|
843
|
-
chmod 440 /etc/sudoers.d/90-cicy
|
|
844
|
-
fi
|
|
845
|
-
|
|
846
|
-
# ── 2) Install the binary into a system path ─────────────────────────
|
|
847
|
-
DST=/usr/local/bin/cicy-code
|
|
848
|
-
sudo_or_root() { if [ "$(id -u)" = 0 ]; then "$@"; else sudo "$@"; fi; }
|
|
849
|
-
TMP=$(mktemp)
|
|
850
|
-
cp '${wslPath.replace(/'/g, `'\\''`)}' "$TMP"
|
|
851
|
-
chmod +x "$TMP"
|
|
852
|
-
sudo_or_root mv -f "$TMP" "$DST"
|
|
853
|
-
ACT=$("$DST" --version 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -1 || echo unknown)
|
|
854
|
-
sudo_or_root sh -c "printf '%s' \\"$ACT\\" > /usr/local/bin/cicy-code.version" || true
|
|
855
|
-
|
|
856
|
-
# ── 3) Mirror the binary into cicy's $HOME/.local/bin
|
|
857
|
-
# cicy-desktop's main process probes $HOME/.local/bin/cicy-code under
|
|
858
|
-
# the default user to decide if the install completed. Symlink there
|
|
859
|
-
# (rather than copying) keeps everything pointing at /usr/local/bin so
|
|
860
|
-
# upgrades stay atomic. We only mirror for cicy — root doesn't need it
|
|
861
|
-
# (cicy-code binary is already in /usr/local/bin which is on root's
|
|
862
|
-
# PATH, and the /etc/wsl.conf [boot] command su's to cicy anyway).
|
|
863
|
-
H=/home/cicy
|
|
864
|
-
sudo_or_root mkdir -p "$H/.local/bin"
|
|
865
|
-
sudo_or_root ln -sfn "$DST" "$H/.local/bin/cicy-code"
|
|
866
|
-
sudo_or_root sh -c "printf '%s' '$ACT' > '$H/.local/bin/cicy-code.version'"
|
|
867
|
-
sudo_or_root chown -R cicy:cicy "$H/.local" 2>/dev/null || true
|
|
868
|
-
|
|
869
|
-
# ── 4) Set up cicy user's PATH for npm-global and node binaries.
|
|
870
|
-
# When cicy-code spawns agent CLIs (claude/codex/opencode), it runs
|
|
871
|
-
# them through bash and expects to find them on PATH. Without this:
|
|
872
|
-
# - npm install -g installs to ~/.npm-global/bin but PATH doesn't
|
|
873
|
-
# include it → cicy-code keeps reinstalling on every agent start
|
|
874
|
-
# - node downloaded to ~/.local/node-vX.Y.Z is not on PATH → npm
|
|
875
|
-
# itself isn't found, breaking the whole install chain
|
|
876
|
-
# We append (not prepend) so /usr/local/bin still wins for cicy-code.
|
|
877
|
-
H=/home/cicy
|
|
878
|
-
[ -f "$H/.bashrc" ] || touch "$H/.bashrc"
|
|
879
|
-
if ! grep -q 'CICY_PATH_SETUP' "$H/.bashrc" 2>/dev/null; then
|
|
880
|
-
cat >> "$H/.bashrc" <<'BASHRC'
|
|
881
|
-
# CICY_PATH_SETUP — added by cicy-desktop installer
|
|
882
|
-
export PATH="$HOME/.npm-global/bin:$PATH"
|
|
883
|
-
# Add the most recent node-vX.Y.Z bin dir under ~/.local (cicy-code
|
|
884
|
-
# downloads node here when bootstrapping a fresh Ubuntu).
|
|
885
|
-
for d in "$HOME"/.local/node-v*-linux-x64/bin; do
|
|
886
|
-
[ -d "$d" ] && export PATH="$d:$PATH"
|
|
887
|
-
done
|
|
888
|
-
BASHRC
|
|
889
|
-
fi
|
|
890
|
-
# Profile too — login shells (the [boot] command runs as su - cicy).
|
|
891
|
-
[ -f "$H/.profile" ] || touch "$H/.profile"
|
|
892
|
-
if ! grep -q 'CICY_PATH_SETUP' "$H/.profile" 2>/dev/null; then
|
|
893
|
-
cat >> "$H/.profile" <<'PROFILE'
|
|
894
|
-
# CICY_PATH_SETUP — added by cicy-desktop installer
|
|
895
|
-
export PATH="$HOME/.npm-global/bin:$PATH"
|
|
896
|
-
for d in "$HOME"/.local/node-v*-linux-x64/bin; do
|
|
897
|
-
[ -d "$d" ] && export PATH="$d:$PATH"
|
|
898
|
-
done
|
|
899
|
-
PROFILE
|
|
900
|
-
fi
|
|
901
|
-
chown cicy:cicy "$H/.bashrc" "$H/.profile" 2>/dev/null || true
|
|
902
|
-
|
|
903
|
-
# Pre-create npm-global so \`npm install -g\` works without --prefix.
|
|
904
|
-
sudo -u cicy bash -lc 'mkdir -p $HOME/.npm-global/{bin,lib} && npm config set prefix $HOME/.npm-global 2>/dev/null || true' >/dev/null 2>&1 || true
|
|
905
|
-
|
|
906
|
-
# ── 5) Make cicy the default WSL user ────────────────────────────────
|
|
907
|
-
# Without this, every \`wsl -d Ubuntu\` lands as root and cicy-code
|
|
908
|
-
# refuses to run. /etc/wsl.conf is read at distro boot, so the caller
|
|
909
|
-
# must \`wsl --terminate <distro>\` for this to take effect.
|
|
910
|
-
# systemd=false is intentional: enabling systemd in WSL2 has caused
|
|
911
|
-
# distro-boot failures (E_FAIL with corrupted ext4.vhdx state) in
|
|
912
|
-
# field reports. We use [boot] command to autostart cicy-code instead.
|
|
913
|
-
if ! grep -q '^default=cicy' /etc/wsl.conf 2>/dev/null; then
|
|
914
|
-
cat > /etc/wsl.conf <<'EOF'
|
|
915
|
-
[user]
|
|
916
|
-
default=cicy
|
|
917
|
-
|
|
918
|
-
[boot]
|
|
919
|
-
systemd=false
|
|
920
|
-
command=su - cicy -c 'pgrep -f cicy-code >/dev/null 2>&1 || setsid -f $HOME/.local/bin/cicy-code </dev/null >>$HOME/.cicy-code.log 2>&1'
|
|
921
|
-
EOF
|
|
922
|
-
fi
|
|
923
|
-
|
|
924
|
-
echo "INSTALLED:$ACT"`;
|
|
925
|
-
const r = await wslBash(distro, script, { timeoutMs: 60_000 });
|
|
926
|
-
if (!r.ok) return { ok: false, error: r.stderr || "install failed" };
|
|
927
|
-
const m = r.stdout.match(/INSTALLED:([0-9.]+)/);
|
|
928
|
-
return { ok: true, version: m ? m[1] : expectVersion };
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
// ── Windows shell integration ──────────────────────────────────────────
|
|
932
|
-
// Make WSL's /home/cicy obvious from Windows by:
|
|
933
|
-
// 1. Creating a Desktop shortcut "CiCy" → \\wsl$\<distro>\home\cicy
|
|
934
|
-
// 2. Pinning the same UNC path to File Explorer's Quick Access
|
|
935
|
-
// Both are best-effort — failures degrade silently. The renderer's
|
|
936
|
-
// banner already has an "Open WSL Files" button as the primary access
|
|
937
|
-
// point; these shortcuts are for users who close cicy-desktop and want
|
|
938
|
-
// to find their files later without re-launching the homepage.
|
|
939
|
-
async function createWindowsWslShortcuts(wslHomePath, emit) {
|
|
940
|
-
emit({ phase: "mounting-files", message: "Creating Desktop shortcut + Quick Access pin…", status: "running" });
|
|
941
|
-
// PowerShell escapes: the UNC path uses backslashes, which inside a
|
|
942
|
-
// PS single-quoted string survive verbatim. We pass it interpolated
|
|
943
|
-
// into the script body (JS → PS literal) without further escaping.
|
|
944
|
-
// .lnk creation via WScript.Shell COM is the standard Windows-shell
|
|
945
|
-
// idiom; it requires no admin and works on every Windows since 7.
|
|
946
|
-
// Quick Access pin uses the verb "pintohome" on the Shell32 namespace
|
|
947
|
-
// — same path File Explorer's right-click "Pin to Quick access" uses.
|
|
948
|
-
const script = `
|
|
949
|
-
$ErrorActionPreference = 'Continue'
|
|
950
|
-
$target = '${wslHomePath.replace(/'/g, "''")}'
|
|
951
|
-
|
|
952
|
-
# 1. Desktop shortcut
|
|
953
|
-
try {
|
|
954
|
-
$desktop = [Environment]::GetFolderPath('Desktop')
|
|
955
|
-
$lnk = Join-Path $desktop 'CiCy.lnk'
|
|
956
|
-
$shell = New-Object -ComObject WScript.Shell
|
|
957
|
-
$sc = $shell.CreateShortcut($lnk)
|
|
958
|
-
$sc.TargetPath = $target
|
|
959
|
-
$sc.Description = 'CiCy WSL files (AI agent workspace)'
|
|
960
|
-
$sc.IconLocation = 'shell32.dll,3' # generic folder icon
|
|
961
|
-
$sc.Save()
|
|
962
|
-
Write-Output "SHORTCUT_OK $lnk"
|
|
963
|
-
} catch { Write-Output "SHORTCUT_FAIL $_" }
|
|
964
|
-
|
|
965
|
-
# 2. Quick Access pin
|
|
966
|
-
try {
|
|
967
|
-
$shell = New-Object -ComObject Shell.Application
|
|
968
|
-
$folder = $shell.Namespace($target)
|
|
969
|
-
if ($folder) {
|
|
970
|
-
$folder.Self.InvokeVerb('pintohome')
|
|
971
|
-
Write-Output "QUICKACCESS_OK"
|
|
972
|
-
} else {
|
|
973
|
-
Write-Output "QUICKACCESS_SKIP target unreachable"
|
|
974
|
-
}
|
|
975
|
-
} catch { Write-Output "QUICKACCESS_FAIL $_" }
|
|
976
|
-
`;
|
|
977
|
-
// Only step that genuinely needs PowerShell (WScript.Shell / Shell.Application
|
|
978
|
-
// COM for .lnk + Quick Access pin — no cmd/curl equivalent). Generous
|
|
979
|
-
// timeout so a slow (AV-throttled) powershell.exe spawn still completes;
|
|
980
|
-
// failure is non-fatal and handled below.
|
|
981
|
-
const r = await ps(script, { timeoutMs: 75_000 });
|
|
982
|
-
// Non-fatal regardless — the "Open WSL Files" button in the banner
|
|
983
|
-
// covers the primary access path. We only surface success/skip in
|
|
984
|
-
// the timeline detail so the user knows the shortcut was created.
|
|
985
|
-
if (r.ok && r.stdout.includes("SHORTCUT_OK")) {
|
|
986
|
-
const pinned = r.stdout.includes("QUICKACCESS_OK");
|
|
987
|
-
emit({
|
|
988
|
-
phase: "mounting-files",
|
|
989
|
-
message: pinned ? "Desktop shortcut + Quick Access pin created" : "Desktop shortcut created",
|
|
990
|
-
status: "done",
|
|
991
|
-
});
|
|
992
|
-
} else {
|
|
993
|
-
emit({ phase: "mounting-files", message: `Warning: shortcut creation failed — open via the banner button instead`, status: "warning" });
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
// ── apt runtime dependency install ─────────────────────────────────────
|
|
998
|
-
// cicy-code's setup.go runs `checkEnvironment()` at first launch and
|
|
999
|
-
// blocks the API server until missing baseTools (unzip, jq, etc.) are
|
|
1000
|
-
// apt-installed. On a fresh Ubuntu rootfs this is ~30 seconds of
|
|
1001
|
-
// "Installed v2.1.0" → click "open" → nothing-responds limbo.
|
|
1002
|
-
//
|
|
1003
|
-
// We front-load the apt install here so the API is up the moment the
|
|
1004
|
-
// "done" event fires. Skipping ffmpeg (240 MB, async on the cicy-code
|
|
1005
|
-
// side anyway — non-blocking even if installed lazily).
|
|
1006
|
-
async function installRuntimeDeps(distro, emit) {
|
|
1007
|
-
emit({ phase: "installing-deps", message: "Installing apt packages (unzip, jq, CJK fonts)…", status: "running" });
|
|
1008
|
-
// sudoPrefix mirrors setup.go's logic — apt-get needs root, but the
|
|
1009
|
-
// current user might already be root (fresh rootfs imports run as
|
|
1010
|
-
// root by default before /etc/wsl.conf default=cicy takes effect).
|
|
1011
|
-
//
|
|
1012
|
-
// `set -o pipefail` so `apt | tail` doesn't mask apt's exit code, plus
|
|
1013
|
-
// a per-package dpkg check after install — apt-get can exit 0 while
|
|
1014
|
-
// some packages silently fail (e.g. missing from configured mirror).
|
|
1015
|
-
// Reporting "done" with packages missing leaves cicy-code to apt-fight
|
|
1016
|
-
// with our background apt-get update and the UI lies about readiness.
|
|
1017
|
-
const script = `set -eo pipefail
|
|
1018
|
-
SUDO=""
|
|
1019
|
-
if [ "$(id -u)" != "0" ] && command -v sudo >/dev/null 2>&1; then SUDO="sudo "; fi
|
|
1020
|
-
PKGS="unzip jq fonts-wqy-microhei fontconfig"
|
|
1021
|
-
# Don't run update if we already have one from the apt-mirror config step
|
|
1022
|
-
# (timestamp <5 min old). Saves ~10 seconds on warm runs.
|
|
1023
|
-
if [ ! -f /var/cache/apt/pkgcache.bin ] || [ $(($(date +%s) - $(stat -c %Y /var/cache/apt/pkgcache.bin))) -gt 300 ]; then
|
|
1024
|
-
DEBIAN_FRONTEND=noninteractive $SUDO apt-get update -qq >/dev/null 2>&1 || true
|
|
1025
|
-
fi
|
|
1026
|
-
APT_OUT=$(DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y --no-install-recommends $PKGS 2>&1) || {
|
|
1027
|
-
echo "$APT_OUT" | tail -5 >&2
|
|
1028
|
-
exit 1
|
|
1029
|
-
}
|
|
1030
|
-
MISSING=""
|
|
1031
|
-
for p in $PKGS; do
|
|
1032
|
-
dpkg -s "$p" >/dev/null 2>&1 || MISSING="$MISSING $p"
|
|
1033
|
-
done
|
|
1034
|
-
if [ -n "$MISSING" ]; then
|
|
1035
|
-
echo "$APT_OUT" | tail -5 >&2
|
|
1036
|
-
echo "PACKAGES_MISSING:$MISSING" >&2
|
|
1037
|
-
exit 2
|
|
1038
|
-
fi
|
|
1039
|
-
echo "$APT_OUT" | tail -3
|
|
1040
|
-
`;
|
|
1041
|
-
// Run the apt packages AND the Node.js pre-install concurrently — Node is
|
|
1042
|
-
// the long pole of cicy-code's first-launch checkEnvironment() (it blocks
|
|
1043
|
-
// the :8008 bind until node is on PATH). Front-loading it here, overlapped
|
|
1044
|
-
// with apt + the user's agent-pick think-time, turns the later "启动 AI
|
|
1045
|
-
// 引擎" step from a 3–5 min wait into a near-instant health check.
|
|
1046
|
-
const [r] = await Promise.all([
|
|
1047
|
-
wslBash(distro, script, { timeoutMs: 5 * 60_000 }),
|
|
1048
|
-
preinstallNode(distro, emit),
|
|
1049
|
-
]);
|
|
1050
|
-
if (!r.ok) {
|
|
1051
|
-
// Non-fatal: cicy-code can apt-install on its own at first launch.
|
|
1052
|
-
// We just lose the "API ready instantly" guarantee.
|
|
1053
|
-
emit({ phase: "installing-deps", message: `Warning: ${summarize(r.stderr, { maxLines: 1 })} — cicy-code will retry`, status: "warning" });
|
|
1054
|
-
return { ok: false, error: r.stderr };
|
|
1055
|
-
}
|
|
1056
|
-
emit({ phase: "installing-deps", message: "运行依赖已就绪", status: "done" });
|
|
1057
|
-
return { ok: true };
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// Best-effort Node.js pre-install — mirrors api/mgr/setup.go nodeInstallCmd
|
|
1061
|
-
// (Node 24 prebuilt tarball into $HOME/.local, npmmirror→nodejs.org). Lands
|
|
1062
|
-
// `node` on $HOME/.local/bin which cicy-code's extendPATH() exports, so its
|
|
1063
|
-
// checkEnvironment() finds node already present and skips the slow install,
|
|
1064
|
-
// binding :8008 immediately. Purely an optimization: if anything here fails,
|
|
1065
|
-
// cicy-code installs node itself at first launch (just slower). Never throws.
|
|
1066
|
-
async function preinstallNode(distro, emit) {
|
|
1067
|
-
emit({ phase: "installing-deps", message: "正在预装运行依赖(Node.js)…", status: "running" });
|
|
1068
|
-
const script = `set -e
|
|
1069
|
-
if command -v node >/dev/null 2>&1 && [ -x "$HOME/.local/bin/node" ]; then echo NODE_OK; exit 0; fi
|
|
1070
|
-
ARCH=$(uname -m)
|
|
1071
|
-
case "$ARCH" in
|
|
1072
|
-
x86_64) NARCH=x64 ;;
|
|
1073
|
-
aarch64|arm64) NARCH=arm64 ;;
|
|
1074
|
-
*) exit 1 ;;
|
|
1075
|
-
esac
|
|
1076
|
-
INDEX_URL=""
|
|
1077
|
-
for url in "https://npmmirror.com/mirrors/node/index.json" "https://nodejs.org/dist/index.json"; do
|
|
1078
|
-
if curl -fsI --max-time 6 "$url" >/dev/null 2>&1; then INDEX_URL="$url"; break; fi
|
|
1079
|
-
done
|
|
1080
|
-
[ -n "$INDEX_URL" ] || exit 1
|
|
1081
|
-
NODE_VER=$(curl -fsSL --max-time 15 "$INDEX_URL" | grep -m1 -oE '"v24\\.[0-9]+\\.[0-9]+"' | head -1 | tr -d '"')
|
|
1082
|
-
[ -n "$NODE_VER" ] || NODE_VER="v24.0.0"
|
|
1083
|
-
BASE=$(dirname "$INDEX_URL")
|
|
1084
|
-
TARBALL="$BASE/\${NODE_VER}/node-\${NODE_VER}-linux-\${NARCH}.tar.xz"
|
|
1085
|
-
mkdir -p "$HOME/.local" "$HOME/.local/bin"
|
|
1086
|
-
TMP=$(mktemp -t node.XXXXXX.tar.xz)
|
|
1087
|
-
curl -fsSL --max-time 300 "$TARBALL" -o "$TMP"
|
|
1088
|
-
tar -xJf "$TMP" -C "$HOME/.local"
|
|
1089
|
-
rm -f "$TMP"
|
|
1090
|
-
ln -sfn "$HOME/.local/node-\${NODE_VER}-linux-\${NARCH}" "$HOME/.local/node"
|
|
1091
|
-
ln -sf "$HOME/.local/node/bin/node" "$HOME/.local/bin/node"
|
|
1092
|
-
ln -sf "$HOME/.local/node/bin/npm" "$HOME/.local/bin/npm"
|
|
1093
|
-
ln -sf "$HOME/.local/node/bin/npx" "$HOME/.local/bin/npx"
|
|
1094
|
-
"$HOME/.local/bin/node" --version >/dev/null 2>&1 || exit 1
|
|
1095
|
-
echo NODE_OK`;
|
|
1096
|
-
try {
|
|
1097
|
-
const r = await wslBash(distro, script, { timeoutMs: 6 * 60_000 });
|
|
1098
|
-
if (!(r.ok && /NODE_OK/.test(r.stdout))) {
|
|
1099
|
-
_emit({ kind: "log", text: "(node pre-install skipped/failed — cicy-code will install it at first launch)" });
|
|
1100
|
-
}
|
|
1101
|
-
} catch {/* non-fatal optimization */}
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
// Rewrite /etc/wsl.conf's [boot] command so cicy-code is launched with
|
|
1105
|
-
// --agents=<csv> at every distro start (and on the post-install
|
|
1106
|
-
// `wsl --terminate` we do below). Without this the user's agent
|
|
1107
|
-
// selection only sticks for the current launch — next reboot they'd lose
|
|
1108
|
-
// it. Idempotent: replaces the whole [boot] section.
|
|
1109
|
-
async function applyAgentsToBoot(distro, agents, emit) {
|
|
1110
|
-
const csv = (Array.isArray(agents) ? agents : []).join(",");
|
|
1111
|
-
const bootCmd = `pgrep -f cicy-code >/dev/null 2>&1 || setsid -f $HOME/.local/bin/cicy-code ${csv ? `--agents=${csv} ` : ""}</dev/null >>$HOME/.cicy-code.log 2>&1`;
|
|
1112
|
-
// Use sed to replace just the [boot] block — keeps [user] etc. intact.
|
|
1113
|
-
// python3 is the cleanest tool, but not always present at this stage;
|
|
1114
|
-
// fall back to awk which IS guaranteed by base-files in every Ubuntu.
|
|
1115
|
-
const script = `set -e
|
|
1116
|
-
SUDO=""
|
|
1117
|
-
if [ "$(id -u)" != "0" ] && command -v sudo >/dev/null 2>&1; then SUDO="sudo "; fi
|
|
1118
|
-
F=/etc/wsl.conf
|
|
1119
|
-
$SUDO touch "$F"
|
|
1120
|
-
$SUDO cp "$F" "$F.bak.$(date +%s)" 2>/dev/null || true
|
|
1121
|
-
$SUDO awk -v cmd=${JSON.stringify(bootCmd)} '
|
|
1122
|
-
BEGIN { in_boot=0; printed=0 }
|
|
1123
|
-
/^\\[boot\\]/ { in_boot=1; print; print "systemd=false"; print "command=su - cicy -c \\047" cmd "\\047"; printed=1; next }
|
|
1124
|
-
/^\\[/ && in_boot { in_boot=0 }
|
|
1125
|
-
in_boot { next }
|
|
1126
|
-
{ print }
|
|
1127
|
-
END {
|
|
1128
|
-
if (!printed) {
|
|
1129
|
-
print ""
|
|
1130
|
-
print "[boot]"
|
|
1131
|
-
print "systemd=false"
|
|
1132
|
-
print "command=su - cicy -c \\047" cmd "\\047"
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
' "$F" > /tmp/wsl.conf.new
|
|
1136
|
-
$SUDO mv /tmp/wsl.conf.new "$F"
|
|
1137
|
-
$SUDO chmod 644 "$F"
|
|
1138
|
-
echo OK
|
|
1139
|
-
`;
|
|
1140
|
-
const r = await wslBash(distro, script, { timeoutMs: 15_000 });
|
|
1141
|
-
if (!r.ok || !/OK/.test(r.stdout || "")) {
|
|
1142
|
-
// Non-fatal: cicy-code can still be started ad-hoc this session, the
|
|
1143
|
-
// user just won't get persistent --agents across reboots.
|
|
1144
|
-
emit?.({ phase: "starting", message: `Warning: wsl.conf agent update failed (${r.stderr || "unknown"})`, status: "warning" });
|
|
1145
|
-
return { ok: false };
|
|
1146
|
-
}
|
|
1147
|
-
return { ok: true };
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
// ── agent CLI install (claude/codex/opencode) ─────────────────────────
|
|
1151
|
-
// Kept for reference / future "manual install" UI. NOT called from
|
|
1152
|
-
// windowsInstall anymore — installer used to pre-warm all three CLIs
|
|
1153
|
-
// before the install was reported done, which added ~3–5 minutes and
|
|
1154
|
-
// failed often on flaky networks. cicy-code's own setup.go now installs
|
|
1155
|
-
// the requested subset (from --agents) lazily on first agent create, so
|
|
1156
|
-
// the UI flips to "ready" the moment :8008 binds, and the user picks
|
|
1157
|
-
// claude / codex / opencode in the drawer before start.
|
|
1158
|
-
//
|
|
1159
|
-
// Original npm-install logic referenced below — preserved because the
|
|
1160
|
-
// nested-native-binary verify dance is non-obvious. cicy-code's setup.go
|
|
1161
|
-
// uses the same approach internally.
|
|
1162
|
-
// platform-native sibling when the network drops mid-install. The
|
|
1163
|
-
// wrapper still prints a version string but actually launching the CLI
|
|
1164
|
-
// errors with "native binary not installed". Pre-installing here with
|
|
1165
|
-
// strict verification means:
|
|
1166
|
-
// - the bug is hot-fixable via CF Worker without re-releasing cicy-code
|
|
1167
|
-
// - first agent start after install is instant (no first-time npm wait)
|
|
1168
|
-
//
|
|
1169
|
-
// Each upstream wrapper ships platform binaries via optionalDependencies
|
|
1170
|
-
// using its own naming convention. npm puts these *nested* inside the
|
|
1171
|
-
// wrapper's own node_modules — we verify there, NOT at the top level.
|
|
1172
|
-
// We do not pass the native subpackage as a positional install arg:
|
|
1173
|
-
// codex's optional deps are npm-aliases to version-tagged builds
|
|
1174
|
-
// (`npm:@openai/codex@<v>-linux-x64`), so `@openai/codex-linux-x64@latest`
|
|
1175
|
-
// 404s and would kill the whole install command.
|
|
1176
|
-
const AGENT_SPECS = {
|
|
1177
|
-
claude: { pkg: "@anthropic-ai/claude-code", nativePrefix: "@anthropic-ai/claude-code-linux-" },
|
|
1178
|
-
codex: { pkg: "@openai/codex", nativePrefix: "@openai/codex-linux-" },
|
|
1179
|
-
opencode: { pkg: "opencode-ai", nativePrefix: "opencode-linux-" },
|
|
1180
|
-
};
|
|
1181
|
-
|
|
1182
|
-
async function installAgentClis(distro, network, agents, emit) {
|
|
1183
|
-
// CN networks must use npmmirror; with registry.npmjs.org the wrapper
|
|
1184
|
-
// download alone can stall for 20+ min before npm gives up on optional
|
|
1185
|
-
// deps, which is the exact path that produces the "wrapper installed,
|
|
1186
|
-
// native missing" state we're trying to prevent.
|
|
1187
|
-
const registry = (network === "cn" || network === "unknown")
|
|
1188
|
-
? "https://registry.npmmirror.com"
|
|
1189
|
-
: "https://registry.npmjs.org";
|
|
1190
|
-
const fallbackRegistry = registry === "https://registry.npmmirror.com"
|
|
1191
|
-
? "https://registry.npmjs.org"
|
|
1192
|
-
: "https://registry.npmmirror.com";
|
|
1193
|
-
|
|
1194
|
-
// WSL is always Linux; map uname -m → npm platform suffix.
|
|
1195
|
-
// x86_64 → x64, aarch64/arm64 → arm64. Reject anything else with a
|
|
1196
|
-
// clear error rather than silently picking the wrong package.
|
|
1197
|
-
const archProbe = await wslBash(distro, "uname -m", { timeoutMs: 5_000 });
|
|
1198
|
-
const m = (archProbe.stdout || "").trim();
|
|
1199
|
-
const arch = m === "x86_64" ? "x64" : (m === "aarch64" || m === "arm64") ? "arm64" : "";
|
|
1200
|
-
if (!arch) return { ok: false, error: `unsupported arch: ${m}` };
|
|
1201
|
-
|
|
1202
|
-
// One install attempt for one agent against a given registry. Returns
|
|
1203
|
-
// { ok, error }. The caller handles retry/fallback policy.
|
|
1204
|
-
async function tryInstall(name, spec, nativePkg, reg) {
|
|
1205
|
-
const script = `
|
|
1206
|
-
set -e
|
|
1207
|
-
export PATH="$HOME/.npm-global/bin:$HOME/.local/node/bin:$PATH"
|
|
1208
|
-
npm install -g --include=optional --fetch-timeout=60000 --fetch-retries=2 \\
|
|
1209
|
-
--registry=${reg} --prefix "$HOME/.npm-global" \\
|
|
1210
|
-
${spec.pkg}@latest
|
|
1211
|
-
`;
|
|
1212
|
-
const r = await wslBash(distro, script, { timeoutMs: 8 * 60_000 });
|
|
1213
|
-
if (!r.ok) {
|
|
1214
|
-
return { ok: false, error: summarize(r.stderr || r.stdout, { maxLines: 2, maxChars: 200 }) };
|
|
1215
|
-
}
|
|
1216
|
-
// The native subpackage lives *inside* the wrapper's own node_modules,
|
|
1217
|
-
// not as a top-level sibling. This is npm's standard placement for
|
|
1218
|
-
// optionalDependencies resolved during install.
|
|
1219
|
-
const verify = `[ -d "$HOME/.npm-global/lib/node_modules/${spec.pkg}/node_modules/${nativePkg}" ] && echo OK || { echo MISSING; exit 1; }`;
|
|
1220
|
-
const v = await wslBash(distro, verify, { timeoutMs: 5_000 });
|
|
1221
|
-
if (!v.ok || !v.stdout.includes("OK")) {
|
|
1222
|
-
return { ok: false, error: `native subpackage missing (${nativePkg})` };
|
|
1223
|
-
}
|
|
1224
|
-
return { ok: true };
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
const results = {};
|
|
1228
|
-
for (const name of agents) {
|
|
1229
|
-
const spec = AGENT_SPECS[name];
|
|
1230
|
-
if (!spec) {
|
|
1231
|
-
results[name] = { ok: false, error: "unknown agent" };
|
|
1232
|
-
continue;
|
|
1233
|
-
}
|
|
1234
|
-
const nativePkg = `${spec.nativePrefix}${arch}`;
|
|
1235
|
-
emit({ phase: "installing-agents", message: `Installing ${name}…`, status: "running", agent: name });
|
|
1236
|
-
|
|
1237
|
-
// First attempt against the preferred registry.
|
|
1238
|
-
let r = await tryInstall(name, spec, nativePkg, registry);
|
|
1239
|
-
|
|
1240
|
-
// If verification failed (or the install errored), drop the cache
|
|
1241
|
-
// and retry against the fallback registry. The most common cause
|
|
1242
|
-
// we've seen is npmmirror returning a zero-byte tarball whose
|
|
1243
|
-
// sha512 doesn't match — once cached, every retry against the same
|
|
1244
|
-
// mirror reproduces the same failure until the cache is purged.
|
|
1245
|
-
if (!r.ok) {
|
|
1246
|
-
emit({ phase: "installing-agents", message: `${name}: ${r.error}; retrying via ${fallbackRegistry}`, status: "running", agent: name });
|
|
1247
|
-
await wslBash(distro, "npm cache clean --force >/dev/null 2>&1 || true", { timeoutMs: 30_000 });
|
|
1248
|
-
r = await tryInstall(name, spec, nativePkg, fallbackRegistry);
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
if (!r.ok) {
|
|
1252
|
-
results[name] = r;
|
|
1253
|
-
emit({ phase: "installing-agents", message: `${name}: ${r.error}`, status: "error", agent: name });
|
|
1254
|
-
continue;
|
|
1255
|
-
}
|
|
1256
|
-
results[name] = { ok: true, native: nativePkg };
|
|
1257
|
-
emit({ phase: "installing-agents", message: `${name} ✓`, status: "done", agent: name });
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
const failed = Object.entries(results).filter(([, v]) => !v.ok).map(([k]) => k);
|
|
1261
|
-
if (failed.length) return { ok: false, error: `failed: ${failed.join(", ")}`, results };
|
|
1262
|
-
return { ok: true, results };
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
// ── stage path ────────────────────────────────────────────────────────
|
|
1266
|
-
// Each version gets its own filename so a stale download from an older
|
|
1267
|
-
// release doesn't get reused as if it were the new binary.
|
|
1268
|
-
async function resolveStagePath(version) {
|
|
1269
|
-
const safe = String(version || "unknown").replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1270
|
-
// Resolve %APPDATA% via cmd (instant) then build + mkdir the stage dir.
|
|
1271
|
-
const r = await cmdExec(`echo %APPDATA%`, { timeoutMs: 12_000, silent: true });
|
|
1272
|
-
if (!r.ok || !r.stdout.trim()) throw new Error("stage path resolution failed");
|
|
1273
|
-
const dir = `${r.stdout.trim()}\\CiCy Desktop\\cicy-code\\wsl-stage`;
|
|
1274
|
-
await makeDir(dir);
|
|
1275
|
-
return `${dir}\\cicy-code-v${safe}-staged`;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
async function cleanupStage(path) {
|
|
1279
|
-
await delFile(path);
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
// ── main flow ─────────────────────────────────────────────────────────
|
|
1283
|
-
// onPickAgents: async ({ defaults, available }) → string[]
|
|
1284
|
-
// Called once between deps-install and start-cicy-code. UI shows a checkbox
|
|
1285
|
-
// picker; we await user confirmation, then pass the selected list to cicy-code
|
|
1286
|
-
// via --agents=<csv>. cicy-code installs only those CLIs lazily on first
|
|
1287
|
-
// agent create, so the installer doesn't block on npm. Default: all three.
|
|
1288
|
-
export async function windowsInstall({ onProgress = () => {}, onPickAgents } = {}) {
|
|
1289
|
-
const emit = (e) => { try { onProgress(e); } catch {} };
|
|
1290
|
-
setEmit(emit);
|
|
1291
|
-
assertRPC();
|
|
1292
|
-
|
|
1293
|
-
// 1. Network detect
|
|
1294
|
-
emit({ phase: "detecting", message: "Detecting network…" });
|
|
1295
|
-
const network = await detectNetwork();
|
|
1296
|
-
emit({ phase: "detecting", message: `Network: ${network}`, network });
|
|
1297
|
-
|
|
1298
|
-
// 1b. Pick install drive — auto-select the fixed NTFS drive with the
|
|
1299
|
-
// most free space. On OEM laptops where C: is a small SSD and D: is
|
|
1300
|
-
// a large data drive, the user wants WSL on D:. The picker handles
|
|
1301
|
-
// this without any UI; we surface the choice in the timeline so the
|
|
1302
|
-
// user understands where their files will live.
|
|
1303
|
-
const installDrive = await pickInstallDrive();
|
|
1304
|
-
if (installDrive.lowDisk) {
|
|
1305
|
-
// Enumeration succeeded but no fixed NTFS drive has ≥10 GB free — a
|
|
1306
|
-
// genuine low-disk condition. Bail with the real free figure rather than
|
|
1307
|
-
// failing mid-install with a confusing ENOSPC / vhdx I/O error.
|
|
1308
|
-
throw new Error(`LOW_DISK_SPACE:${installDrive.freeGb}:10`);
|
|
1309
|
-
}
|
|
1310
|
-
if (installDrive.probeFailed) {
|
|
1311
|
-
// Drive probe was inconclusive (a flaky/slow shell). Proceed on C:
|
|
1312
|
-
// rather than blocking a machine that may well be installable — the
|
|
1313
|
-
// import will surface a real disk error only if space truly runs out.
|
|
1314
|
-
emit({ phase: "detecting", message: "Drive probe inconclusive — defaulting to C:", status: "warning", installDrive: "C" });
|
|
1315
|
-
}
|
|
1316
|
-
emit({
|
|
1317
|
-
phase: "detecting",
|
|
1318
|
-
message: `Install drive: ${installDrive.letter}: (${installDrive.freeGb} GB free of ${installDrive.totalGb} GB, ${installDrive.isSSD ? "SSD" : "HDD"}${installDrive.isSystemDrive ? ", system" : ""})`,
|
|
1319
|
-
network,
|
|
1320
|
-
installDrive: installDrive.letter,
|
|
1321
|
-
installDir: installDrive.installDir,
|
|
1322
|
-
isSSD: installDrive.isSSD,
|
|
1323
|
-
});
|
|
1324
|
-
// Also assert C: has enough headroom for staged downloads + Windows
|
|
1325
|
-
// itself, even when the WSL vhdx lands elsewhere. cicy-code's staged
|
|
1326
|
-
// binary download (~20 MB) plus npm cache + temp files during the
|
|
1327
|
-
// rootfs import can still push C: over the edge on near-full systems.
|
|
1328
|
-
if (installDrive.letter !== "C") {
|
|
1329
|
-
const cFree = (installDrive.all.find(d => d.Letter === "C") || {}).FreeGB;
|
|
1330
|
-
const C_MIN_GB = 2;
|
|
1331
|
-
if (Number.isFinite(cFree) && cFree < C_MIN_GB) {
|
|
1332
|
-
throw new Error(`LOW_DISK_SPACE:${cFree}:${C_MIN_GB}`);
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
// 2. Latest manifest
|
|
1337
|
-
emit({ phase: "checking", message: "Checking latest version…" });
|
|
1338
|
-
const mf = await fetchLatestManifest(network);
|
|
1339
|
-
if (!mf.ok) throw new Error("manifest fetch failed: " + mf.error);
|
|
1340
|
-
const version = mf.version;
|
|
1341
|
-
const assetUrl = mf.assets["linux-amd64"];
|
|
1342
|
-
if (!assetUrl) throw new Error("manifest has no linux-amd64 asset"); // dev-only, never seen by users
|
|
1343
|
-
const expectSize = (mf.sizes && mf.sizes["linux-amd64"]) || 0;
|
|
1344
|
-
|
|
1345
|
-
// Resolve the staging path early so we can check the local cache before
|
|
1346
|
-
// making any network calls — if we already have a same-size copy of the
|
|
1347
|
-
// target binary on disk, no need to ping GitHub at all.
|
|
1348
|
-
const stagePath = await resolveStagePath(version);
|
|
1349
|
-
let cachedHit = false;
|
|
1350
|
-
if (expectSize > 0) {
|
|
1351
|
-
cachedHit = (await fileSize(stagePath)) === expectSize;
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
// Guard: verify the asset actually exists on the server before declaring
|
|
1355
|
-
// a new version is available. Prevents the case where manifest.json was
|
|
1356
|
-
// uploaded first but the binary hasn't finished uploading yet (Release
|
|
1357
|
-
// workflow still in progress). Skip this when we have a verified cached
|
|
1358
|
-
// copy — there's nothing to fetch.
|
|
1359
|
-
if (!cachedHit) {
|
|
1360
|
-
emit({ phase: "checking", message: `Verifying release v${version}…`, status: "running", version });
|
|
1361
|
-
const checkUrls = (network === "cn" || network === "unknown")
|
|
1362
|
-
? [...GH_MIRRORS.map(m => m + assetUrl), assetUrl]
|
|
1363
|
-
: [assetUrl, ...GH_MIRRORS.map(m => m + assetUrl)];
|
|
1364
|
-
let reachable = false;
|
|
1365
|
-
for (const u of checkUrls) {
|
|
1366
|
-
const r = await sh(
|
|
1367
|
-
`curl.exe -sI -o NUL --connect-timeout 8 --max-time 14 -w "%{http_code}" "${u}"`,
|
|
1368
|
-
{ timeoutMs: 22_000, silent: true },
|
|
1369
|
-
);
|
|
1370
|
-
const code = parseInt((r.stdout || "").trim(), 10) || 0;
|
|
1371
|
-
if (r.ok && code >= 200 && code < 400) { reachable = true; break; }
|
|
1372
|
-
}
|
|
1373
|
-
if (!reachable) {
|
|
1374
|
-
// Stable, identifiable error tag — banner can localize it via t() if
|
|
1375
|
-
// it sees this prefix.
|
|
1376
|
-
const e = new Error(`RELEASE_NOT_READY:${version}`);
|
|
1377
|
-
e.code = "RELEASE_NOT_READY";
|
|
1378
|
-
e.version = version;
|
|
1379
|
-
throw e;
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
emit({ phase: "checking", message: `Latest: v${version}`, version, network });
|
|
1384
|
-
|
|
1385
|
-
// ── 3 + 4–5: download cicy-code AND prepare WSL in parallel ──────
|
|
1386
|
-
// The two are independent: download is pure network I/O, WSL setup is
|
|
1387
|
-
// a local Windows operation. Running them concurrently can cut total
|
|
1388
|
-
// install time by the duration of whichever finishes first.
|
|
1389
|
-
// stagePath was resolved earlier (during the cache pre-check).
|
|
1390
|
-
emit({ phase: "downloading", message: `Downloading cicy-code v${version}…`, status: "running", version, network });
|
|
1391
|
-
emit({ phase: "checking-wsl", message: "Checking WSL state…", status: "running" });
|
|
1392
|
-
|
|
1393
|
-
let actualBinaryPath = stagePath;
|
|
1394
|
-
const downloadTask = (async () => {
|
|
1395
|
-
const dl = await downloadStaged({ assetUrl, network, dstPath: stagePath, expectSize });
|
|
1396
|
-
if (!dl.ok) throw new Error("download failed: " + (dl.error || "all mirrors failed"));
|
|
1397
|
-
// dl.path is the actual on-disk filename — usually equals stagePath
|
|
1398
|
-
// but may be a `.part-*` fallback if stagePath was locked by a stale
|
|
1399
|
-
// download process from an earlier interrupted install.
|
|
1400
|
-
if (dl.path) actualBinaryPath = dl.path;
|
|
1401
|
-
emit({
|
|
1402
|
-
phase: "downloading",
|
|
1403
|
-
message: dl.reused
|
|
1404
|
-
? `Using cached ${(dl.size / 1024 / 1024).toFixed(1)} MB`
|
|
1405
|
-
: `Downloaded ${(dl.size / 1024 / 1024).toFixed(1)} MB`,
|
|
1406
|
-
status: "done",
|
|
1407
|
-
progress: 1,
|
|
1408
|
-
version,
|
|
1409
|
-
});
|
|
1410
|
-
return dl;
|
|
1411
|
-
})();
|
|
1412
|
-
|
|
1413
|
-
const wslTask = (async () => {
|
|
1414
|
-
let wsl = await checkWslStatus();
|
|
1415
|
-
if (!wsl.installed || !wsl.usableDistro) {
|
|
1416
|
-
const ins = await installWsl(network, installDrive, emit);
|
|
1417
|
-
if (!ins.ok) throw new Error("wsl install: " + (ins.error || "failed"));
|
|
1418
|
-
// Explicit "done" so the installing-wsl step settles to ✓ — the
|
|
1419
|
-
// download poller's last emit is a "running" tick, so without this
|
|
1420
|
-
// the step would linger at "•" (looking stuck) even though import
|
|
1421
|
-
// succeeded and later steps advance.
|
|
1422
|
-
emit({ phase: "installing-wsl", message: "运行环境已就绪", status: "done", progress: 1 });
|
|
1423
|
-
wsl = await checkWslStatus();
|
|
1424
|
-
if (!wsl.usableDistro) throw new Error("WSL installed but no usable distro detected — Windows may need a reboot");
|
|
1425
|
-
}
|
|
1426
|
-
// Always verify distro is responsive — covers both fresh installs and
|
|
1427
|
-
// pre-existing distros that may be stopped or in a degraded state.
|
|
1428
|
-
const w = await waitForDistroReady(wsl.usableDistro, emit);
|
|
1429
|
-
if (!w.ok) throw new Error(w.error);
|
|
1430
|
-
// Settle waiting-distro to ✓ — its last emit was a "running" boot tick,
|
|
1431
|
-
// so without this it lingers at "•" even though the distro is up.
|
|
1432
|
-
emit({ phase: "waiting-distro", message: "运行环境已启动", status: "done", progress: 1 });
|
|
1433
|
-
emit({ phase: "checking-wsl", message: `Using distro: ${wsl.usableDistro}`, status: "done" });
|
|
1434
|
-
return wsl;
|
|
1435
|
-
})();
|
|
1436
|
-
|
|
1437
|
-
// Promise.all rejects on the first error, but we still want both
|
|
1438
|
-
// failures surfaced — `allSettled` lets us emit error status for each
|
|
1439
|
-
// failing branch before re-throwing.
|
|
1440
|
-
const [dlRes, wslRes] = await Promise.allSettled([downloadTask, wslTask]);
|
|
1441
|
-
if (dlRes.status === "rejected") {
|
|
1442
|
-
emit({ phase: "downloading", message: dlRes.reason?.message || String(dlRes.reason), status: "error" });
|
|
1443
|
-
}
|
|
1444
|
-
if (wslRes.status === "rejected") {
|
|
1445
|
-
emit({ phase: "checking-wsl", message: wslRes.reason?.message || String(wslRes.reason), status: "error" });
|
|
1446
|
-
}
|
|
1447
|
-
if (dlRes.status === "rejected") throw dlRes.reason;
|
|
1448
|
-
if (wslRes.status === "rejected") throw wslRes.reason;
|
|
1449
|
-
const wsl = wslRes.value;
|
|
1450
|
-
const distro = wsl.usableDistro;
|
|
1451
|
-
|
|
1452
|
-
// 6. Apt mirror — depends on WSL being ready (not on cicy-code download).
|
|
1453
|
-
const apt = await ensureAptSourcesReachable(distro, network, emit);
|
|
1454
|
-
if (!apt.ok) emit({ phase: "configuring-apt", message: `Warning: apt mirror config failed (${apt.error}), continuing` });
|
|
1455
|
-
|
|
1456
|
-
// 7. Copy + verify — depends on BOTH download and WSL being ready.
|
|
1457
|
-
const r = await installCicyCodeIntoWsl(distro, actualBinaryPath, version, emit);
|
|
1458
|
-
if (!r.ok) throw new Error("install: " + r.error);
|
|
1459
|
-
|
|
1460
|
-
// 7b. Front-load cicy-code's baseTools apt-install so its first launch
|
|
1461
|
-
// binds :8008 immediately (no 30s "checking environment" stall).
|
|
1462
|
-
await installRuntimeDeps(distro, emit);
|
|
1463
|
-
|
|
1464
|
-
// 8. Ask the user which AI agents to enable BEFORE starting cicy-code.
|
|
1465
|
-
// We pre-install nothing — cicy-code's setup.go installs only the
|
|
1466
|
-
// requested CLIs lazily on first agent create. This shaves 3–5 min
|
|
1467
|
-
// off install time and avoids the "60% of users get blocked because
|
|
1468
|
-
// one of three CLIs failed via flaky npm" problem.
|
|
1469
|
-
//
|
|
1470
|
-
// Default: claude (single most-used CLI). User multi-selects via
|
|
1471
|
-
// the drawer; we await their choice before continuing.
|
|
1472
|
-
const AVAILABLE_AGENTS = ["claude", "codex", "opencode"];
|
|
1473
|
-
let pickedAgents = ["claude"];
|
|
1474
|
-
if (_cachedAgents && _cachedAgents.length > 0) {
|
|
1475
|
-
// Resume path: the user already chose on an earlier attempt — don't
|
|
1476
|
-
// make them pick again, just reuse it so retry continues seamlessly.
|
|
1477
|
-
pickedAgents = _cachedAgents;
|
|
1478
|
-
emit({ phase: "picking-agents", message: `Agents: ${pickedAgents.join(", ")}`, status: "done", agents: pickedAgents });
|
|
1479
|
-
} else if (typeof onPickAgents === "function") {
|
|
1480
|
-
emit({ phase: "picking-agents", message: "Waiting for agent selection…", status: "running" });
|
|
1481
|
-
try {
|
|
1482
|
-
const picked = await onPickAgents({ defaults: pickedAgents, available: AVAILABLE_AGENTS });
|
|
1483
|
-
if (Array.isArray(picked) && picked.length > 0) {
|
|
1484
|
-
// Filter to known agents only — don't trust UI to send valid
|
|
1485
|
-
// names; an unknown agent passed to --agents would break startup.
|
|
1486
|
-
pickedAgents = picked.filter((a) => AVAILABLE_AGENTS.includes(a));
|
|
1487
|
-
if (pickedAgents.length === 0) pickedAgents = ["claude"];
|
|
1488
|
-
}
|
|
1489
|
-
} catch (e) {
|
|
1490
|
-
emit({ phase: "picking-agents", message: `pick error: ${e.message} — using default (claude)`, status: "warning" });
|
|
1491
|
-
}
|
|
1492
|
-
_cachedAgents = pickedAgents;
|
|
1493
|
-
emit({ phase: "picking-agents", message: `Agents: ${pickedAgents.join(", ")}`, status: "done", agents: pickedAgents });
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
// 9. Persist the agent selection in /etc/wsl.conf [boot] so cicy-code
|
|
1497
|
-
// starts with the same flag on every reboot. Idempotent — replaces
|
|
1498
|
-
// the [boot] section in-place.
|
|
1499
|
-
await applyAgentsToBoot(distro, pickedAgents, emit);
|
|
1500
|
-
|
|
1501
|
-
// 10. Reload the distro so the new /etc/wsl.conf [user] default=cicy
|
|
1502
|
-
// AND [boot] command take effect, then start cicy-code under the
|
|
1503
|
-
// new default user so :8008 is up by the time the UI flips to
|
|
1504
|
-
// "uptodate".
|
|
1505
|
-
emit({ phase: "starting", message: "正在启动 AI 引擎…", status: "running" });
|
|
1506
|
-
// Make Ubuntu the *default* distro so `wsl.exe -e bash` (no -d) — used
|
|
1507
|
-
// by cicy-desktop's main process to read $HOME/cicy-ai/global.json for
|
|
1508
|
-
// the auto-login token — lands in Ubuntu, not docker-desktop (which
|
|
1509
|
-
// ships without bash). This is mandatory for the "open in browser"
|
|
1510
|
-
// flow to inject the api_token query param.
|
|
1511
|
-
await sh(`wsl --set-default ${distro}`, { timeoutMs: 10_000 });
|
|
1512
|
-
await sh(`wsl --terminate ${distro}`, { timeoutMs: 15_000 });
|
|
1513
|
-
// First post-terminate command takes a few seconds while wsl service
|
|
1514
|
-
// re-initialises — that's also when the new wsl.conf is parsed.
|
|
1515
|
-
const agentsArg = pickedAgents.join(",");
|
|
1516
|
-
const startCmd = `wsl -d ${distro} -- bash -lc "pgrep -f cicy-code >/dev/null 2>&1 || setsid -f /usr/local/bin/cicy-code --agents=${agentsArg} </dev/null >>~/.cicy-code.log 2>&1 ; sleep 1 ; pgrep -fa cicy-code | head -1"`;
|
|
1517
|
-
await sh(startCmd, { timeoutMs: 30_000 });
|
|
1518
|
-
// First launch runs cicy-code's checkEnvironment() before binding :8008.
|
|
1519
|
-
// We front-loaded Node.js + apt deps in installRuntimeDeps, so this is now
|
|
1520
|
-
// usually seconds — but we still MUST wait for a real 200 from /api/health
|
|
1521
|
-
// (never a fake "done") in case the pre-install fell back to lazy install.
|
|
1522
|
-
emit({ phase: "starting", message: "正在启动 AI 引擎,请稍候…", status: "running" });
|
|
1523
|
-
let _apiUp = false;
|
|
1524
|
-
const _hDeadline = Date.now() + 8 * 60_000;
|
|
1525
|
-
while (Date.now() < _hDeadline) {
|
|
1526
|
-
const _hc = await sh(
|
|
1527
|
-
`wsl -d ${distro} -- bash -lc "curl -s -o /dev/null -w '%{http_code}' --max-time 4 http://localhost:8008/api/health 2>/dev/null || echo 000"`,
|
|
1528
|
-
{ timeoutMs: 15_000, silent: true },
|
|
1529
|
-
);
|
|
1530
|
-
if (_hc.ok && /200/.test(_hc.stdout)) { _apiUp = true; break; }
|
|
1531
|
-
const _left = Math.max(0, Math.round((_hDeadline - Date.now()) / 1000));
|
|
1532
|
-
emit({ phase: "starting", message: `正在配置运行环境,请稍候…(最多约 ${Math.ceil(_left / 60)} 分钟)`, status: "running" });
|
|
1533
|
-
await new Promise(r => setTimeout(r, 5_000));
|
|
1534
|
-
}
|
|
1535
|
-
emit({
|
|
1536
|
-
phase: "starting",
|
|
1537
|
-
message: _apiUp ? "AI 引擎已就绪" : "AI 引擎仍在后台配置,稍后会自动就绪",
|
|
1538
|
-
status: _apiUp ? "done" : "warning",
|
|
1539
|
-
agents: pickedAgents,
|
|
1540
|
-
});
|
|
1541
|
-
|
|
1542
|
-
// 11. Done. We deliberately do NOT create a Desktop shortcut / Quick
|
|
1543
|
-
// Access pin to the WSL home: a first-time user is here to use the
|
|
1544
|
-
// AI product, not to browse Linux files — a "\\wsl$\Ubuntu" folder
|
|
1545
|
-
// shortcut just adds confusion. (It was also the slowest remaining
|
|
1546
|
-
// PowerShell step, ~75s on AV-throttled machines.) Files stay
|
|
1547
|
-
// reachable via Explorer's network path for anyone who needs them.
|
|
1548
|
-
emit({
|
|
1549
|
-
phase: "done",
|
|
1550
|
-
message: `Installed v${r.version}`,
|
|
1551
|
-
version: r.version,
|
|
1552
|
-
status: "done",
|
|
1553
|
-
installDrive: installDrive.letter,
|
|
1554
|
-
installDir: installDrive.installDir,
|
|
1555
|
-
});
|
|
1556
|
-
return { ok: true, version: r.version, installDir: installDrive.installDir };
|
|
1557
|
-
// Note: stagePath is intentionally not deleted — downloadStaged checks
|
|
1558
|
-
// size before reuse, so cached files speed up retries / repeat installs.
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
export function canRunRendererInstall() {
|
|
1562
|
-
return typeof window !== "undefined" && typeof window.electronRPC === "function";
|
|
1563
|
-
}
|