cicy-desktop 2.1.30 → 2.1.32

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.
@@ -1,672 +0,0 @@
1
- // cicy-code installer — downloads the sidecar binary into ~/.local/bin/,
2
- // with CN-network awareness and double-source retry.
3
- //
4
- // Public surface:
5
- // getStatus() → { userInstalled, userVersion, binaryPath, installing }
6
- // checkLatest() → { ok, latest, network, releaseUrl }
7
- // install({ onProgress, signal }) → final state event
8
- // cancel() → cancels the in-flight install
9
- //
10
- // Layout on disk (2026-05-29 — versioned + symlink):
11
- // ~/.local/bin/cicy-code-<version> actual binary, +x
12
- // ~/.local/bin/cicy-code symlink → cicy-code-<version>
13
- //
14
- // The current version is read by `fs.readlink(~/.local/bin/cicy-code)` and
15
- // parsing the basename — no separate version file. Upgrading is "drop
16
- // cicy-code-<newver> + atomic-rename a new symlink over the old one".
17
- // Old versions stay on disk for rollback; future GC may trim them.
18
- //
19
- // Windows path is unchanged — the daemon lives inside WSL2 at
20
- // `$HOME/.local/bin/cicy-code` from the distro's POV, and a separate
21
- // `<userData>/cicy-code/wsl-version` cache mirrors the version for fast
22
- // Windows-side reads.
23
- //
24
- // Progress event shape:
25
- // { phase, message, progress?, version?, network?, error? }
26
- // phase ∈ "detecting" | "checking" | "downloading" | "installing" | "done" | "error" | "cancelled"
27
-
28
- const fs = require("fs");
29
- const path = require("path");
30
- const https = require("https");
31
- const http = require("http");
32
- const { app } = require("electron");
33
- const log = require("electron-log");
34
- const netDetect = require("./net-detect");
35
- const { buildUrlList } = require("./mirrors");
36
-
37
- const REPO = "cicy-ai/cicy-code";
38
-
39
- // Arch aliases — release artifacts often use Go's "amd64" while Node's
40
- // process.arch reports "x64".
41
- const ARCH_ALIASES = {
42
- x64: ["x64", "amd64", "x86_64"],
43
- arm64: ["arm64", "aarch64"],
44
- };
45
-
46
- // ---- platform helpers ----
47
- // On Windows we run the linux-amd64 binary inside WSL2, so platformDir()
48
- // reports "linux" for win32 — the manifest lookup, asset URL construction,
49
- // and arch alias logic all just use the Linux entry. wsl.js owns actually
50
- // putting the binary onto disk inside the distro.
51
- function platformDir() {
52
- return process.platform === "darwin" ? "darwin"
53
- : process.platform === "linux" ? "linux"
54
- : process.platform === "win32" ? "linux"
55
- : null;
56
- }
57
- function archDir() {
58
- // On Windows we always pull linux-amd64 (WSL distros are typically x64).
59
- if (process.platform === "win32") return "x64";
60
- return process.arch === "x64" ? "x64"
61
- : process.arch === "arm64" ? "arm64"
62
- : null;
63
- }
64
- function userDir() {
65
- if (process.platform === "win32") return null; // WSL-managed
66
- return path.join(require("os").homedir(), ".local", "bin");
67
- }
68
- function userBinary() {
69
- // Windows: cicy-code lives inside WSL at $HOME/.local/bin/cicy-code, which
70
- // is not directly addressable from the Windows side. Return a virtual
71
- // marker path so callers can still test "is something installed?" — actual
72
- // existence is checked via wsl.userInstalled() in cicy-code.js.
73
- if (process.platform === "win32") return "wsl:cicy-code";
74
-
75
- const dir = userDir();
76
- if (!dir) return null;
77
- // ~/.local/bin/cicy-code — symlink to the active version on disk.
78
- return path.join(dir, "cicy-code");
79
- }
80
- // Resolve the versioned binary path for a given semver string.
81
- function versionedBinaryPath(version) {
82
- const dir = userDir();
83
- if (!dir || !version) return null;
84
- return path.join(dir, `cicy-code-${version}`);
85
- }
86
- function userVersion() {
87
- if (process.platform === "win32") {
88
- // sync read of version file from inside WSL is awkward; we rely on the
89
- // installer step to mirror the version into a Windows-side cache.
90
- try {
91
- const cache = path.join(app.getPath("userData"), "cicy-code", "wsl-version");
92
- return fs.readFileSync(cache, "utf8").trim() || null;
93
- } catch { return null; }
94
- }
95
- const bin = userBinary();
96
- if (!bin) return null;
97
- // Preferred: read the symlink target and parse `cicy-code-<version>`.
98
- try {
99
- const target = fs.readlinkSync(bin);
100
- const m = path.basename(target).match(/^cicy-code-(\d+\.\d+\.\d+)$/);
101
- if (m) return m[1];
102
- } catch {}
103
- // Fallback: if some legacy install wrote a `version` file next to the
104
- // binary, honour it. Lets older installs upgrade cleanly.
105
- try {
106
- const legacy = path.join(path.dirname(bin), "version");
107
- const v = fs.readFileSync(legacy, "utf8").trim();
108
- if (/^\d+\.\d+\.\d+$/.test(v)) return v;
109
- } catch {}
110
- return null;
111
- }
112
-
113
- // ---- state ----
114
- let inflight = null; // { abort: () => void }
115
- let lastProgress = null; // last emitted event (for late subscribers)
116
-
117
- function getStatus() {
118
- // win32: binaryPath is a virtual marker; actual existence is inside WSL.
119
- // We can't call wsl.userInstalled() synchronously here, so we rely on the
120
- // cached wsl-version file: if it exists the user already completed an
121
- // install. The sidecar:status IPC also has a dedicated sidecar:wsl-status
122
- // channel for deeper probing.
123
- const bin = userBinary();
124
- const ver = userVersion();
125
- const installed = process.platform === "win32"
126
- ? !!ver // win32: treat "has version cache" as installed
127
- : !!(bin && fs.existsSync(bin));
128
- return {
129
- userInstalled: installed,
130
- userVersion: ver,
131
- binaryPath: bin,
132
- installing: !!inflight,
133
- lastProgress,
134
- };
135
- }
136
-
137
- // Quick "is the daemon listening on :8008?" probe used by the homepage to
138
- // decide between showing a "Start" or an "Open" button on the local card.
139
- // Returns true on any 2xx/3xx/4xx response — we just want to know that
140
- // something is binding the port and answering HTTP.
141
- function isRunning(port = 8008) {
142
- return new Promise((resolve) => {
143
- const req = http.request(
144
- { host: "127.0.0.1", port, path: "/", method: "HEAD", timeout: 1500 },
145
- (res) => { res.resume(); resolve(true); }
146
- );
147
- req.on("error", () => resolve(false));
148
- req.on("timeout", () => { req.destroy(); resolve(false); });
149
- req.end();
150
- });
151
- }
152
-
153
- // ---- HTTP helpers ----
154
- function headCheck(url, { timeoutMs = 6000 } = {}) {
155
- return new Promise((resolve) => {
156
- const lib = url.startsWith("https:") ? https : http;
157
- const tryUrl = (u, hops = 0) => {
158
- if (hops > 5) return resolve(false);
159
- const req = lib.request(u, { method: "HEAD", timeout: timeoutMs }, (res) => {
160
- if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
161
- res.resume();
162
- return tryUrl(res.headers.location, hops + 1);
163
- }
164
- res.resume();
165
- // 200 = exists, 403/404 = not found / blocked. Some mirrors return 200 for HEAD even when GET would 404 — best effort.
166
- resolve(res.statusCode === 200);
167
- });
168
- req.on("error", () => resolve(false));
169
- req.on("timeout", () => { req.destroy(); resolve(false); });
170
- req.end();
171
- };
172
- tryUrl(url);
173
- });
174
- }
175
-
176
- function getJson(url, { timeoutMs = 10000, headers = {} } = {}) {
177
- return new Promise((resolve, reject) => {
178
- const lib = url.startsWith("https:") ? https : http;
179
- const req = lib.get(url, { timeout: timeoutMs, headers: { "User-Agent": "cicy-desktop", ...headers } }, (res) => {
180
- if ([301, 302, 307, 308].includes(res.statusCode)) {
181
- res.resume();
182
- return getJson(res.headers.location, { timeoutMs, headers }).then(resolve, reject);
183
- }
184
- if (res.statusCode !== 200) {
185
- res.resume();
186
- return reject(new Error(`HTTP ${res.statusCode} ${url}`));
187
- }
188
- const chunks = [];
189
- res.on("data", (c) => chunks.push(c));
190
- res.on("end", () => {
191
- try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
192
- catch (e) { reject(e); }
193
- });
194
- });
195
- req.on("error", reject);
196
- req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
197
- });
198
- }
199
-
200
- function downloadFile(url, destPath, { signal, onProgress, timeoutMs = 30000 } = {}) {
201
- return new Promise((resolve, reject) => {
202
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
203
- const tmp = destPath + ".part";
204
- let out;
205
- let aborted = false;
206
- let req;
207
-
208
- const cleanup = () => {
209
- if (out) try { out.close(); } catch {}
210
- try { fs.unlinkSync(tmp); } catch {}
211
- };
212
- const onAbort = () => {
213
- aborted = true;
214
- if (req) try { req.destroy(); } catch {}
215
- cleanup();
216
- reject(new Error("cancelled"));
217
- };
218
- if (signal) {
219
- if (signal.aborted) return onAbort();
220
- signal.addEventListener("abort", onAbort, { once: true });
221
- }
222
-
223
- const start = (currentUrl) => {
224
- const lib = currentUrl.startsWith("https:") ? https : http;
225
- req = lib.get(currentUrl, { timeout: timeoutMs }, (res) => {
226
- if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
227
- res.resume();
228
- return start(res.headers.location);
229
- }
230
- if (res.statusCode !== 200) {
231
- res.resume();
232
- cleanup();
233
- return reject(new Error(`HTTP ${res.statusCode} ${currentUrl}`));
234
- }
235
- const total = Number(res.headers["content-length"]) || 0;
236
- let received = 0;
237
- out = fs.createWriteStream(tmp);
238
- res.on("data", (chunk) => {
239
- if (aborted) return;
240
- received += chunk.length;
241
- if (onProgress) {
242
- try { onProgress({ received, total }); } catch {}
243
- }
244
- });
245
- res.pipe(out);
246
- out.on("finish", () => {
247
- if (aborted) return;
248
- out.close((closeErr) => {
249
- if (closeErr) return reject(closeErr);
250
- try {
251
- // On macOS/Linux the old binary may still be running.
252
- // rename() over a running binary replaces the dir entry but
253
- // the running process keeps the old inode — safe. However
254
- // some systems raise ETXTBSY. Unlinking the old file first
255
- // ensures we always get a fresh inode.
256
- try { fs.unlinkSync(destPath); } catch {}
257
- try {
258
- fs.renameSync(tmp, destPath);
259
- } catch {
260
- // Last resort: copy bytes (works even if rename fails across
261
- // filesystems or when the old file is still locked).
262
- fs.copyFileSync(tmp, destPath);
263
- try { fs.unlinkSync(tmp); } catch {}
264
- }
265
- resolve({ destPath, total });
266
- } catch (e) { reject(e); }
267
- });
268
- });
269
- out.on("error", (e) => { cleanup(); reject(e); });
270
- });
271
- req.on("error", (e) => { cleanup(); reject(e); });
272
- req.on("timeout", () => { req.destroy(); cleanup(); reject(new Error("timeout")); });
273
- };
274
- start(url);
275
- });
276
- }
277
-
278
- // ---- release lookup ----
279
- // `manifest.json` is published as a release asset by .github/workflows/release.yml.
280
- // GitHub's `releases/latest/download/<asset>` redirect always resolves to the
281
- // most recent release, so we get a stable URL for "the latest manifest" without
282
- // hitting api.github.com (which is blocked in CN).
283
- //
284
- // Each request is parallel-raced across direct + CN_MIRRORS so whichever path
285
- // is fastest wins. Short timeout so we don't hang the UI when a mirror dies.
286
- const MANIFEST_PATH = `${REPO}/releases/latest/download/manifest.json`;
287
- const MANIFEST_DIRECT = `https://github.com/${MANIFEST_PATH}`;
288
-
289
- async function fetchManifest() {
290
- const network = await netDetect.detect();
291
- const urls = buildUrlList(MANIFEST_DIRECT, network);
292
- return new Promise((resolve, reject) => {
293
- let done = 0;
294
- let lastErr;
295
- let resolved = false;
296
- urls.forEach(u => {
297
- getJson(u, { timeoutMs: 4000 }).then(j => {
298
- if (resolved) return;
299
- if (j && j.version && j.assets) {
300
- resolved = true;
301
- log.info(`[installer] manifest from ${u.startsWith("https://github.com/") ? "direct" : "mirror"} → v${j.version}`);
302
- resolve(j);
303
- } else if (++done === urls.length && !resolved) {
304
- reject(lastErr || new Error("manifest had no version field"));
305
- }
306
- }).catch(e => {
307
- lastErr = e;
308
- if (++done === urls.length && !resolved) reject(lastErr);
309
- });
310
- });
311
- });
312
- }
313
-
314
- async function fetchLatestReleaseSmart() {
315
- const archAlias = pickArchAlias();
316
- if (!archAlias) throw new Error(`unsupported arch ${process.arch}`);
317
- const plat = platformDir();
318
- const key = `${plat}-${archAlias}`;
319
- try {
320
- const m = await fetchManifest();
321
- const assetUrl = m.assets && m.assets[key];
322
- if (!assetUrl) throw new Error(`no asset for ${key} in manifest`);
323
-
324
- // The manifest can be uploaded before the binary asset on GitHub
325
- // Releases (Actions uploads files sequentially). Verify the binary
326
- // is actually reachable before declaring this version "available" —
327
- // otherwise an "update available" indicator points at a 404.
328
- const network = await netDetect.detect();
329
- const probeUrls = buildUrlList(assetUrl, network);
330
- const reachable = await new Promise((resolve) => {
331
- let pending = probeUrls.length;
332
- let done = false;
333
- probeUrls.forEach((u) =>
334
- headCheck(u, { timeoutMs: 5000 }).then((ok) => {
335
- if (ok && !done) { done = true; resolve(true); }
336
- if (--pending === 0 && !done) resolve(false);
337
- })
338
- );
339
- });
340
- if (!reachable) {
341
- log.warn(`[installer] manifest v${m.version} present but binary asset not reachable yet`);
342
- throw new Error(`RELEASE_NOT_READY:${m.version}`);
343
- }
344
- return {
345
- version: m.version,
346
- htmlUrl: `https://github.com/${REPO}/releases/tag/v${m.version}`,
347
- archAlias,
348
- assetUrl,
349
- sizeBytes: (m.sizes && m.sizes[key]) || null,
350
- };
351
- } catch (manifestErr) {
352
- // RELEASE_NOT_READY is a real signal — propagate to caller so the UI
353
- // can show "wait a few minutes" instead of fallback-probing.
354
- if (/^RELEASE_NOT_READY:/.test(manifestErr.message)) throw manifestErr;
355
- // Fallback path: older releases (before manifest.json existed) still use
356
- // jsdelivr tag list + HEAD probe. Will be removed once all live releases
357
- // ship a manifest.json.
358
- log.warn(`[installer] manifest fetch failed (${manifestErr.message}), falling back to jsdelivr probe`);
359
- return fetchLatestReleaseLegacy();
360
- }
361
- }
362
-
363
- const JSDELIVR_DATA_URL = `https://data.jsdelivr.com/v1/package/gh/${REPO}`;
364
- async function fetchLatestReleaseLegacy() {
365
- const meta = await getJson(JSDELIVR_DATA_URL, { timeoutMs: 5000 });
366
- const versions = meta.versions || [];
367
- if (versions.length === 0) throw new Error("no versions in jsdelivr response");
368
- const network = await netDetect.detect();
369
- const archAlias = pickArchAlias();
370
- const plat = platformDir();
371
- for (const version of versions.slice(0, 6)) {
372
- const directUrl = `https://github.com/${REPO}/releases/download/v${version}/cicy-code-${plat}-${archAlias}`;
373
- const probeOrder = buildUrlList(directUrl, network);
374
- log.info(`[installer] probing v${version} (legacy, network=${network})`);
375
- const found = await new Promise(resolve => {
376
- let done = 0;
377
- probeOrder.forEach(u => headCheck(u, { timeoutMs: 3000 }).then(ok => {
378
- if (ok && !done) resolve(u);
379
- if (++done === probeOrder.length) resolve(null);
380
- }));
381
- });
382
- if (found) return {
383
- version,
384
- htmlUrl: `https://github.com/${REPO}/releases/tag/v${version}`,
385
- archAlias,
386
- assetUrl: directUrl,
387
- sizeBytes: null,
388
- };
389
- }
390
- throw new Error(`no release binary found for ${plat}-${archAlias} in recent versions`);
391
- }
392
-
393
- function pickArchAlias() {
394
- const arch = archDir();
395
- if (!arch) return null;
396
- const aliases = ARCH_ALIASES[arch] || [arch];
397
- // First alias is the canonical one for our naming convention.
398
- // Prefer Go-style amd64 over x64 since release names use it.
399
- const preferred = arch === "x64" ? "amd64" : aliases[0];
400
- return preferred;
401
- }
402
-
403
- async function checkLatest() {
404
- try {
405
- const release = await fetchLatestReleaseSmart();
406
- if (!release.version) return { ok: false, error: "no version found" };
407
- const network = await netDetect.detect();
408
- return {
409
- ok: true,
410
- latest: release.version,
411
- assetName: `cicy-code-${platformDir()}-${release.archAlias}`,
412
- assetUrl: release.assetUrl,
413
- sizeBytes: release.sizeBytes, // populated when manifest.json available
414
- network,
415
- releaseUrl: release.htmlUrl,
416
- installedVersion: userVersion(),
417
- };
418
- } catch (e) {
419
- return { ok: false, error: e.message };
420
- }
421
- }
422
-
423
- // ---- parallel race download ----
424
- // Race a list of URLs in parallel — whichever responds first wins. Each
425
- // candidate writes to its own .partN temp file; on success, the winner is
426
- // atomically moved (or copied) to dest and losers are cancelled and cleaned up.
427
- //
428
- // emit: coarse phase events (per-attempt "downloading" announcement)
429
- // emitProgress: throttled byte-level progress updates
430
- // outerSignal: external cancel — checked after the race resolves
431
- async function raceDownload({ urls, dest, version, network, emit, emitProgress, outerSignal }) {
432
- fs.mkdirSync(path.dirname(dest), { recursive: true });
433
- let lastErr;
434
-
435
- await new Promise((resolve, reject) => {
436
- let settled = false;
437
- let pending = urls.length;
438
-
439
- const settle = (winner) => {
440
- if (settled) return;
441
- settled = true;
442
- resolve(winner);
443
- controllers.forEach(c => { try { c.abort(); } catch {} });
444
- };
445
- const fail = (err) => {
446
- lastErr = err;
447
- if (--pending === 0 && !settled) reject(lastErr);
448
- };
449
-
450
- const controllers = urls.map((url, i) => {
451
- const ctl = new AbortController();
452
- const tmpPath = dest + `.part${i}`;
453
- const { MIRRORS } = require("./mirrors");
454
- const isMirror = MIRRORS.some(m => url.startsWith(m.url));
455
- emit({ phase: "downloading", message: "并行下载中…", version, network, progress: 0 });
456
- downloadFile(url, tmpPath, {
457
- signal: ctl.signal,
458
- timeoutMs: 60000,
459
- onProgress: ({ received, total }) => {
460
- if (settled) return;
461
- const pct = total ? received / total : null;
462
- emitProgress({ phase: "downloading", message: `下载中 (${isMirror ? "镜像" : "直连"})`, progress: pct, version, network, received, total });
463
- },
464
- }).then(({ destPath }) => {
465
- if (settled) { try { fs.unlinkSync(destPath); } catch {} return; }
466
- try { fs.unlinkSync(dest); } catch {}
467
- try {
468
- fs.renameSync(destPath, dest);
469
- } catch {
470
- fs.copyFileSync(destPath, dest);
471
- try { fs.unlinkSync(destPath); } catch {}
472
- }
473
- settle(url);
474
- }).catch(err => {
475
- if (err.message !== "cancelled") fail(err);
476
- try { fs.unlinkSync(tmpPath); } catch {}
477
- });
478
- return ctl;
479
- });
480
- });
481
-
482
- if (outerSignal && outerSignal.aborted) throw new Error("cancelled");
483
- }
484
-
485
- // ---- install ----
486
- async function install({ onProgress } = {}) {
487
- if (inflight) throw new Error("install already in progress");
488
-
489
- const ac = new AbortController();
490
- inflight = { abort: () => ac.abort() };
491
-
492
- const emit = (event) => {
493
- lastProgress = event;
494
- if (onProgress) {
495
- try { onProgress(event); } catch {}
496
- }
497
- };
498
-
499
- // Throttled progress emitter — max 1 update per 80ms to prevent
500
- // rapid parallel-download events from causing UI flicker.
501
- let pendingProg = null;
502
- let progTimer = null;
503
- const emitProgress = (event) => {
504
- pendingProg = event;
505
- if (!progTimer) {
506
- progTimer = setTimeout(() => {
507
- progTimer = null;
508
- if (pendingProg) {
509
- emit(pendingProg);
510
- pendingProg = null;
511
- }
512
- }, 80);
513
- }
514
- };
515
-
516
- try {
517
- emit({ phase: "detecting", message: "检测网络…" });
518
- const network = await netDetect.detect();
519
-
520
- emit({ phase: "checking", message: "检查最新版本…", network });
521
- const check = await checkLatest();
522
- if (!check.ok) throw new Error(check.error);
523
-
524
- if (check.installedVersion === check.latest) {
525
- const bin = userBinary();
526
- const binaryExists = bin && bin !== "wsl:cicy-code" && fs.existsSync(bin);
527
- if (binaryExists) {
528
- const ev = { phase: "done", message: `已是最新版本 v${check.latest}`, version: check.latest, network, alreadyUpToDate: true };
529
- emit(ev);
530
- return ev;
531
- }
532
- // version file says up-to-date but binary is missing — re-download
533
- log.info(`[installer] version=${check.latest} but binary missing, re-downloading`);
534
- }
535
-
536
- // ── Windows: download on host (parallel race + progress + verify),
537
- // then hand the file to WSL. setupAll() also handles WSL/Ubuntu install
538
- // if needed, fully automatic with CN-aware mirrors. Network detection is
539
- // already done; we pass it through so wsl.installWsl picks --web-download
540
- // first when in CN (avoids slow Microsoft Store).
541
- if (process.platform === "win32") {
542
- const wsl = require("./wsl");
543
-
544
- // Stage to userData/cicy-code/wsl-stage. Path is accessible from WSL
545
- // via /mnt/c/... (translated by wslpath in installFromHostFile).
546
- const stageDir = path.join(app.getPath("userData"), "cicy-code", "wsl-stage");
547
- const stagePath = path.join(stageDir, "cicy-code-staged");
548
-
549
- emit({ phase: "downloading", message: `下载 cicy-code v${check.latest}…`, version: check.latest, network, progress: 0 });
550
- const order = buildUrlList(check.assetUrl, network);
551
- await raceDownload({
552
- urls: order,
553
- dest: stagePath,
554
- version: check.latest,
555
- network,
556
- emit,
557
- emitProgress,
558
- outerSignal: ac.signal,
559
- });
560
-
561
- // setupAll handles the rest: install WSL+Ubuntu if needed, then
562
- // copy the staged binary into the distro. Each step streams progress.
563
- const result = await wsl.setupAll({
564
- network,
565
- hostStagePath: stagePath,
566
- version: check.latest,
567
- onProgress: emit,
568
- });
569
- const installedVersion = result.version || check.latest;
570
- if (installedVersion !== check.latest) {
571
- log.warn(`[installer] WSL binary reports v${installedVersion}, expected v${check.latest} — mirror likely cached stale content`);
572
- }
573
-
574
- // Cache version on Windows side so userVersion() doesn't need WSL.
575
- try {
576
- const cacheDir = path.join(app.getPath("userData"), "cicy-code");
577
- fs.mkdirSync(cacheDir, { recursive: true });
578
- fs.writeFileSync(path.join(cacheDir, "wsl-version"), installedVersion, "utf8");
579
- } catch {}
580
-
581
- try { fs.unlinkSync(stagePath); } catch {}
582
-
583
- const final = { phase: "done", message: `已安装 v${installedVersion}`, version: installedVersion, network };
584
- emit(final);
585
- return final;
586
- }
587
-
588
- emit({ phase: "downloading", message: `下载 cicy-code v${check.latest}…`, version: check.latest, network, progress: 0 });
589
-
590
- const order = buildUrlList(check.assetUrl, network);
591
- // Download straight to ~/.local/bin/cicy-code-<assumed version>. We re-
592
- // verify the version after by execing --version; if the mirror served a
593
- // stale binary the file may end up renamed.
594
- let dest = versionedBinaryPath(check.latest);
595
- if (!dest) throw new Error(`unsupported platform ${process.platform}-${process.arch}`);
596
-
597
- await raceDownload({
598
- urls: order,
599
- dest,
600
- version: check.latest,
601
- network,
602
- emit,
603
- emitProgress,
604
- outerSignal: ac.signal,
605
- });
606
-
607
- emit({ phase: "installing", message: "正在安装…", version: check.latest, network, progress: 1 });
608
-
609
- try { fs.chmodSync(dest, 0o755); } catch (e) { log.warn(`[installer] chmod failed: ${e.message}`); }
610
-
611
- // Verify the downloaded binary reports the expected version. Mirrors
612
- // sometimes serve stale cached content. If `--version` disagrees, rename
613
- // the file onto the real version so the symlink we point at is honest.
614
- let installedVersion = check.latest;
615
- try {
616
- const { execFileSync } = require("child_process");
617
- const out = execFileSync(dest, ["--version"], { timeout: 5000, encoding: "utf8" }).trim();
618
- const m = out.match(/(\d+\.\d+\.\d+)/);
619
- if (m && m[1] !== check.latest) {
620
- log.warn(`[installer] downloaded binary reports v${m[1]}, expected v${check.latest} — mirror likely cached stale content`);
621
- const realDest = versionedBinaryPath(m[1]);
622
- if (realDest && realDest !== dest) {
623
- try { fs.unlinkSync(realDest); } catch {}
624
- fs.renameSync(dest, realDest);
625
- dest = realDest;
626
- }
627
- installedVersion = m[1];
628
- }
629
- } catch (e) {
630
- log.warn(`[installer] version verify failed: ${e.message}`);
631
- }
632
-
633
- // Atomic-replace the `cicy-code` symlink to point at the new versioned
634
- // binary. Write the new link at a tmp name + rename = atomic on POSIX,
635
- // so a concurrent reader either sees the old version or the new one,
636
- // never a missing file.
637
- const link = userBinary(); // ~/.local/bin/cicy-code
638
- const linkDir = path.dirname(link);
639
- fs.mkdirSync(linkDir, { recursive: true });
640
- const tmpLink = `${link}.new-${process.pid}-${Date.now()}`;
641
- try { fs.unlinkSync(tmpLink); } catch {}
642
- fs.symlinkSync(path.basename(dest), tmpLink); // relative → cicy-code-<ver>
643
- fs.renameSync(tmpLink, link);
644
-
645
- log.info(`[installer] linked ~/.local/bin/cicy-code → cicy-code-${installedVersion}`);
646
- const final = { phase: "done", message: `已安装 v${installedVersion}`, version: installedVersion, network };
647
- emit(final);
648
- return final;
649
- } catch (e) {
650
- const ev = e.message === "cancelled"
651
- ? { phase: "cancelled", message: "已取消" }
652
- : { phase: "error", message: `失败: ${e.message}`, error: e.message };
653
- emit(ev);
654
- throw e;
655
- } finally {
656
- inflight = null;
657
- }
658
- }
659
-
660
- function cancel() {
661
- if (inflight) inflight.abort();
662
- }
663
-
664
- module.exports = {
665
- getStatus,
666
- isRunning,
667
- checkLatest,
668
- install,
669
- cancel,
670
- userBinary,
671
- userVersion,
672
- };