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.
- package/package.json +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.
|
|
4
|
-
"deepseekBinaryVersion": "0.8.
|
|
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",
|
package/scripts/install.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 (
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
841
|
+
throw new NonRetryableError(`Checksum manifest is missing ${assetName}`);
|
|
126
842
|
}
|
|
127
843
|
const actual = await sha256File(filePath);
|
|
128
844
|
if (actual !== expected) {
|
|
129
|
-
|
|
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);
|