返回 DeepSeek-TUI-2026
install.js
根目录 / npm / deepseek-tui / scripts / install.js
1 const fs = require("fs");
2 const https = require("https");
3 const http = require("http");
4 const net = require("net");
5 const tls = require("tls");
6 const crypto = require("crypto");
7 const { URL } = require("url");
8 const { mkdir, chmod, stat, rename, readFile, unlink, writeFile } = fs.promises;
9 const { createWriteStream } = fs;
10 const path = require("path");
11
12 const {
13 checksumManifestUrl,
14 detectBinaryNames,
15 releaseAssetUrl,
16 releaseBinaryDirectory,
17 } = require("./artifacts");
18 const { preflightGlibc } = require("./preflight-glibc");
19 const pkg = require("../package.json");
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
61 function resolvePackageVersion() {
62 const configuredVersion =
63 process.env.DEEPSEEK_TUI_VERSION ||
64 process.env.DEEPSEEK_VERSION ||
65 pkg.deepseekBinaryVersion ||
66 pkg.version;
67 return String(configuredVersion).trim();
68 }
69
70 function resolveRepo() {
71 return process.env.DEEPSEEK_TUI_GITHUB_REPO || process.env.DEEPSEEK_GITHUB_REPO || "Hmbown/DeepSeek-TUI";
72 }
73
74 function binaryPaths() {
75 const { deepseek, tui } = detectBinaryNames();
76 const releaseDir = releaseBinaryDirectory();
77 return {
78 deepseek: {
79 asset: deepseek,
80 target: path.join(releaseDir, process.platform === "win32" ? "deepseek.exe" : "deepseek"),
81 },
82 tui: {
83 asset: tui,
84 target: path.join(releaseDir, process.platform === "win32" ? "deepseek-tui.exe" : "deepseek-tui"),
85 },
86 };
87 }
88
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 ));
516 return;
517 }
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 }
566 return;
567 }
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);
636 });
637 }
638
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;
650 }
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;
658 }
659
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 }
687 }
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;
716 }
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 });
802 }
803
804 async function readLocalVersion(file) {
805 return readFile(file, "utf8").catch(() => "");
806 }
807
808 async function fileExists(file) {
809 try {
810 const result = await stat(file);
811 return result.isFile();
812 } catch {
813 return false;
814 }
815 }
816
817 function parseChecksumManifest(text) {
818 const checksums = new Map();
819 for (const line of text.split(/\r?\n/)) {
820 const trimmed = line.trim();
821 if (!trimmed) {
822 continue;
823 }
824 const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
825 if (!match) {
826 throw new Error(`Invalid checksum manifest line: ${trimmed}`);
827 }
828 checksums.set(match[2], match[1].toLowerCase());
829 }
830 return checksums;
831 }
832
833 async function sha256File(filePath) {
834 const content = await readFile(filePath);
835 return crypto.createHash("sha256").update(content).digest("hex");
836 }
837
838 async function verifyChecksum(filePath, assetName, checksums) {
839 const expected = checksums.get(assetName);
840 if (!expected) {
841 throw new NonRetryableError(`Checksum manifest is missing ${assetName}`);
842 }
843 const actual = await sha256File(filePath);
844 if (actual !== expected) {
845 // Bytes are corrupted; another fetch is unlikely to help without a fix
846 // upstream. Mark non-retryable.
847 throw new NonRetryableError(
848 `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
849 );
850 }
851 }
852
853 async function loadChecksums(version, repo) {
854 return parseChecksumManifest(await downloadText(checksumManifestUrl(version, repo)));
855 }
856
857 async function ensureBinary(targetPath, assetName, version, repo, getChecksums) {
858 const marker = `${targetPath}.version`;
859 const downloadIfNeeded =
860 process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD === "1" || process.env.DEEPSEEK_FORCE_DOWNLOAD === "1";
861 if (!downloadIfNeeded) {
862 const existing = await fileExists(targetPath);
863 if (existing) {
864 const markerVersion = await readLocalVersion(marker);
865 if (markerVersion === String(version)) {
866 return targetPath;
867 }
868 }
869 }
870 const checksums = await getChecksums();
871 const url = releaseAssetUrl(assetName, version, repo);
872 const destination = `${targetPath}.${process.pid}.${Date.now()}.download`;
873 await download(url, destination, { assetName });
874 try {
875 await verifyChecksum(destination, assetName, checksums);
876 preflightGlibc(destination);
877 } catch (error) {
878 await unlink(destination).catch(() => {});
879 throw error;
880 }
881 if (process.platform !== "win32") {
882 await chmod(destination, 0o755);
883 }
884 await rename(destination, targetPath);
885 await writeFile(marker, String(version), "utf8");
886 return targetPath;
887 }
888
889 async function run() {
890 if (process.env.DEEPSEEK_TUI_DISABLE_INSTALL === "1" || process.env.DEEPSEEK_DISABLE_INSTALL === "1") {
891 return;
892 }
893 const version = resolvePackageVersion();
894 const repo = resolveRepo();
895 const paths = binaryPaths();
896 const releaseDir = releaseBinaryDirectory();
897 await mkdir(releaseDir, { recursive: true });
898
899 let checksumsPromise;
900 const getChecksums = () => {
901 if (!checksumsPromise) {
902 checksumsPromise = loadChecksums(version, repo);
903 }
904 return checksumsPromise;
905 };
906
907 await Promise.all([
908 ensureBinary(paths.deepseek.target, paths.deepseek.asset, version, repo, getChecksums),
909 ensureBinary(paths.tui.target, paths.tui.asset, version, repo, getChecksums),
910 ]);
911 }
912
913 async function getBinaryPath(name) {
914 await run();
915 const paths = binaryPaths();
916 if (name === "deepseek") {
917 return paths.deepseek.target;
918 }
919 if (name === "deepseek-tui") {
920 return paths.tui.target;
921 }
922 throw new Error(`Unknown binary: ${name}`);
923 }
924
925 module.exports = {
926 getBinaryPath,
927 run,
928 };
929
930 if (require.main === module) {
931 run().catch((error) => {
932 console.error("deepseek-tui install failed:", error.message);
933 if (process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL === "1") {
934 console.error(
935 "DEEPSEEK_TUI_OPTIONAL_INSTALL=1 set; continuing without a usable binary.",
936 );
937 process.exit(0);
938 }
939 process.exit(1);
940 });
941 }
942
942 lines JAVASCRIPT