deepseek-tui 0.8.10 → 0.8.12

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/scripts/install.js +749 -31
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "deepseek-tui",
3
- "version": "0.8.10",
4
- "deepseekBinaryVersion": "0.8.10",
3
+ "version": "0.8.12",
4
+ "deepseekBinaryVersion": "0.8.12",
5
5
  "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.",
6
6
  "author": "Hmbown",
7
7
  "license": "MIT",
@@ -1,10 +1,12 @@
1
1
  const fs = require("fs");
2
2
  const https = require("https");
3
3
  const http = require("http");
4
+ const net = require("net");
5
+ const tls = require("tls");
4
6
  const crypto = require("crypto");
7
+ const { URL } = require("url");
5
8
  const { mkdir, chmod, stat, rename, readFile, unlink, writeFile } = fs.promises;
6
9
  const { createWriteStream } = fs;
7
- const { pipeline } = require("stream/promises");
8
10
  const path = require("path");
9
11
 
10
12
  const {
@@ -16,6 +18,46 @@ const {
16
18
  const { preflightGlibc } = require("./preflight-glibc");
17
19
  const pkg = require("../package.json");
18
20
 
21
+ const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes per attempt
22
+ const DEFAULT_STALL_MS = 30_000; // abort if no bytes for 30s
23
+ const MAX_ATTEMPTS = 5;
24
+ const BASE_BACKOFF_MS = 1_000;
25
+
26
+ const RETRYABLE_NET_CODES = new Set([
27
+ "ECONNRESET",
28
+ "ECONNREFUSED",
29
+ "ETIMEDOUT",
30
+ "EAI_AGAIN",
31
+ "ENETUNREACH",
32
+ "EHOSTUNREACH",
33
+ "EPIPE",
34
+ "ECONNABORTED",
35
+ ]);
36
+
37
+ class NonRetryableError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = "NonRetryableError";
41
+ this.nonRetryable = true;
42
+ }
43
+ }
44
+
45
+ class HttpStatusError extends Error {
46
+ constructor(status, url) {
47
+ super(`Request failed with status ${status}: ${url}`);
48
+ this.name = "HttpStatusError";
49
+ this.status = status;
50
+ }
51
+ }
52
+
53
+ class DownloadTimeoutError extends Error {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = "DownloadTimeoutError";
57
+ this.code = "EDOWNLOADTIMEOUT";
58
+ }
59
+ }
60
+
19
61
  function resolvePackageVersion() {
20
62
  const configuredVersion =
21
63
  process.env.DEEPSEEK_TUI_VERSION ||
@@ -44,45 +86,719 @@ function binaryPaths() {
44
86
  };
45
87
  }
46
88
 
47
- async function httpGet(url) {
48
- const client = url.startsWith("https:") ? https : http;
49
- const response = await new Promise((resolve, reject) => {
50
- client.get(url, (res) => {
51
- const status = res.statusCode || 0;
52
- if (status >= 300 && status < 400 && res.headers.location) {
53
- resolve({ redirect: res.headers.location, response: null });
89
+ // ────────────────────────────────────────────────────────────────────────────
90
+ // Logging / progress
91
+ // ────────────────────────────────────────────────────────────────────────────
92
+
93
+ function isQuietInstall() {
94
+ if (process.env.DEEPSEEK_TUI_QUIET_INSTALL === "1") {
95
+ return true;
96
+ }
97
+ const level = (process.env.npm_config_loglevel || "").toLowerCase();
98
+ return level === "silent" || level === "error";
99
+ }
100
+
101
+ function logInfo(message) {
102
+ if (isQuietInstall()) {
103
+ return;
104
+ }
105
+ process.stderr.write(`deepseek-tui: ${message}\n`);
106
+ }
107
+
108
+ function envInt(name, fallback) {
109
+ const raw = process.env[name];
110
+ if (!raw) {
111
+ return fallback;
112
+ }
113
+ const parsed = Number.parseInt(String(raw).trim(), 10);
114
+ if (!Number.isFinite(parsed) || parsed <= 0) {
115
+ return fallback;
116
+ }
117
+ return parsed;
118
+ }
119
+
120
+ function downloadTimeoutMs() {
121
+ return envInt(
122
+ "DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS",
123
+ envInt("DEEPSEEK_DOWNLOAD_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
124
+ );
125
+ }
126
+
127
+ function downloadStallMs() {
128
+ return envInt(
129
+ "DEEPSEEK_TUI_DOWNLOAD_STALL_MS",
130
+ envInt("DEEPSEEK_DOWNLOAD_STALL_MS", DEFAULT_STALL_MS),
131
+ );
132
+ }
133
+
134
+ function formatMb(bytes) {
135
+ return (bytes / (1024 * 1024)).toFixed(0);
136
+ }
137
+
138
+ function createProgressReporter(assetName, totalBytes) {
139
+ if (isQuietInstall()) {
140
+ return { onChunk: () => {}, finish: () => {} };
141
+ }
142
+ const isTty = !!process.stderr.isTTY;
143
+ const interactive = isTty;
144
+ const tickBytes = interactive ? 1 * 1024 * 1024 : 5 * 1024 * 1024;
145
+ const tickMs = 2_000;
146
+
147
+ let received = 0;
148
+ let lastBytesPrinted = 0;
149
+ let lastTimePrinted = 0;
150
+ let everPrinted = false;
151
+
152
+ const render = (final) => {
153
+ if (totalBytes && totalBytes > 0) {
154
+ const pct = Math.min(100, Math.round((received / totalBytes) * 100));
155
+ const line = `deepseek-tui: downloading ${assetName}: ${formatMb(received)} / ${formatMb(totalBytes)} MB (${pct}%)`;
156
+ if (interactive) {
157
+ process.stderr.write(`${line}\r`);
158
+ } else {
159
+ process.stderr.write(`${line}\n`);
160
+ }
161
+ } else {
162
+ const line = `deepseek-tui: downloading ${assetName}: ${formatMb(received)} MB downloaded`;
163
+ if (interactive) {
164
+ process.stderr.write(`${line}\r`);
165
+ } else {
166
+ process.stderr.write(`${line}\n`);
167
+ }
168
+ }
169
+ everPrinted = true;
170
+ lastBytesPrinted = received;
171
+ lastTimePrinted = Date.now();
172
+ };
173
+
174
+ return {
175
+ onChunk(chunkLen) {
176
+ received += chunkLen;
177
+ const now = Date.now();
178
+ if (
179
+ received - lastBytesPrinted >= tickBytes ||
180
+ (interactive && now - lastTimePrinted >= tickMs)
181
+ ) {
182
+ render(false);
183
+ }
184
+ },
185
+ finish() {
186
+ // Final line — always render once.
187
+ render(true);
188
+ if (interactive && everPrinted) {
189
+ // Move past the carriage-return line and emit a "done" footer.
190
+ process.stderr.write("\n");
191
+ }
192
+ process.stderr.write(`deepseek-tui: ${assetName} ... done.\n`);
193
+ },
194
+ };
195
+ }
196
+
197
+ // ────────────────────────────────────────────────────────────────────────────
198
+ // Proxy support (HTTPS_PROXY / HTTP_PROXY / NO_PROXY) — pure Node, CONNECT
199
+ // tunnel + TLS upgrade for HTTPS targets.
200
+ // ────────────────────────────────────────────────────────────────────────────
201
+
202
+ function getProxyUrl(targetUrl) {
203
+ const isHttps = targetUrl.protocol === "https:";
204
+ const candidates = isHttps
205
+ ? ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]
206
+ : ["HTTP_PROXY", "http_proxy"];
207
+ for (const name of candidates) {
208
+ const raw = process.env[name];
209
+ if (raw && String(raw).trim() !== "") {
210
+ return String(raw).trim();
211
+ }
212
+ }
213
+ return null;
214
+ }
215
+
216
+ function shouldBypassProxy(host) {
217
+ const raw = process.env.NO_PROXY || process.env.no_proxy;
218
+ if (!raw) {
219
+ return false;
220
+ }
221
+ const lower = String(host).toLowerCase();
222
+ for (const part of String(raw).split(",")) {
223
+ const entry = part.trim().toLowerCase();
224
+ if (!entry) {
225
+ continue;
226
+ }
227
+ if (entry === "*") {
228
+ return true;
229
+ }
230
+ // Strip leading dot and any explicit port.
231
+ const stripped = entry.replace(/^\./, "").replace(/:.*$/, "");
232
+ if (!stripped) {
233
+ continue;
234
+ }
235
+ if (lower === stripped || lower.endsWith(`.${stripped}`)) {
236
+ return true;
237
+ }
238
+ }
239
+ return false;
240
+ }
241
+
242
+ function parseProxy(proxyStr) {
243
+ // Accept "http://user:pass@host:port" and bare "host:port".
244
+ const normalized = /^[a-z][a-z0-9+\-.]*:\/\//i.test(proxyStr)
245
+ ? proxyStr
246
+ : `http://${proxyStr}`;
247
+ const u = new URL(normalized);
248
+ const port = u.port
249
+ ? Number.parseInt(u.port, 10)
250
+ : u.protocol === "https:"
251
+ ? 443
252
+ : 80;
253
+ let auth = null;
254
+ if (u.username) {
255
+ const user = decodeURIComponent(u.username);
256
+ const pass = u.password ? decodeURIComponent(u.password) : "";
257
+ auth = Buffer.from(`${user}:${pass}`).toString("base64");
258
+ }
259
+ return {
260
+ protocol: u.protocol,
261
+ host: u.hostname,
262
+ port,
263
+ auth,
264
+ raw: proxyStr,
265
+ };
266
+ }
267
+
268
+ function connectThroughProxy(proxy, targetHost, targetPort, timeoutMs) {
269
+ return new Promise((resolve, reject) => {
270
+ const socket = net.connect({ host: proxy.host, port: proxy.port });
271
+ let settled = false;
272
+ const fail = (err) => {
273
+ if (settled) return;
274
+ settled = true;
275
+ try {
276
+ socket.destroy();
277
+ } catch {
278
+ // ignore
279
+ }
280
+ reject(err);
281
+ };
282
+
283
+ const timer = timeoutMs > 0
284
+ ? setTimeout(() => fail(new DownloadTimeoutError(
285
+ `proxy CONNECT to ${proxy.host}:${proxy.port} timed out after ${timeoutMs} ms`,
286
+ )), timeoutMs)
287
+ : null;
288
+
289
+ socket.once("error", (err) => {
290
+ if (timer) clearTimeout(timer);
291
+ // Surface proxy host so the user can fix it.
292
+ const wrapped = new Error(
293
+ `proxy connection failed (${proxy.host}:${proxy.port}): ${err.message}`,
294
+ );
295
+ wrapped.code = err.code;
296
+ fail(wrapped);
297
+ });
298
+
299
+ socket.once("connect", () => {
300
+ const lines = [
301
+ `CONNECT ${targetHost}:${targetPort} HTTP/1.1`,
302
+ `Host: ${targetHost}:${targetPort}`,
303
+ "User-Agent: deepseek-tui-installer",
304
+ "Proxy-Connection: keep-alive",
305
+ ];
306
+ if (proxy.auth) {
307
+ lines.push(`Proxy-Authorization: Basic ${proxy.auth}`);
308
+ }
309
+ const req = `${lines.join("\r\n")}\r\n\r\n`;
310
+
311
+ let buf = Buffer.alloc(0);
312
+ const onData = (chunk) => {
313
+ buf = Buffer.concat([buf, chunk]);
314
+ const idx = buf.indexOf("\r\n\r\n");
315
+ if (idx === -1) {
316
+ if (buf.length > 16 * 1024) {
317
+ socket.removeListener("data", onData);
318
+ fail(new Error(
319
+ `proxy ${proxy.host}:${proxy.port} returned an oversized response header`,
320
+ ));
321
+ }
322
+ return;
323
+ }
324
+ socket.removeListener("data", onData);
325
+ const head = buf.slice(0, idx).toString("utf8");
326
+ const firstLine = head.split(/\r?\n/, 1)[0] || "";
327
+ const m = firstLine.match(/^HTTP\/\d\.\d\s+(\d{3})/);
328
+ if (!m) {
329
+ fail(new Error(`proxy ${proxy.host}:${proxy.port} returned invalid CONNECT reply: ${firstLine}`));
330
+ return;
331
+ }
332
+ const code = Number.parseInt(m[1], 10);
333
+ if (code !== 200) {
334
+ fail(new Error(
335
+ `proxy ${proxy.host}:${proxy.port} refused CONNECT to ${targetHost}:${targetPort}: HTTP ${code}`,
336
+ ));
337
+ return;
338
+ }
339
+ if (timer) clearTimeout(timer);
340
+ if (settled) return;
341
+ settled = true;
342
+ // Any bytes past the header belong to the tunneled stream — but in
343
+ // practice CONNECT 200 has no body; if it did, we'd lose those bytes
344
+ // here. Keep it simple: trust well-behaved proxies.
345
+ resolve(socket);
346
+ };
347
+ socket.on("data", onData);
348
+ socket.write(req, "utf8");
349
+ });
350
+ });
351
+ }
352
+
353
+ // ────────────────────────────────────────────────────────────────────────────
354
+ // HTTP request with timeout, stall detection, and proxy support.
355
+ // ────────────────────────────────────────────────────────────────────────────
356
+
357
+ function httpRequest(rawUrl, opts = {}) {
358
+ const totalTimeoutMs = opts.totalTimeoutMs ?? downloadTimeoutMs();
359
+ const stallMs = opts.stallMs ?? downloadStallMs();
360
+
361
+ return new Promise((resolve, reject) => {
362
+ let url;
363
+ try {
364
+ url = new URL(rawUrl);
365
+ } catch (err) {
366
+ reject(new NonRetryableError(`Invalid URL: ${rawUrl} (${err.message})`));
367
+ return;
368
+ }
369
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
370
+ reject(new NonRetryableError(`Unsupported protocol: ${url.protocol}`));
371
+ return;
372
+ }
373
+
374
+ const proxyStr = !shouldBypassProxy(url.hostname) ? getProxyUrl(url) : null;
375
+ const isHttps = url.protocol === "https:";
376
+ const port = url.port
377
+ ? Number.parseInt(url.port, 10)
378
+ : isHttps
379
+ ? 443
380
+ : 80;
381
+
382
+ let totalTimer = null;
383
+ let stallTimer = null;
384
+ let settled = false;
385
+ let req = null;
386
+ let res = null;
387
+
388
+ const cleanup = () => {
389
+ if (totalTimer) {
390
+ clearTimeout(totalTimer);
391
+ totalTimer = null;
392
+ }
393
+ if (stallTimer) {
394
+ clearTimeout(stallTimer);
395
+ stallTimer = null;
396
+ }
397
+ };
398
+
399
+ const fail = (err) => {
400
+ if (settled) return;
401
+ settled = true;
402
+ cleanup();
403
+ try {
404
+ if (req && !req.destroyed) req.destroy();
405
+ } catch {
406
+ // ignore
407
+ }
408
+ try {
409
+ if (res && !res.destroyed) res.destroy();
410
+ } catch {
411
+ // ignore
412
+ }
413
+ reject(err);
414
+ };
415
+
416
+ if (totalTimeoutMs > 0) {
417
+ totalTimer = setTimeout(() => {
418
+ fail(new DownloadTimeoutError(
419
+ `download exceeded total timeout of ${totalTimeoutMs} ms ` +
420
+ `(set DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS to raise it; current stall budget is ${stallMs} ms)`,
421
+ ));
422
+ }, totalTimeoutMs);
423
+ }
424
+
425
+ const armStallTimer = () => {
426
+ if (stallMs <= 0) return;
427
+ if (stallTimer) clearTimeout(stallTimer);
428
+ stallTimer = setTimeout(() => {
429
+ fail(new DownloadTimeoutError(
430
+ `download stalled — no bytes received for ${stallMs} ms ` +
431
+ `(set DEEPSEEK_TUI_DOWNLOAD_STALL_MS to raise it; total budget is ${totalTimeoutMs} ms)`,
432
+ ));
433
+ }, stallMs);
434
+ };
435
+
436
+ const launch = (socket) => {
437
+ const reqOptions = {
438
+ method: "GET",
439
+ host: url.hostname,
440
+ port,
441
+ path: `${url.pathname}${url.search || ""}`,
442
+ headers: {
443
+ Host: url.host,
444
+ "User-Agent": "deepseek-tui-installer",
445
+ Accept: "*/*",
446
+ Connection: "close",
447
+ },
448
+ };
449
+ if (socket) {
450
+ reqOptions.createConnection = () => socket;
451
+ if (isHttps) {
452
+ // Wrap raw TCP socket from CONNECT in TLS.
453
+ const tlsSocket = tls.connect({
454
+ socket,
455
+ servername: url.hostname,
456
+ ALPNProtocols: ["http/1.1"],
457
+ });
458
+ tlsSocket.once("error", (err) => fail(err));
459
+ reqOptions.createConnection = () => tlsSocket;
460
+ }
461
+ }
462
+ const client = isHttps ? https : http;
463
+ try {
464
+ req = client.request(reqOptions, (response) => {
465
+ res = response;
466
+ armStallTimer();
467
+ response.on("data", () => {
468
+ armStallTimer();
469
+ });
470
+ response.on("end", () => {
471
+ cleanup();
472
+ });
473
+ response.on("error", (err) => fail(err));
474
+
475
+ const status = response.statusCode || 0;
476
+ if (status >= 300 && status < 400 && response.headers.location) {
477
+ cleanup();
478
+ settled = true;
479
+ response.resume();
480
+ resolve({ redirect: response.headers.location, response: null });
481
+ return;
482
+ }
483
+ if (status < 200 || status >= 300) {
484
+ const err = new HttpStatusError(status, rawUrl);
485
+ // 4xx: non-retryable; 5xx: retryable.
486
+ if (status >= 400 && status < 500) {
487
+ err.nonRetryable = true;
488
+ }
489
+ fail(err);
490
+ return;
491
+ }
492
+ if (settled) return;
493
+ settled = true;
494
+ // Hand the live response stream to the caller.
495
+ resolve({ redirect: null, response });
496
+ });
497
+ req.once("error", (err) => fail(err));
498
+ req.once("socket", (s) => {
499
+ // Belt-and-suspenders: surface socket-level errors quickly.
500
+ s.once("error", (err) => fail(err));
501
+ });
502
+ req.end();
503
+ } catch (err) {
504
+ fail(err);
505
+ }
506
+ };
507
+
508
+ if (proxyStr) {
509
+ let proxy;
510
+ try {
511
+ proxy = parseProxy(proxyStr);
512
+ } catch (err) {
513
+ fail(new NonRetryableError(
514
+ `Invalid proxy URL "${proxyStr}": ${err.message}`,
515
+ ));
54
516
  return;
55
517
  }
56
- if (status !== 200) {
57
- reject(new Error(`Request failed with status ${status}: ${url}`));
518
+ if (!isHttps) {
519
+ // Plain HTTP through proxy send absolute URI, no CONNECT.
520
+ const client = http;
521
+ try {
522
+ req = client.request(
523
+ {
524
+ host: proxy.host,
525
+ port: proxy.port,
526
+ method: "GET",
527
+ path: rawUrl,
528
+ headers: {
529
+ Host: url.host,
530
+ "User-Agent": "deepseek-tui-installer",
531
+ Accept: "*/*",
532
+ Connection: "close",
533
+ ...(proxy.auth ? { "Proxy-Authorization": `Basic ${proxy.auth}` } : {}),
534
+ },
535
+ },
536
+ (response) => {
537
+ res = response;
538
+ armStallTimer();
539
+ response.on("data", () => armStallTimer());
540
+ response.on("end", () => cleanup());
541
+ response.on("error", (err) => fail(err));
542
+ const status = response.statusCode || 0;
543
+ if (status >= 300 && status < 400 && response.headers.location) {
544
+ cleanup();
545
+ settled = true;
546
+ response.resume();
547
+ resolve({ redirect: response.headers.location, response: null });
548
+ return;
549
+ }
550
+ if (status < 200 || status >= 300) {
551
+ const err = new HttpStatusError(status, rawUrl);
552
+ if (status >= 400 && status < 500) err.nonRetryable = true;
553
+ fail(err);
554
+ return;
555
+ }
556
+ if (settled) return;
557
+ settled = true;
558
+ resolve({ redirect: null, response });
559
+ },
560
+ );
561
+ req.once("error", (err) => fail(err));
562
+ req.end();
563
+ } catch (err) {
564
+ fail(err);
565
+ }
58
566
  return;
59
567
  }
60
- resolve({ redirect: null, response: res });
61
- }).on("error", reject);
568
+
569
+ // HTTPS through proxy: CONNECT tunnel + TLS upgrade.
570
+ connectThroughProxy(proxy, url.hostname, port, Math.max(stallMs, 5_000))
571
+ .then((tcpSocket) => {
572
+ if (settled) {
573
+ try { tcpSocket.destroy(); } catch { /* ignore */ }
574
+ return;
575
+ }
576
+ const tlsSocket = tls.connect({
577
+ socket: tcpSocket,
578
+ servername: url.hostname,
579
+ ALPNProtocols: ["http/1.1"],
580
+ });
581
+ tlsSocket.once("error", (err) => fail(err));
582
+ tlsSocket.once("secureConnect", () => {
583
+ if (settled) {
584
+ try { tlsSocket.destroy(); } catch { /* ignore */ }
585
+ return;
586
+ }
587
+ const reqOptions = {
588
+ method: "GET",
589
+ createConnection: () => tlsSocket,
590
+ path: `${url.pathname}${url.search || ""}`,
591
+ headers: {
592
+ Host: url.host,
593
+ "User-Agent": "deepseek-tui-installer",
594
+ Accept: "*/*",
595
+ Connection: "close",
596
+ },
597
+ };
598
+ try {
599
+ req = https.request(reqOptions, (response) => {
600
+ res = response;
601
+ armStallTimer();
602
+ response.on("data", () => armStallTimer());
603
+ response.on("end", () => cleanup());
604
+ response.on("error", (err) => fail(err));
605
+ const status = response.statusCode || 0;
606
+ if (status >= 300 && status < 400 && response.headers.location) {
607
+ cleanup();
608
+ settled = true;
609
+ response.resume();
610
+ resolve({ redirect: response.headers.location, response: null });
611
+ return;
612
+ }
613
+ if (status < 200 || status >= 300) {
614
+ const err = new HttpStatusError(status, rawUrl);
615
+ if (status >= 400 && status < 500) err.nonRetryable = true;
616
+ fail(err);
617
+ return;
618
+ }
619
+ if (settled) return;
620
+ settled = true;
621
+ resolve({ redirect: null, response });
622
+ });
623
+ req.once("error", (err) => fail(err));
624
+ req.end();
625
+ } catch (err) {
626
+ fail(err);
627
+ }
628
+ });
629
+ })
630
+ .catch((err) => fail(err));
631
+ return;
632
+ }
633
+
634
+ // No proxy — direct connection.
635
+ launch(null);
62
636
  });
63
- return response;
64
637
  }
65
638
 
66
- async function download(url, destination) {
67
- const resolved = await httpGet(url);
68
- if (resolved.redirect) {
69
- return download(resolved.redirect, destination);
639
+ // ────────────────────────────────────────────────────────────────────────────
640
+ // Retry wrapper
641
+ // ────────────────────────────────────────────────────────────────────────────
642
+
643
+ function isRetryable(err) {
644
+ if (!err) return false;
645
+ if (err.nonRetryable) return false;
646
+ if (err instanceof NonRetryableError) return false;
647
+ if (err instanceof DownloadTimeoutError) return true;
648
+ if (err instanceof HttpStatusError) {
649
+ return err.status >= 500;
70
650
  }
71
- await mkdir(path.dirname(destination), { recursive: true });
72
- await pipeline(resolved.response, createWriteStream(destination));
651
+ if (err.code && RETRYABLE_NET_CODES.has(err.code)) return true;
652
+ // Network-flavored messages we may see without a code.
653
+ const msg = String(err.message || "").toLowerCase();
654
+ if (msg.includes("network") && msg.includes("unreachable")) return true;
655
+ if (msg.includes("socket hang up")) return true;
656
+ if (msg.includes("aborted")) return true;
657
+ return false;
73
658
  }
74
659
 
75
- async function downloadText(url) {
76
- const resolved = await httpGet(url);
77
- if (resolved.redirect) {
78
- return downloadText(resolved.redirect);
660
+ function backoffDelay(attempt) {
661
+ // attempt is 1-indexed; first retry waits ~1s.
662
+ const base = BASE_BACKOFF_MS * 2 ** (attempt - 1);
663
+ const jitter = (Math.random() * 0.4 - 0.2) * base; // ±20%
664
+ return Math.max(0, Math.round(base + jitter));
665
+ }
666
+
667
+ function sleep(ms) {
668
+ return new Promise((resolve) => setTimeout(resolve, ms));
669
+ }
670
+
671
+ async function withRetry(label, fn) {
672
+ let lastErr;
673
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
674
+ try {
675
+ return await fn(attempt);
676
+ } catch (err) {
677
+ lastErr = err;
678
+ if (!isRetryable(err) || attempt === MAX_ATTEMPTS) {
679
+ break;
680
+ }
681
+ const wait = backoffDelay(attempt);
682
+ logInfo(
683
+ `${label} failed (attempt ${attempt}/${MAX_ATTEMPTS}): ${err.message}; retrying in ${wait} ms`,
684
+ );
685
+ await sleep(wait);
686
+ }
79
687
  }
80
- const chunks = [];
81
- resolved.response.setEncoding("utf8");
82
- for await (const chunk of resolved.response) {
83
- chunks.push(chunk);
688
+ const msg = lastErr && lastErr.message ? lastErr.message : String(lastErr);
689
+ const wrapped = new Error(
690
+ `${label} failed after ${MAX_ATTEMPTS} attempt(s): ${msg}`,
691
+ );
692
+ if (lastErr && lastErr.stack) {
693
+ wrapped.cause = lastErr;
694
+ }
695
+ throw wrapped;
696
+ }
697
+
698
+ // ────────────────────────────────────────────────────────────────────────────
699
+ // Public download primitives (now retry + progress aware)
700
+ // ────────────────────────────────────────────────────────────────────────────
701
+
702
+ async function followRedirects(url, opts) {
703
+ const maxRedirects = 10;
704
+ let current = url;
705
+ for (let hop = 0; hop < maxRedirects; hop++) {
706
+ const result = await httpRequest(current, opts);
707
+ if (result.redirect) {
708
+ try {
709
+ current = new URL(result.redirect, current).toString();
710
+ } catch {
711
+ current = result.redirect;
712
+ }
713
+ continue;
714
+ }
715
+ return result;
84
716
  }
85
- return chunks.join("");
717
+ throw new NonRetryableError(`too many redirects starting at ${url}`);
718
+ }
719
+
720
+ function streamToFile(response, destination, progress) {
721
+ return new Promise((resolve, reject) => {
722
+ const sink = createWriteStream(destination);
723
+ let done = false;
724
+ const finish = (err) => {
725
+ if (done) return;
726
+ done = true;
727
+ if (err) {
728
+ sink.destroy();
729
+ reject(err);
730
+ } else {
731
+ resolve();
732
+ }
733
+ };
734
+ response.on("data", (chunk) => {
735
+ if (progress) progress.onChunk(chunk.length);
736
+ });
737
+ response.on("error", (err) => finish(err));
738
+ sink.on("error", (err) => finish(err));
739
+ sink.on("finish", () => finish(null));
740
+ response.pipe(sink);
741
+ });
742
+ }
743
+
744
+ async function download(url, destination, options = {}) {
745
+ await mkdir(path.dirname(destination), { recursive: true });
746
+ const assetName = options.assetName || path.basename(destination);
747
+ await withRetry(`download ${assetName}`, async (attempt) => {
748
+ const result = await followRedirects(url, {
749
+ totalTimeoutMs: downloadTimeoutMs(),
750
+ stallMs: downloadStallMs(),
751
+ });
752
+ const response = result.response;
753
+ const lenHeader = response.headers["content-length"];
754
+ const total = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
755
+ const progress = createProgressReporter(assetName, Number.isFinite(total) ? total : 0);
756
+ if (attempt > 1) {
757
+ logInfo(`retry attempt ${attempt}/${MAX_ATTEMPTS} for ${assetName}`);
758
+ }
759
+ try {
760
+ await streamToFile(response, destination, progress);
761
+ } catch (err) {
762
+ // Ensure we don't leave a partial file confusing future attempts.
763
+ try {
764
+ await unlink(destination);
765
+ } catch {
766
+ // ignore
767
+ }
768
+ throw err;
769
+ }
770
+ progress.finish();
771
+ });
772
+ }
773
+
774
+ async function downloadText(url) {
775
+ return withRetry(`fetch ${url}`, async () => {
776
+ const result = await followRedirects(url, {
777
+ totalTimeoutMs: downloadTimeoutMs(),
778
+ stallMs: downloadStallMs(),
779
+ });
780
+ const response = result.response;
781
+ response.setEncoding("utf8");
782
+ // NOTE: do NOT use `for await (const chunk of response)` here.
783
+ // `httpRequest` attaches a `data` listener on the response to re-arm
784
+ // the stall timer, which puts the stream in flowing mode. The async
785
+ // iterator expects paused mode and will silently miss every chunk —
786
+ // this manifested as an empty checksum manifest in the npm wrapper
787
+ // smoke test ("Checksum manifest is missing <asset>"). Subscribing
788
+ // to `data` events directly stacks alongside the stall listener and
789
+ // both fire per chunk, so we collect the body correctly without
790
+ // disturbing the stall detection.
791
+ return new Promise((resolve, reject) => {
792
+ const chunks = [];
793
+ response.on("data", (chunk) => {
794
+ chunks.push(chunk);
795
+ });
796
+ response.on("end", () => {
797
+ resolve(chunks.join(""));
798
+ });
799
+ response.on("error", reject);
800
+ });
801
+ });
86
802
  }
87
803
 
88
804
  async function readLocalVersion(file) {
@@ -122,11 +838,13 @@ async function sha256File(filePath) {
122
838
  async function verifyChecksum(filePath, assetName, checksums) {
123
839
  const expected = checksums.get(assetName);
124
840
  if (!expected) {
125
- throw new Error(`Checksum manifest is missing ${assetName}`);
841
+ throw new NonRetryableError(`Checksum manifest is missing ${assetName}`);
126
842
  }
127
843
  const actual = await sha256File(filePath);
128
844
  if (actual !== expected) {
129
- throw new Error(
845
+ // Bytes are corrupted; another fetch is unlikely to help without a fix
846
+ // upstream. Mark non-retryable.
847
+ throw new NonRetryableError(
130
848
  `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
131
849
  );
132
850
  }
@@ -152,7 +870,7 @@ async function ensureBinary(targetPath, assetName, version, repo, getChecksums)
152
870
  const checksums = await getChecksums();
153
871
  const url = releaseAssetUrl(assetName, version, repo);
154
872
  const destination = `${targetPath}.${process.pid}.${Date.now()}.download`;
155
- await download(url, destination);
873
+ await download(url, destination, { assetName });
156
874
  try {
157
875
  await verifyChecksum(destination, assetName, checksums);
158
876
  preflightGlibc(destination);