返回 CodeWhale
install.js
根目录 / npm / codewhale / scripts / install.js
1 function assertSupportedNode() {
2 const version = process.versions && process.versions.node ? process.versions.node : "unknown";
3 const major = Number.parseInt(String(version).split(".")[0], 10);
4 if (Number.isNaN(major) || major < 18) {
5 process.stderr.write(
6 "codewhale: Node.js 18 or newer is required for npm installation. " +
7 `Current Node.js version is ${version}. ` +
8 "Please upgrade Node.js and rerun `npm install -g codewhale`.\n",
9 );
10 process.exit(1);
11 }
12 }
13
14 assertSupportedNode();
15
16 const fs = require("fs");
17 const https = require("https");
18 const http = require("http");
19 const net = require("net");
20 const tls = require("tls");
21 const crypto = require("crypto");
22 const { URL } = require("url");
23 const { mkdir, chmod, stat, rename, readFile, unlink, writeFile } = fs.promises;
24 const { createWriteStream } = fs;
25 const path = require("path");
26
27 const {
28 checksumManifestUrl,
29 detectBinaryNames,
30 releaseAssetUrl,
31 releaseBinaryDirectory,
32 } = require("./artifacts");
33 const { preflightGlibc } = require("./preflight-glibc");
34 const pkg = require("../package.json");
35
36 const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes per attempt
37 const DEFAULT_STALL_MS = 30_000; // abort if no bytes for 30s
38 const OPTIONAL_TIMEOUT_MS = 15_000; // fail fast during optional npm postinstall
39 const OPTIONAL_STALL_MS = 5_000; // avoid long hangs when install can recover on first run
40 const MAX_ATTEMPTS = 5;
41 const OPTIONAL_MAX_ATTEMPTS = 1; // runtime keeps the full retry budget on first launch
42 const BASE_BACKOFF_MS = 1_000;
43
44 const RETRYABLE_NET_CODES = new Set([
45 "ECONNRESET",
46 "ECONNREFUSED",
47 "EDOWNLOADTIMEOUT",
48 "ETIMEDOUT",
49 "EAI_AGAIN",
50 "ENOTFOUND",
51 "ENETUNREACH",
52 "EHOSTUNREACH",
53 "EPIPE",
54 "ECONNABORTED",
55 ]);
56
57 class NonRetryableError extends Error {
58 constructor(message) {
59 super(message);
60 this.name = "NonRetryableError";
61 this.nonRetryable = true;
62 }
63 }
64
65 class HttpStatusError extends Error {
66 constructor(status, url) {
67 super(`Request failed with status ${status}: ${url}`);
68 this.name = "HttpStatusError";
69 this.status = status;
70 }
71 }
72
73 class DownloadTimeoutError extends Error {
74 constructor(message) {
75 super(message);
76 this.name = "DownloadTimeoutError";
77 this.code = "EDOWNLOADTIMEOUT";
78 }
79 }
80
81 // Binary-version precedence must match run.js and verify-release-assets.js so
82 // install-time asset resolution agrees with runtime and release verification.
83 // `codewhaleBinaryVersion` lets a packaging-only npm release target a specific
84 // CodeWhale binary; legacy env vars and `deepseekBinaryVersion` stay supported
85 // for backward compatibility (#3769). `pkgObj`/`env` are injectable for tests.
86 function resolvePackageVersion(pkgObj = pkg, env = process.env) {
87 const configuredVersion =
88 env.DEEPSEEK_TUI_VERSION ||
89 env.DEEPSEEK_VERSION ||
90 pkgObj.codewhaleBinaryVersion ||
91 pkgObj.deepseekBinaryVersion ||
92 pkgObj.version;
93 return String(configuredVersion).trim();
94 }
95
96 function resolveRepo() {
97 return process.env.DEEPSEEK_TUI_GITHUB_REPO || process.env.DEEPSEEK_GITHUB_REPO || "Hmbown/CodeWhale";
98 }
99
100 function isOptionalInstall(argv = process.argv.slice(2), env = process.env) {
101 return (
102 argv.includes("--optional") ||
103 env.DEEPSEEK_TUI_OPTIONAL_INSTALL === "1" ||
104 env.DEEPSEEK_OPTIONAL_INSTALL === "1"
105 );
106 }
107
108 function isInstallContext(context) {
109 return context === "install";
110 }
111
112 function isPnpmUserAgent(env = process.env) {
113 return String(env.npm_config_user_agent || "").toLowerCase().includes("pnpm/");
114 }
115
116 function shouldSkipOptionalPostinstall(
117 context,
118 argv = process.argv.slice(2),
119 env = process.env,
120 ) {
121 return isInstallContext(context) && isOptionalInstall(argv, env) && isPnpmUserAgent(env);
122 }
123
124 // Optional install only relaxes npm postinstall behavior. Runtime downloads
125 // keep the normal retry/timeout budget so first-run recovery stays resilient.
126 function defaultTimeoutMs(context = "runtime", env = process.env) {
127 return isInstallContext(context) && isOptionalInstall(undefined, env)
128 ? OPTIONAL_TIMEOUT_MS
129 : DEFAULT_TIMEOUT_MS;
130 }
131
132 function defaultStallMs(context = "runtime", env = process.env) {
133 return isInstallContext(context) && isOptionalInstall(undefined, env)
134 ? OPTIONAL_STALL_MS
135 : DEFAULT_STALL_MS;
136 }
137
138 function maxAttempts(context = "runtime", env = process.env) {
139 return isInstallContext(context) && isOptionalInstall(undefined, env)
140 ? OPTIONAL_MAX_ATTEMPTS
141 : MAX_ATTEMPTS;
142 }
143
144 function binaryPaths() {
145 const { codewhale, codew, tui } = detectBinaryNames();
146 const releaseDir = releaseBinaryDirectory();
147 return {
148 codewhale: {
149 asset: codewhale,
150 target: path.join(releaseDir, process.platform === "win32" ? "codewhale.exe" : "codewhale"),
151 },
152 codew: {
153 asset: codew,
154 target: path.join(releaseDir, process.platform === "win32" ? "codew.exe" : "codew"),
155 },
156 tui: {
157 asset: tui,
158 target: path.join(releaseDir, process.platform === "win32" ? "codewhale-tui.exe" : "codewhale-tui"),
159 },
160 };
161 }
162
163 // ────────────────────────────────────────────────────────────────────────────
164 // Logging / progress
165 // ────────────────────────────────────────────────────────────────────────────
166
167 function isQuietInstall() {
168 if (process.env.DEEPSEEK_TUI_QUIET_INSTALL === "1") {
169 return true;
170 }
171 const level = (process.env.npm_config_loglevel || "").toLowerCase();
172 return level === "silent" || level === "error";
173 }
174
175 function logInfo(message) {
176 if (isQuietInstall()) {
177 return;
178 }
179 process.stderr.write(`codewhale: ${message}\n`);
180 }
181
182 function installFailureHint(error) {
183 const message = error && error.message ? String(error.message) : "";
184 const code = error && error.code ? String(error.code) : "";
185 const releaseBase =
186 process.env.DEEPSEEK_TUI_RELEASE_BASE_URL ||
187 process.env.DEEPSEEK_RELEASE_BASE_URL;
188 const networkMarkers = [
189 "github.com",
190 "ENOTFOUND",
191 "EAI_AGAIN",
192 "ETIMEDOUT",
193 "ECONNRESET",
194 "ENETUNREACH",
195 "EHOSTUNREACH",
196 "EDOWNLOADTIMEOUT",
197 ];
198 const looksLikeNetworkDownloadFailure = networkMarkers.some(
199 (marker) => message.includes(marker) || code === marker,
200 );
201 if (!looksLikeNetworkDownloadFailure) {
202 return "";
203 }
204
205 if (releaseBase) {
206 return [
207 "codewhale install hint:",
208 ` DEEPSEEK_TUI_RELEASE_BASE_URL is set to ${releaseBase}`,
209 " Verify that this directory contains codewhale-artifacts-sha256.txt",
210 " plus the codewhale/codew/codewhale-tui binary assets for your platform.",
211 ].join("\n");
212 }
213
214 return [
215 "codewhale install hint:",
216 " The npm package downloads prebuilt binaries from GitHub Releases.",
217 " If GitHub is unavailable on this network, mirror the release assets and set:",
218 " DEEPSEEK_TUI_RELEASE_BASE_URL=https://<mirror>/<release-asset-directory>/",
219 " The directory must contain codewhale-artifacts-sha256.txt and the platform binaries.",
220 " See docs/INSTALL.md#npm-binary-download-times-out.",
221 ].join("\n");
222 }
223
224 function envInt(name, fallback) {
225 const raw = process.env[name];
226 if (!raw) {
227 return fallback;
228 }
229 const parsed = Number.parseInt(String(raw).trim(), 10);
230 if (!Number.isFinite(parsed) || parsed <= 0) {
231 return fallback;
232 }
233 return parsed;
234 }
235
236 function downloadTimeoutMs(context = "runtime") {
237 return envInt(
238 "DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS",
239 envInt("DEEPSEEK_DOWNLOAD_TIMEOUT_MS", defaultTimeoutMs(context)),
240 );
241 }
242
243 function downloadStallMs(context = "runtime") {
244 return envInt(
245 "DEEPSEEK_TUI_DOWNLOAD_STALL_MS",
246 envInt("DEEPSEEK_DOWNLOAD_STALL_MS", defaultStallMs(context)),
247 );
248 }
249
250 function formatMb(bytes) {
251 return (bytes / (1024 * 1024)).toFixed(0);
252 }
253
254 function createProgressReporter(assetName, totalBytes) {
255 if (isQuietInstall()) {
256 return { onChunk: () => {}, finish: () => {} };
257 }
258 const isTty = !!process.stderr.isTTY;
259 const interactive = isTty;
260 const tickBytes = interactive ? 1 * 1024 * 1024 : 5 * 1024 * 1024;
261 const tickMs = 2_000;
262
263 let received = 0;
264 let lastBytesPrinted = 0;
265 let lastTimePrinted = 0;
266 let everPrinted = false;
267
268 const render = (final) => {
269 if (totalBytes && totalBytes > 0) {
270 const pct = Math.min(100, Math.round((received / totalBytes) * 100));
271 const line = `codewhale: downloading ${assetName}: ${formatMb(received)} / ${formatMb(totalBytes)} MB (${pct}%)`;
272 if (interactive) {
273 process.stderr.write(`${line}\r`);
274 } else {
275 process.stderr.write(`${line}\n`);
276 }
277 } else {
278 const line = `codewhale: downloading ${assetName}: ${formatMb(received)} MB downloaded`;
279 if (interactive) {
280 process.stderr.write(`${line}\r`);
281 } else {
282 process.stderr.write(`${line}\n`);
283 }
284 }
285 everPrinted = true;
286 lastBytesPrinted = received;
287 lastTimePrinted = Date.now();
288 };
289
290 return {
291 onChunk(chunkLen) {
292 received += chunkLen;
293 const now = Date.now();
294 if (
295 received - lastBytesPrinted >= tickBytes ||
296 (interactive && now - lastTimePrinted >= tickMs)
297 ) {
298 render(false);
299 }
300 },
301 finish() {
302 // Final line — always render once.
303 render(true);
304 if (interactive && everPrinted) {
305 // Move past the carriage-return line and emit a "done" footer.
306 process.stderr.write("\n");
307 }
308 process.stderr.write(`codewhale: ${assetName} ... done.\n`);
309 },
310 };
311 }
312
313 // ────────────────────────────────────────────────────────────────────────────
314 // Proxy support (HTTPS_PROXY / HTTP_PROXY / NO_PROXY) — pure Node, CONNECT
315 // tunnel + TLS upgrade for HTTPS targets.
316 // ────────────────────────────────────────────────────────────────────────────
317
318 function getProxyUrl(targetUrl) {
319 const isHttps = targetUrl.protocol === "https:";
320 const candidates = isHttps
321 ? ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]
322 : ["HTTP_PROXY", "http_proxy"];
323 for (const name of candidates) {
324 const raw = process.env[name];
325 if (raw && String(raw).trim() !== "") {
326 return String(raw).trim();
327 }
328 }
329 return null;
330 }
331
332 function shouldBypassProxy(host) {
333 const raw = process.env.NO_PROXY || process.env.no_proxy;
334 if (!raw) {
335 return false;
336 }
337 const lower = String(host).toLowerCase();
338 for (const part of String(raw).split(",")) {
339 const entry = part.trim().toLowerCase();
340 if (!entry) {
341 continue;
342 }
343 if (entry === "*") {
344 return true;
345 }
346 // Strip leading dot and any explicit port.
347 const stripped = entry.replace(/^\./, "").replace(/:.*$/, "");
348 if (!stripped) {
349 continue;
350 }
351 if (lower === stripped || lower.endsWith(`.${stripped}`)) {
352 return true;
353 }
354 }
355 return false;
356 }
357
358 function parseProxy(proxyStr) {
359 // Accept "http://user:pass@host:port" and bare "host:port".
360 const normalized = /^[a-z][a-z0-9+\-.]*:\/\//i.test(proxyStr)
361 ? proxyStr
362 : `http://${proxyStr}`;
363 const u = new URL(normalized);
364 const port = u.port
365 ? Number.parseInt(u.port, 10)
366 : u.protocol === "https:"
367 ? 443
368 : 80;
369 let auth = null;
370 if (u.username) {
371 const user = decodeURIComponent(u.username);
372 const pass = u.password ? decodeURIComponent(u.password) : "";
373 auth = Buffer.from(`${user}:${pass}`).toString("base64");
374 }
375 return {
376 protocol: u.protocol,
377 host: u.hostname,
378 port,
379 auth,
380 raw: proxyStr,
381 };
382 }
383
384 function connectThroughProxy(proxy, targetHost, targetPort, timeoutMs) {
385 return new Promise((resolve, reject) => {
386 const socket = net.connect({ host: proxy.host, port: proxy.port });
387 let settled = false;
388 const fail = (err) => {
389 if (settled) return;
390 settled = true;
391 try {
392 socket.destroy();
393 } catch {
394 // ignore
395 }
396 reject(err);
397 };
398
399 const timer = timeoutMs > 0
400 ? setTimeout(() => fail(new DownloadTimeoutError(
401 `proxy CONNECT to ${proxy.host}:${proxy.port} timed out after ${timeoutMs} ms`,
402 )), timeoutMs)
403 : null;
404
405 socket.once("error", (err) => {
406 if (timer) clearTimeout(timer);
407 // Surface proxy host so the user can fix it.
408 const wrapped = new Error(
409 `proxy connection failed (${proxy.host}:${proxy.port}): ${err.message}`,
410 );
411 wrapped.code = err.code;
412 fail(wrapped);
413 });
414
415 socket.once("connect", () => {
416 const lines = [
417 `CONNECT ${targetHost}:${targetPort} HTTP/1.1`,
418 `Host: ${targetHost}:${targetPort}`,
419 "User-Agent: codewhale-installer",
420 "Proxy-Connection: keep-alive",
421 ];
422 if (proxy.auth) {
423 lines.push(`Proxy-Authorization: Basic ${proxy.auth}`);
424 }
425 const req = `${lines.join("\r\n")}\r\n\r\n`;
426
427 let buf = Buffer.alloc(0);
428 const onData = (chunk) => {
429 buf = Buffer.concat([buf, chunk]);
430 const idx = buf.indexOf("\r\n\r\n");
431 if (idx === -1) {
432 if (buf.length > 16 * 1024) {
433 socket.removeListener("data", onData);
434 fail(new Error(
435 `proxy ${proxy.host}:${proxy.port} returned an oversized response header`,
436 ));
437 }
438 return;
439 }
440 socket.removeListener("data", onData);
441 const head = buf.slice(0, idx).toString("utf8");
442 const firstLine = head.split(/\r?\n/, 1)[0] || "";
443 const m = firstLine.match(/^HTTP\/\d\.\d\s+(\d{3})/);
444 if (!m) {
445 fail(new Error(`proxy ${proxy.host}:${proxy.port} returned invalid CONNECT reply: ${firstLine}`));
446 return;
447 }
448 const code = Number.parseInt(m[1], 10);
449 if (code !== 200) {
450 fail(new Error(
451 `proxy ${proxy.host}:${proxy.port} refused CONNECT to ${targetHost}:${targetPort}: HTTP ${code}`,
452 ));
453 return;
454 }
455 if (timer) clearTimeout(timer);
456 if (settled) return;
457 settled = true;
458 // Any bytes past the header belong to the tunneled stream — but in
459 // practice CONNECT 200 has no body; if it did, we'd lose those bytes
460 // here. Keep it simple: trust well-behaved proxies.
461 resolve(socket);
462 };
463 socket.on("data", onData);
464 socket.write(req, "utf8");
465 });
466 });
467 }
468
469 // ────────────────────────────────────────────────────────────────────────────
470 // HTTP request with timeout, stall detection, and proxy support.
471 // ────────────────────────────────────────────────────────────────────────────
472
473 function httpRequest(rawUrl, opts = {}) {
474 const context =
475 opts.context === undefined || opts.context === null ? "runtime" : opts.context;
476 const totalTimeoutMs =
477 opts.totalTimeoutMs === undefined || opts.totalTimeoutMs === null
478 ? downloadTimeoutMs(context)
479 : opts.totalTimeoutMs;
480 const stallMs =
481 opts.stallMs === undefined || opts.stallMs === null
482 ? downloadStallMs(context)
483 : opts.stallMs;
484
485 return new Promise((resolve, reject) => {
486 let url;
487 try {
488 url = new URL(rawUrl);
489 } catch (err) {
490 reject(new NonRetryableError(`Invalid URL: ${rawUrl} (${err.message})`));
491 return;
492 }
493 if (url.protocol !== "https:" && url.protocol !== "http:") {
494 reject(new NonRetryableError(`Unsupported protocol: ${url.protocol}`));
495 return;
496 }
497
498 const proxyStr = !shouldBypassProxy(url.hostname) ? getProxyUrl(url) : null;
499 const isHttps = url.protocol === "https:";
500 const port = url.port
501 ? Number.parseInt(url.port, 10)
502 : isHttps
503 ? 443
504 : 80;
505
506 let totalTimer = null;
507 let stallTimer = null;
508 let settled = false;
509 let req = null;
510 let res = null;
511
512 const cleanup = () => {
513 if (totalTimer) {
514 clearTimeout(totalTimer);
515 totalTimer = null;
516 }
517 if (stallTimer) {
518 clearTimeout(stallTimer);
519 stallTimer = null;
520 }
521 };
522
523 const fail = (err) => {
524 if (settled) return;
525 settled = true;
526 cleanup();
527 try {
528 if (req && !req.destroyed) req.destroy();
529 } catch {
530 // ignore
531 }
532 try {
533 if (res && !res.destroyed) res.destroy();
534 } catch {
535 // ignore
536 }
537 reject(err);
538 };
539
540 if (totalTimeoutMs > 0) {
541 totalTimer = setTimeout(() => {
542 fail(new DownloadTimeoutError(
543 `download exceeded total timeout of ${totalTimeoutMs} ms ` +
544 `(set DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS to raise it; current stall budget is ${stallMs} ms)`,
545 ));
546 }, totalTimeoutMs);
547 }
548
549 const armStallTimer = () => {
550 if (stallMs <= 0) return;
551 if (stallTimer) clearTimeout(stallTimer);
552 stallTimer = setTimeout(() => {
553 fail(new DownloadTimeoutError(
554 `download stalled — no bytes received for ${stallMs} ms ` +
555 `(set DEEPSEEK_TUI_DOWNLOAD_STALL_MS to raise it; total budget is ${totalTimeoutMs} ms)`,
556 ));
557 }, stallMs);
558 };
559
560 const launch = (socket) => {
561 const reqOptions = {
562 method: "GET",
563 host: url.hostname,
564 port,
565 path: `${url.pathname}${url.search || ""}`,
566 headers: {
567 Host: url.host,
568 "User-Agent": "codewhale-installer",
569 Accept: "*/*",
570 Connection: "close",
571 },
572 };
573 if (socket) {
574 reqOptions.createConnection = () => socket;
575 if (isHttps) {
576 // Wrap raw TCP socket from CONNECT in TLS.
577 const tlsSocket = tls.connect({
578 socket,
579 servername: url.hostname,
580 ALPNProtocols: ["http/1.1"],
581 });
582 tlsSocket.once("error", (err) => fail(err));
583 reqOptions.createConnection = () => tlsSocket;
584 }
585 }
586 const client = isHttps ? https : http;
587 try {
588 req = client.request(reqOptions, (response) => {
589 res = response;
590 response.pause();
591 armStallTimer();
592 response.on("data", () => {
593 armStallTimer();
594 });
595 response.on("end", () => {
596 cleanup();
597 });
598 response.on("error", (err) => fail(err));
599
600 const status = response.statusCode || 0;
601 if (status >= 300 && status < 400 && response.headers.location) {
602 cleanup();
603 settled = true;
604 response.resume();
605 resolve({ redirect: response.headers.location, response: null });
606 return;
607 }
608 if (status < 200 || status >= 300) {
609 const err = new HttpStatusError(status, rawUrl);
610 // 4xx: non-retryable; 5xx: retryable.
611 if (status >= 400 && status < 500) {
612 err.nonRetryable = true;
613 }
614 fail(err);
615 return;
616 }
617 if (settled) return;
618 settled = true;
619 // Hand the live response stream to the caller.
620 resolve({ redirect: null, response });
621 });
622 req.once("error", (err) => fail(err));
623 req.once("socket", (s) => {
624 // Belt-and-suspenders: surface socket-level errors quickly.
625 s.once("error", (err) => fail(err));
626 });
627 req.end();
628 } catch (err) {
629 fail(err);
630 }
631 };
632
633 if (proxyStr) {
634 let proxy;
635 try {
636 proxy = parseProxy(proxyStr);
637 } catch (err) {
638 fail(new NonRetryableError(
639 `Invalid proxy URL "${proxyStr}": ${err.message}`,
640 ));
641 return;
642 }
643 if (!isHttps) {
644 // Plain HTTP through proxy — send absolute URI, no CONNECT.
645 const client = http;
646 try {
647 req = client.request(
648 {
649 host: proxy.host,
650 port: proxy.port,
651 method: "GET",
652 path: rawUrl,
653 headers: {
654 Host: url.host,
655 "User-Agent": "codewhale-installer",
656 Accept: "*/*",
657 Connection: "close",
658 ...(proxy.auth ? { "Proxy-Authorization": `Basic ${proxy.auth}` } : {}),
659 },
660 },
661 (response) => {
662 res = response;
663 response.pause();
664 armStallTimer();
665 response.on("data", () => armStallTimer());
666 response.on("end", () => cleanup());
667 response.on("error", (err) => fail(err));
668 const status = response.statusCode || 0;
669 if (status >= 300 && status < 400 && response.headers.location) {
670 cleanup();
671 settled = true;
672 response.resume();
673 resolve({ redirect: response.headers.location, response: null });
674 return;
675 }
676 if (status < 200 || status >= 300) {
677 const err = new HttpStatusError(status, rawUrl);
678 if (status >= 400 && status < 500) err.nonRetryable = true;
679 fail(err);
680 return;
681 }
682 if (settled) return;
683 settled = true;
684 resolve({ redirect: null, response });
685 },
686 );
687 req.once("error", (err) => fail(err));
688 req.end();
689 } catch (err) {
690 fail(err);
691 }
692 return;
693 }
694
695 // HTTPS through proxy: CONNECT tunnel + TLS upgrade.
696 connectThroughProxy(proxy, url.hostname, port, Math.max(stallMs, 5_000))
697 .then((tcpSocket) => {
698 if (settled) {
699 try { tcpSocket.destroy(); } catch { /* ignore */ }
700 return;
701 }
702 const tlsSocket = tls.connect({
703 socket: tcpSocket,
704 servername: url.hostname,
705 ALPNProtocols: ["http/1.1"],
706 });
707 tlsSocket.once("error", (err) => fail(err));
708 tlsSocket.once("secureConnect", () => {
709 if (settled) {
710 try { tlsSocket.destroy(); } catch { /* ignore */ }
711 return;
712 }
713 const reqOptions = {
714 method: "GET",
715 createConnection: () => tlsSocket,
716 path: `${url.pathname}${url.search || ""}`,
717 headers: {
718 Host: url.host,
719 "User-Agent": "codewhale-installer",
720 Accept: "*/*",
721 Connection: "close",
722 },
723 };
724 try {
725 req = https.request(reqOptions, (response) => {
726 res = response;
727 response.pause();
728 armStallTimer();
729 response.on("data", () => armStallTimer());
730 response.on("end", () => cleanup());
731 response.on("error", (err) => fail(err));
732 const status = response.statusCode || 0;
733 if (status >= 300 && status < 400 && response.headers.location) {
734 cleanup();
735 settled = true;
736 response.resume();
737 resolve({ redirect: response.headers.location, response: null });
738 return;
739 }
740 if (status < 200 || status >= 300) {
741 const err = new HttpStatusError(status, rawUrl);
742 if (status >= 400 && status < 500) err.nonRetryable = true;
743 fail(err);
744 return;
745 }
746 if (settled) return;
747 settled = true;
748 resolve({ redirect: null, response });
749 });
750 req.once("error", (err) => fail(err));
751 req.end();
752 } catch (err) {
753 fail(err);
754 }
755 });
756 })
757 .catch((err) => fail(err));
758 return;
759 }
760
761 // No proxy — direct connection.
762 launch(null);
763 });
764 }
765
766 // ────────────────────────────────────────────────────────────────────────────
767 // Retry wrapper
768 // ────────────────────────────────────────────────────────────────────────────
769
770 function isRetryable(err) {
771 if (!err) return false;
772 if (err.nonRetryable) return false;
773 if (err instanceof NonRetryableError) return false;
774 if (err instanceof DownloadTimeoutError) return true;
775 // withRetry() rethrows a plain Error while preserving name/status, so wrapped
776 // HTTP 5xx failures still classify as retryable during optional postinstall.
777 if (
778 (err instanceof HttpStatusError || err.name === "HttpStatusError") &&
779 typeof err.status === "number"
780 ) {
781 return err.status >= 500;
782 }
783 if (err.code && RETRYABLE_NET_CODES.has(err.code)) return true;
784 // Network-flavored messages we may see without a code.
785 const msg = String(err.message || "").toLowerCase();
786 if (msg.includes("network") && msg.includes("unreachable")) return true;
787 if (msg.includes("socket hang up")) return true;
788 if (msg.includes("aborted")) return true;
789 return false;
790 }
791
792 function backoffDelay(attempt) {
793 // attempt is 1-indexed; first retry waits ~1s.
794 const base = BASE_BACKOFF_MS * 2 ** (attempt - 1);
795 const jitter = (Math.random() * 0.4 - 0.2) * base; // ±20%
796 return Math.max(0, Math.round(base + jitter));
797 }
798
799 function sleep(ms) {
800 return new Promise((resolve) => setTimeout(resolve, ms));
801 }
802
803 async function withRetry(label, fn, context = "runtime") {
804 let lastErr;
805 const attemptLimit = maxAttempts(context);
806 for (let attempt = 1; attempt <= attemptLimit; attempt++) {
807 try {
808 return await fn(attempt);
809 } catch (err) {
810 lastErr = err;
811 if (!isRetryable(err) || attempt === attemptLimit) {
812 break;
813 }
814 const wait = backoffDelay(attempt);
815 logInfo(
816 `${label} failed (attempt ${attempt}/${attemptLimit}): ${err.message}; retrying in ${wait} ms`,
817 );
818 if (attempt === 1) {
819 const hint = installFailureHint(err);
820 if (hint) {
821 process.stderr.write(`${hint}\n`);
822 }
823 }
824 await sleep(wait);
825 }
826 }
827 const msg = lastErr && lastErr.message ? lastErr.message : String(lastErr);
828 const wrapped = new Error(
829 `${label} failed after ${attemptLimit} attempt(s): ${msg}`,
830 );
831 // Preserve retry classification metadata because the install entrypoint uses
832 // the wrapped error to decide whether optional postinstall may ignore it.
833 if (lastErr && lastErr.code) {
834 wrapped.code = lastErr.code;
835 }
836 if (lastErr && lastErr.name) {
837 wrapped.name = lastErr.name;
838 }
839 if (lastErr && typeof lastErr.status === "number") {
840 wrapped.status = lastErr.status;
841 }
842 if (lastErr && lastErr.nonRetryable) {
843 wrapped.nonRetryable = true;
844 }
845 if (lastErr && lastErr.stack) {
846 wrapped.cause = lastErr;
847 }
848 throw wrapped;
849 }
850
851 // ────────────────────────────────────────────────────────────────────────────
852 // Public download primitives (now retry + progress aware)
853 // ────────────────────────────────────────────────────────────────────────────
854
855 async function followRedirects(url, opts = {}) {
856 const maxRedirects = 10;
857 let current = url;
858 for (let hop = 0; hop < maxRedirects; hop++) {
859 const result = await httpRequest(current, opts);
860 if (result.redirect) {
861 try {
862 current = new URL(result.redirect, current).toString();
863 } catch {
864 current = result.redirect;
865 }
866 continue;
867 }
868 return result;
869 }
870 throw new NonRetryableError(`too many redirects starting at ${url}`);
871 }
872
873 function streamToFile(response, destination, progress) {
874 return new Promise((resolve, reject) => {
875 const sink = createWriteStream(destination);
876 let done = false;
877 const finish = (err) => {
878 if (done) return;
879 done = true;
880 if (err) {
881 sink.destroy();
882 reject(err);
883 } else {
884 resolve();
885 }
886 };
887 response.on("data", (chunk) => {
888 if (progress) progress.onChunk(chunk.length);
889 });
890 response.on("error", (err) => finish(err));
891 sink.on("error", (err) => finish(err));
892 sink.on("finish", () => finish(null));
893 response.pipe(sink);
894 });
895 }
896
897 async function download(url, destination, options = {}) {
898 await mkdir(path.dirname(destination), { recursive: true });
899 const assetName = options.assetName || path.basename(destination);
900 const context =
901 options.context === undefined || options.context === null ? "runtime" : options.context;
902 const attemptLimit = maxAttempts(context);
903 await withRetry(`download ${assetName}`, async (attempt) => {
904 const result = await followRedirects(url, {
905 context,
906 totalTimeoutMs: downloadTimeoutMs(context),
907 stallMs: downloadStallMs(context),
908 });
909 const response = result.response;
910 const lenHeader = response.headers["content-length"];
911 const total = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
912 const progress = createProgressReporter(assetName, Number.isFinite(total) ? total : 0);
913 if (attempt > 1) {
914 logInfo(`retry attempt ${attempt}/${attemptLimit} for ${assetName}`);
915 }
916 try {
917 await streamToFile(response, destination, progress);
918 } catch (err) {
919 // Ensure we don't leave a partial file confusing future attempts.
920 try {
921 await unlink(destination);
922 } catch {
923 // ignore
924 }
925 throw err;
926 }
927 progress.finish();
928 }, context);
929 }
930
931 async function downloadText(url, options = {}) {
932 const context =
933 options.context === undefined || options.context === null ? "runtime" : options.context;
934 return withRetry(`fetch ${url}`, async () => {
935 const result = await followRedirects(url, {
936 context,
937 totalTimeoutMs: downloadTimeoutMs(context),
938 stallMs: downloadStallMs(context),
939 });
940 const response = result.response;
941 response.setEncoding("utf8");
942 // NOTE: do NOT use `for await (const chunk of response)` here.
943 // `httpRequest` attaches a `data` listener on the response to re-arm
944 // the stall timer, which puts the stream in flowing mode. The async
945 // iterator expects paused mode and will silently miss every chunk —
946 // this manifested as an empty checksum manifest in the npm wrapper
947 // smoke test ("Checksum manifest is missing <asset>"). Subscribing
948 // to `data` events directly stacks alongside the stall listener and
949 // both fire per chunk, so we collect the body correctly without
950 // disturbing the stall detection.
951 return new Promise((resolve, reject) => {
952 const chunks = [];
953 response.on("data", (chunk) => {
954 chunks.push(chunk);
955 });
956 response.on("end", () => {
957 resolve(chunks.join(""));
958 });
959 response.on("error", reject);
960 response.resume();
961 });
962 }, context);
963 }
964
965 async function readLocalVersion(file) {
966 return readFile(file, "utf8").catch(() => "");
967 }
968
969 async function fileExists(file) {
970 try {
971 const result = await stat(file);
972 return result.isFile();
973 } catch {
974 return false;
975 }
976 }
977
978 function parseChecksumManifest(text) {
979 const checksums = new Map();
980 for (const line of text.split(/\r?\n/)) {
981 const trimmed = line.trim();
982 if (!trimmed) {
983 continue;
984 }
985 const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
986 if (!match) {
987 throw new Error(`Invalid checksum manifest line: ${trimmed}`);
988 }
989 checksums.set(match[2], match[1].toLowerCase());
990 }
991 return checksums;
992 }
993
994 async function sha256File(filePath) {
995 const content = await readFile(filePath);
996 return crypto.createHash("sha256").update(content).digest("hex");
997 }
998
999 async function verifyChecksum(filePath, assetName, checksums) {
1000 const expected = checksums.get(assetName);
1001 if (!expected) {
1002 throw new NonRetryableError(`Checksum manifest is missing ${assetName}`);
1003 }
1004 const actual = await sha256File(filePath);
1005 if (actual !== expected) {
1006 // Bytes are corrupted; another fetch is unlikely to help without a fix
1007 // upstream. Mark non-retryable.
1008 throw new NonRetryableError(
1009 `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
1010 );
1011 }
1012 }
1013
1014 async function checksumMatches(filePath, assetName, checksums) {
1015 const expected = checksums.get(assetName);
1016 if (!expected) {
1017 throw new NonRetryableError(`Checksum manifest is missing ${assetName}`);
1018 }
1019 const actual = await sha256File(filePath);
1020 return actual === expected;
1021 }
1022
1023 async function loadChecksums(version, repo, options = {}) {
1024 return parseChecksumManifest(await downloadText(checksumManifestUrl(version, repo), options));
1025 }
1026
1027 function existingBinaryCandidates(targetPath, assetName) {
1028 const candidates = [targetPath];
1029 const assetPath = path.join(path.dirname(targetPath), assetName);
1030 if (assetPath !== targetPath) {
1031 candidates.push(assetPath);
1032 }
1033 return candidates;
1034 }
1035
1036 async function adoptExistingBinaryIfValid(targetPath, assetName, version, getChecksums, marker) {
1037 const candidates = [];
1038 for (const candidate of existingBinaryCandidates(targetPath, assetName)) {
1039 if (await fileExists(candidate)) {
1040 candidates.push(candidate);
1041 }
1042 }
1043 if (candidates.length === 0) {
1044 return false;
1045 }
1046
1047 const checksums = await getChecksums();
1048 for (const candidate of candidates) {
1049 if (!(await checksumMatches(candidate, assetName, checksums))) {
1050 continue;
1051 }
1052 preflightGlibc(candidate);
1053 if (candidate !== targetPath) {
1054 await rename(candidate, targetPath);
1055 }
1056 if (process.platform !== "win32") {
1057 await chmod(targetPath, 0o755);
1058 }
1059 await writeFile(marker, String(version), "utf8");
1060 return true;
1061 }
1062 return false;
1063 }
1064
1065 async function ensureBinary(targetPath, assetName, version, repo, getChecksums, options = {}) {
1066 const marker = `${targetPath}.version`;
1067 const downloadIfNeeded =
1068 process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD === "1" || process.env.DEEPSEEK_FORCE_DOWNLOAD === "1";
1069 if (!downloadIfNeeded) {
1070 const existing = await fileExists(targetPath);
1071 if (existing) {
1072 const markerVersion = await readLocalVersion(marker);
1073 if (markerVersion === String(version)) {
1074 return targetPath;
1075 }
1076 }
1077 if (await adoptExistingBinaryIfValid(targetPath, assetName, version, getChecksums, marker)) {
1078 return targetPath;
1079 }
1080 }
1081 const checksums = await getChecksums();
1082 const url = releaseAssetUrl(assetName, version, repo);
1083 const destination = `${targetPath}.${process.pid}.${Date.now()}.download`;
1084 await download(url, destination, { assetName, context: options.context });
1085 try {
1086 await verifyChecksum(destination, assetName, checksums);
1087 preflightGlibc(destination);
1088 } catch (error) {
1089 await unlink(destination).catch(() => {});
1090 throw error;
1091 }
1092 if (process.platform !== "win32") {
1093 await chmod(destination, 0o755);
1094 }
1095 await rename(destination, targetPath);
1096 await writeFile(marker, String(version), "utf8");
1097 return targetPath;
1098 }
1099
1100 // Optional install may only downgrade retryable download failures to warnings.
1101 // Unsupported platforms, checksum mismatches, glibc compatibility errors, and
1102 // malformed release metadata must still fail with actionable diagnostics.
1103 function shouldIgnoreInstallFailure(
1104 context,
1105 error,
1106 argv = process.argv.slice(2),
1107 env = process.env,
1108 ) {
1109 return isInstallContext(context) && isOptionalInstall(argv, env) && isRetryable(error);
1110 }
1111
1112 async function run(options = {}) {
1113 const context =
1114 options.context === undefined || options.context === null ? "runtime" : options.context;
1115 if (process.env.DEEPSEEK_TUI_DISABLE_INSTALL === "1" || process.env.DEEPSEEK_DISABLE_INSTALL === "1") {
1116 return;
1117 }
1118 if (shouldSkipOptionalPostinstall(context)) {
1119 logInfo(
1120 "pnpm optional postinstall detected; skipping install-time download. The binary will be checked on first run.",
1121 );
1122 return;
1123 }
1124 const version = resolvePackageVersion();
1125 const repo = resolveRepo();
1126 const paths = binaryPaths();
1127 const releaseDir = releaseBinaryDirectory();
1128 await mkdir(releaseDir, { recursive: true });
1129
1130 let checksumsPromise;
1131 const getChecksums = () => {
1132 if (!checksumsPromise) {
1133 checksumsPromise = loadChecksums(version, repo, { context });
1134 }
1135 return checksumsPromise;
1136 };
1137
1138 await Promise.all([
1139 ensureBinary(paths.codewhale.target, paths.codewhale.asset, version, repo, getChecksums, { context }),
1140 ensureBinary(paths.codew.target, paths.codew.asset, version, repo, getChecksums, { context }),
1141 ensureBinary(paths.tui.target, paths.tui.asset, version, repo, getChecksums, { context }),
1142 ]);
1143 }
1144
1145 async function getBinaryPath(name) {
1146 await run({ context: "runtime" });
1147 const paths = binaryPaths();
1148 if (name === "codewhale") {
1149 return paths.codewhale.target;
1150 }
1151 if (name === "codew") {
1152 return paths.codew.target;
1153 }
1154 if (name === "codewhale-tui") {
1155 return paths.tui.target;
1156 }
1157 throw new Error(`Unknown binary: ${name}`);
1158 }
1159
1160 module.exports = {
1161 getBinaryPath,
1162 installFailureHint,
1163 run,
1164 _internal: {
1165 resolvePackageVersion,
1166 isOptionalInstall,
1167 adoptExistingBinaryIfValid,
1168 shouldIgnoreInstallFailure,
1169 shouldSkipOptionalPostinstall,
1170 httpRequest,
1171 defaultTimeoutMs,
1172 defaultStallMs,
1173 binaryPaths,
1174 ensureBinary,
1175 maxAttempts,
1176 withRetry,
1177 },
1178 };
1179
1180 if (require.main === module) {
1181 run({ context: "install" }).catch((error) => {
1182 console.error("codewhale install failed:", error.message);
1183 const hint = installFailureHint(error);
1184 if (hint) {
1185 console.error(hint);
1186 }
1187 if (shouldIgnoreInstallFailure("install", error)) {
1188 console.error(
1189 "Optional install enabled; continuing without a usable binary. The download will be retried on first run.",
1190 );
1191 process.exit(0);
1192 }
1193 process.exit(1);
1194 });
1195 }
1196
1196 lines JAVASCRIPT