返回 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 const os = require("os");
27
28 const {
29 CHECKSUM_MANIFEST,
30 assertCnbMirrorSupportedPlatform,
31 checksumManifestUrl,
32 cnbReleaseBaseUrl,
33 detectBinaryNames,
34 explicitReleaseBase,
35 firstPartyReleaseSources,
36 githubReleaseBaseUrl,
37 releaseAssetUrl,
38 releaseAssetUrlFromBase,
39 releaseBinaryDirectory,
40 shouldRaceFirstPartyMirrors,
41 usesCnbMirror,
42 } = require("./artifacts");
43 const { preflightGlibc } = require("./preflight-glibc");
44 const pkg = require("../package.json");
45
46 const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes per attempt
47 const DEFAULT_STALL_MS = 30_000; // abort if no bytes for 30s
48 const OPTIONAL_TIMEOUT_MS = 15_000; // fail fast during optional npm postinstall
49 const OPTIONAL_STALL_MS = 5_000; // avoid long hangs when install can recover on first run
50 const MANIFEST_TIMEOUT_MS = 15_000; // small checksum probes must not wait on a binary budget
51 const MANIFEST_STALL_MS = 5_000;
52 const MAX_ATTEMPTS = 5;
53 const OPTIONAL_MAX_ATTEMPTS = 1; // runtime keeps the full retry budget on first launch
54 const BASE_BACKOFF_MS = 1_000;
55
56 const RETRYABLE_NET_CODES = new Set([
57 "ECONNRESET",
58 "ECONNREFUSED",
59 "EDOWNLOADTIMEOUT",
60 "ETIMEDOUT",
61 "EAI_AGAIN",
62 "ENOTFOUND",
63 "ENETUNREACH",
64 "EHOSTUNREACH",
65 "EPIPE",
66 "ECONNABORTED",
67 ]);
68
69 class NonRetryableError extends Error {
70 constructor(message) {
71 super(message);
72 this.name = "NonRetryableError";
73 this.nonRetryable = true;
74 }
75 }
76
77 class HttpStatusError extends Error {
78 constructor(status, url) {
79 super(`Request failed with status ${status}: ${url}`);
80 this.name = "HttpStatusError";
81 this.status = status;
82 }
83 }
84
85 class DownloadTimeoutError extends Error {
86 constructor(message) {
87 super(message);
88 this.name = "DownloadTimeoutError";
89 this.code = "EDOWNLOADTIMEOUT";
90 }
91 }
92
93 function abortError(message) {
94 const err = new Error(message || "The operation was aborted");
95 err.name = "AbortError";
96 err.code = "ABORT_ERR";
97 err.nonRetryable = true;
98 return err;
99 }
100
101 function isAbortError(err) {
102 return Boolean(err) && (err.name === "AbortError" || err.code === "ABORT_ERR");
103 }
104
105 // Binary-version precedence must match run.js and verify-release-assets.js so
106 // install-time asset resolution agrees with runtime and release verification.
107 // `codewhaleBinaryVersion` lets a packaging-only npm release target a specific
108 // CodeWhale binary; legacy env vars and `deepseekBinaryVersion` stay supported
109 // for backward compatibility (#3769). `pkgObj`/`env` are injectable for tests.
110 function resolvePackageVersion(pkgObj = pkg, env = process.env) {
111 const configuredVersion =
112 env.CODEWHALE_VERSION ||
113 env.DEEPSEEK_TUI_VERSION ||
114 env.DEEPSEEK_VERSION ||
115 pkgObj.codewhaleBinaryVersion ||
116 pkgObj.deepseekBinaryVersion ||
117 pkgObj.version;
118 return String(configuredVersion).trim();
119 }
120
121 function resolveRepo(env = process.env) {
122 return (
123 env.CODEWHALE_GITHUB_REPO ||
124 env.DEEPSEEK_TUI_GITHUB_REPO ||
125 env.DEEPSEEK_GITHUB_REPO ||
126 "Hmbown/CodeWhale"
127 );
128 }
129
130 function isOptionalInstall(argv = process.argv.slice(2), env = process.env) {
131 return (
132 argv.includes("--optional") ||
133 env.CODEWHALE_OPTIONAL_INSTALL === "1" ||
134 env.DEEPSEEK_TUI_OPTIONAL_INSTALL === "1" ||
135 env.DEEPSEEK_OPTIONAL_INSTALL === "1"
136 );
137 }
138
139 function shouldForceDownload(env = process.env) {
140 return (
141 env.CODEWHALE_FORCE_DOWNLOAD === "1" ||
142 env.DEEPSEEK_TUI_FORCE_DOWNLOAD === "1" ||
143 env.DEEPSEEK_FORCE_DOWNLOAD === "1"
144 );
145 }
146
147 function shouldDisableInstall(env = process.env) {
148 return (
149 env.CODEWHALE_DISABLE_INSTALL === "1" ||
150 env.DEEPSEEK_TUI_DISABLE_INSTALL === "1" ||
151 env.DEEPSEEK_DISABLE_INSTALL === "1"
152 );
153 }
154
155 function isInstallContext(context) {
156 return context === "install";
157 }
158
159 function isPnpmUserAgent(env = process.env) {
160 return String(env.npm_config_user_agent || "").toLowerCase().includes("pnpm/");
161 }
162
163 function shouldSkipOptionalPostinstall(
164 context,
165 argv = process.argv.slice(2),
166 env = process.env,
167 ) {
168 return isInstallContext(context) && isOptionalInstall(argv, env) && isPnpmUserAgent(env);
169 }
170
171 // Optional install only relaxes npm postinstall behavior. Runtime downloads
172 // keep the normal retry/timeout budget so first-run recovery stays resilient.
173 function defaultTimeoutMs(context = "runtime", env = process.env) {
174 return isInstallContext(context) && isOptionalInstall(undefined, env)
175 ? OPTIONAL_TIMEOUT_MS
176 : DEFAULT_TIMEOUT_MS;
177 }
178
179 function defaultStallMs(context = "runtime", env = process.env) {
180 return isInstallContext(context) && isOptionalInstall(undefined, env)
181 ? OPTIONAL_STALL_MS
182 : DEFAULT_STALL_MS;
183 }
184
185 function maxAttempts(context = "runtime", env = process.env) {
186 return isInstallContext(context) && isOptionalInstall(undefined, env)
187 ? OPTIONAL_MAX_ATTEMPTS
188 : MAX_ATTEMPTS;
189 }
190
191 function binaryPaths() {
192 const { codewhale, codew } = detectBinaryNames();
193 const releaseDir = releaseBinaryDirectory();
194 return {
195 codewhale: {
196 asset: codewhale,
197 target: path.join(releaseDir, process.platform === "win32" ? "codewhale.exe" : "codewhale"),
198 },
199 codew: {
200 asset: codew,
201 target: path.join(releaseDir, process.platform === "win32" ? "codew.exe" : "codew"),
202 },
203 };
204 } // single binary — no tui asset (v0.9.5+)
205
206 // ────────────────────────────────────────────────────────────────────────────
207 // Logging / progress
208 // ────────────────────────────────────────────────────────────────────────────
209
210 function isQuietInstall(env = process.env) {
211 if (
212 env.CODEWHALE_QUIET_INSTALL === "1" ||
213 env.DEEPSEEK_TUI_QUIET_INSTALL === "1"
214 ) {
215 return true;
216 }
217 const level = (env.npm_config_loglevel || "").toLowerCase();
218 return level === "silent" || level === "error";
219 }
220
221 function logInfo(message) {
222 if (isQuietInstall()) {
223 return;
224 }
225 process.stderr.write(`codewhale: ${message}\n`);
226 }
227
228 function installFailureHint(error) {
229 const message = error && error.message ? String(error.message) : "";
230 const code = error && error.code ? String(error.code) : "";
231 const releaseBase =
232 process.env.CODEWHALE_RELEASE_BASE_URL ||
233 process.env.DEEPSEEK_TUI_RELEASE_BASE_URL ||
234 process.env.DEEPSEEK_RELEASE_BASE_URL;
235 const networkMarkers = [
236 "github.com",
237 "ENOTFOUND",
238 "EAI_AGAIN",
239 "ETIMEDOUT",
240 "ECONNRESET",
241 "ENETUNREACH",
242 "EHOSTUNREACH",
243 "EDOWNLOADTIMEOUT",
244 ];
245 const looksLikeNetworkDownloadFailure = networkMarkers.some(
246 (marker) => message.includes(marker) || code === marker,
247 );
248 if (!looksLikeNetworkDownloadFailure) {
249 return "";
250 }
251
252 if (releaseBase) {
253 return [
254 "codewhale install hint:",
255 ` CODEWHALE_RELEASE_BASE_URL resolves to ${releaseBase}`,
256 " Verify that this directory contains codewhale-artifacts-sha256.txt",
257 " plus the codewhale/codew binary assets for your platform (single binary).",
258 ].join("\n");
259 }
260
261 return [
262 "codewhale install hint:",
263 " The npm package downloads prebuilt binaries from GitHub Releases.",
264 " On Linux x64 it also probes the CNB first-party checksum manifest and uses",
265 " the first source whose HTTP response and manifest validate.",
266 " If both are unavailable, mirror the release assets and set:",
267 " CODEWHALE_RELEASE_BASE_URL=https://<mirror>/<release-asset-directory>/",
268 " or CODEWHALE_USE_CNB_MIRROR=1 on Linux x64.",
269 " The directory must contain codewhale-artifacts-sha256.txt and the platform binaries.",
270 " See docs/INSTALL.md#npm-binary-download-times-out.",
271 ].join("\n");
272 }
273
274 function envInt(name, fallback, env = process.env) {
275 const raw = env[name];
276 if (!raw) {
277 return fallback;
278 }
279 const parsed = Number.parseInt(String(raw).trim(), 10);
280 if (!Number.isFinite(parsed) || parsed <= 0) {
281 return fallback;
282 }
283 return parsed;
284 }
285
286 function downloadTimeoutMs(context = "runtime", env = process.env) {
287 return envInt(
288 "CODEWHALE_DOWNLOAD_TIMEOUT_MS",
289 envInt(
290 "DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS",
291 envInt("DEEPSEEK_DOWNLOAD_TIMEOUT_MS", defaultTimeoutMs(context, env), env),
292 env,
293 ),
294 env,
295 );
296 }
297
298 function downloadStallMs(context = "runtime", env = process.env) {
299 return envInt(
300 "CODEWHALE_DOWNLOAD_STALL_MS",
301 envInt(
302 "DEEPSEEK_TUI_DOWNLOAD_STALL_MS",
303 envInt("DEEPSEEK_DOWNLOAD_STALL_MS", defaultStallMs(context, env), env),
304 env,
305 ),
306 env,
307 );
308 }
309
310 function formatMb(bytes) {
311 return (bytes / (1024 * 1024)).toFixed(0);
312 }
313
314 function createProgressReporter(assetName, totalBytes) {
315 if (isQuietInstall()) {
316 return { onChunk: () => {}, finish: () => {} };
317 }
318 const isTty = !!process.stderr.isTTY;
319 const interactive = isTty;
320 const tickBytes = interactive ? 1 * 1024 * 1024 : 5 * 1024 * 1024;
321 const tickMs = 2_000;
322
323 let received = 0;
324 let lastBytesPrinted = 0;
325 let lastTimePrinted = 0;
326 let everPrinted = false;
327
328 const render = (final) => {
329 if (totalBytes && totalBytes > 0) {
330 const pct = Math.min(100, Math.round((received / totalBytes) * 100));
331 const line = `codewhale: downloading ${assetName}: ${formatMb(received)} / ${formatMb(totalBytes)} MB (${pct}%)`;
332 if (interactive) {
333 process.stderr.write(`${line}\r`);
334 } else {
335 process.stderr.write(`${line}\n`);
336 }
337 } else {
338 const line = `codewhale: downloading ${assetName}: ${formatMb(received)} MB downloaded`;
339 if (interactive) {
340 process.stderr.write(`${line}\r`);
341 } else {
342 process.stderr.write(`${line}\n`);
343 }
344 }
345 everPrinted = true;
346 lastBytesPrinted = received;
347 lastTimePrinted = Date.now();
348 };
349
350 return {
351 onChunk(chunkLen) {
352 received += chunkLen;
353 const now = Date.now();
354 if (
355 received - lastBytesPrinted >= tickBytes ||
356 (interactive && now - lastTimePrinted >= tickMs)
357 ) {
358 render(false);
359 }
360 },
361 finish() {
362 // Final line — always render once.
363 render(true);
364 if (interactive && everPrinted) {
365 // Move past the carriage-return line and emit a "done" footer.
366 process.stderr.write("\n");
367 }
368 process.stderr.write(`codewhale: ${assetName} ... done.\n`);
369 },
370 };
371 }
372
373 // ────────────────────────────────────────────────────────────────────────────
374 // Proxy support (HTTPS_PROXY / HTTP_PROXY / NO_PROXY) — pure Node, CONNECT
375 // tunnel + TLS upgrade for HTTPS targets.
376 // ────────────────────────────────────────────────────────────────────────────
377
378 function getProxyUrl(targetUrl) {
379 const isHttps = targetUrl.protocol === "https:";
380 const candidates = isHttps
381 ? ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]
382 : ["HTTP_PROXY", "http_proxy"];
383 for (const name of candidates) {
384 const raw = process.env[name];
385 if (raw && String(raw).trim() !== "") {
386 return String(raw).trim();
387 }
388 }
389 return null;
390 }
391
392 function shouldBypassProxy(host) {
393 const raw = process.env.NO_PROXY || process.env.no_proxy;
394 if (!raw) {
395 return false;
396 }
397 const lower = String(host).toLowerCase();
398 for (const part of String(raw).split(",")) {
399 const entry = part.trim().toLowerCase();
400 if (!entry) {
401 continue;
402 }
403 if (entry === "*") {
404 return true;
405 }
406 // Strip leading dot and any explicit port.
407 const stripped = entry.replace(/^\./, "").replace(/:.*$/, "");
408 if (!stripped) {
409 continue;
410 }
411 if (lower === stripped || lower.endsWith(`.${stripped}`)) {
412 return true;
413 }
414 }
415 return false;
416 }
417
418 function parseProxy(proxyStr) {
419 // Accept "http://user:pass@host:port" and bare "host:port".
420 const normalized = /^[a-z][a-z0-9+\-.]*:\/\//i.test(proxyStr)
421 ? proxyStr
422 : `http://${proxyStr}`;
423 const u = new URL(normalized);
424 const port = u.port
425 ? Number.parseInt(u.port, 10)
426 : u.protocol === "https:"
427 ? 443
428 : 80;
429 let auth = null;
430 if (u.username) {
431 const user = decodeURIComponent(u.username);
432 const pass = u.password ? decodeURIComponent(u.password) : "";
433 auth = Buffer.from(`${user}:${pass}`).toString("base64");
434 }
435 return {
436 protocol: u.protocol,
437 host: u.hostname,
438 port,
439 auth,
440 raw: proxyStr,
441 };
442 }
443
444 function connectThroughProxy(proxy, targetHost, targetPort, timeoutMs) {
445 return new Promise((resolve, reject) => {
446 const socket = net.connect({ host: proxy.host, port: proxy.port });
447 let settled = false;
448 const fail = (err) => {
449 if (settled) return;
450 settled = true;
451 try {
452 socket.destroy();
453 } catch {
454 // ignore
455 }
456 reject(err);
457 };
458
459 const timer = timeoutMs > 0
460 ? setTimeout(() => fail(new DownloadTimeoutError(
461 `proxy CONNECT to ${proxy.host}:${proxy.port} timed out after ${timeoutMs} ms`,
462 )), timeoutMs)
463 : null;
464
465 socket.once("error", (err) => {
466 if (timer) clearTimeout(timer);
467 // Surface proxy host so the user can fix it.
468 const wrapped = new Error(
469 `proxy connection failed (${proxy.host}:${proxy.port}): ${err.message}`,
470 );
471 wrapped.code = err.code;
472 fail(wrapped);
473 });
474
475 socket.once("connect", () => {
476 const lines = [
477 `CONNECT ${targetHost}:${targetPort} HTTP/1.1`,
478 `Host: ${targetHost}:${targetPort}`,
479 "User-Agent: codewhale-installer",
480 "Proxy-Connection: keep-alive",
481 ];
482 if (proxy.auth) {
483 lines.push(`Proxy-Authorization: Basic ${proxy.auth}`);
484 }
485 const req = `${lines.join("\r\n")}\r\n\r\n`;
486
487 let buf = Buffer.alloc(0);
488 const onData = (chunk) => {
489 buf = Buffer.concat([buf, chunk]);
490 const idx = buf.indexOf("\r\n\r\n");
491 if (idx === -1) {
492 if (buf.length > 16 * 1024) {
493 socket.removeListener("data", onData);
494 fail(new Error(
495 `proxy ${proxy.host}:${proxy.port} returned an oversized response header`,
496 ));
497 }
498 return;
499 }
500 socket.removeListener("data", onData);
501 const head = buf.slice(0, idx).toString("utf8");
502 const firstLine = head.split(/\r?\n/, 1)[0] || "";
503 const m = firstLine.match(/^HTTP\/\d\.\d\s+(\d{3})/);
504 if (!m) {
505 fail(new Error(`proxy ${proxy.host}:${proxy.port} returned invalid CONNECT reply: ${firstLine}`));
506 return;
507 }
508 const code = Number.parseInt(m[1], 10);
509 if (code !== 200) {
510 fail(new Error(
511 `proxy ${proxy.host}:${proxy.port} refused CONNECT to ${targetHost}:${targetPort}: HTTP ${code}`,
512 ));
513 return;
514 }
515 if (timer) clearTimeout(timer);
516 if (settled) return;
517 settled = true;
518 // Any bytes past the header belong to the tunneled stream — but in
519 // practice CONNECT 200 has no body; if it did, we'd lose those bytes
520 // here. Keep it simple: trust well-behaved proxies.
521 resolve(socket);
522 };
523 socket.on("data", onData);
524 socket.write(req, "utf8");
525 });
526 });
527 }
528
529 // ────────────────────────────────────────────────────────────────────────────
530 // HTTP request with timeout, stall detection, and proxy support.
531 // ────────────────────────────────────────────────────────────────────────────
532
533 function httpRequest(rawUrl, opts = {}) {
534 const context =
535 opts.context === undefined || opts.context === null ? "runtime" : opts.context;
536 const totalTimeoutMs =
537 opts.totalTimeoutMs === undefined || opts.totalTimeoutMs === null
538 ? downloadTimeoutMs(context)
539 : opts.totalTimeoutMs;
540 const stallMs =
541 opts.stallMs === undefined || opts.stallMs === null
542 ? downloadStallMs(context)
543 : opts.stallMs;
544
545 return new Promise((resolve, reject) => {
546 let url;
547 try {
548 url = new URL(rawUrl);
549 } catch (err) {
550 reject(new NonRetryableError(`Invalid URL: ${rawUrl} (${err.message})`));
551 return;
552 }
553 if (url.protocol !== "https:" && url.protocol !== "http:") {
554 reject(new NonRetryableError(`Unsupported protocol: ${url.protocol}`));
555 return;
556 }
557
558 const proxyStr = !shouldBypassProxy(url.hostname) ? getProxyUrl(url) : null;
559 const isHttps = url.protocol === "https:";
560 const port = url.port
561 ? Number.parseInt(url.port, 10)
562 : isHttps
563 ? 443
564 : 80;
565
566 let totalTimer = null;
567 let stallTimer = null;
568 let settled = false;
569 let req = null;
570 let res = null;
571 const signal = opts.signal;
572 let onAbort = null;
573
574 const cleanup = () => {
575 if (totalTimer) {
576 clearTimeout(totalTimer);
577 totalTimer = null;
578 }
579 if (stallTimer) {
580 clearTimeout(stallTimer);
581 stallTimer = null;
582 }
583 if (signal && onAbort) {
584 signal.removeEventListener("abort", onAbort);
585 onAbort = null;
586 }
587 };
588
589 const fail = (err) => {
590 if (settled) return;
591 settled = true;
592 cleanup();
593 try {
594 if (req && !req.destroyed) req.destroy();
595 } catch {
596 // ignore
597 }
598 try {
599 if (res && !res.destroyed) res.destroy();
600 } catch {
601 // ignore
602 }
603 reject(err);
604 };
605
606 if (signal) {
607 if (signal.aborted) {
608 reject(abortError());
609 return;
610 }
611 onAbort = function () {
612 fail(abortError());
613 };
614 signal.addEventListener("abort", onAbort);
615 }
616
617 if (totalTimeoutMs > 0) {
618 totalTimer = setTimeout(() => {
619 fail(new DownloadTimeoutError(
620 `download exceeded total timeout of ${totalTimeoutMs} ms ` +
621 `(set CODEWHALE_DOWNLOAD_TIMEOUT_MS to raise it; current stall budget is ${stallMs} ms)`,
622 ));
623 }, totalTimeoutMs);
624 }
625
626 const armStallTimer = () => {
627 if (stallMs <= 0) return;
628 if (stallTimer) clearTimeout(stallTimer);
629 stallTimer = setTimeout(() => {
630 fail(new DownloadTimeoutError(
631 `download stalled — no bytes received for ${stallMs} ms ` +
632 `(set CODEWHALE_DOWNLOAD_STALL_MS to raise it; total budget is ${totalTimeoutMs} ms)`,
633 ));
634 }, stallMs);
635 };
636
637 const launch = (socket) => {
638 const reqOptions = {
639 method: "GET",
640 host: url.hostname,
641 port,
642 path: `${url.pathname}${url.search || ""}`,
643 headers: {
644 Host: url.host,
645 "User-Agent": "codewhale-installer",
646 Accept: "*/*",
647 Connection: "close",
648 },
649 };
650 if (socket) {
651 reqOptions.createConnection = () => socket;
652 if (isHttps) {
653 // Wrap raw TCP socket from CONNECT in TLS.
654 const tlsSocket = tls.connect({
655 socket,
656 servername: url.hostname,
657 ALPNProtocols: ["http/1.1"],
658 });
659 tlsSocket.once("error", (err) => fail(err));
660 reqOptions.createConnection = () => tlsSocket;
661 }
662 }
663 const client = isHttps ? https : http;
664 try {
665 req = client.request(reqOptions, (response) => {
666 res = response;
667 response.pause();
668 armStallTimer();
669 response.on("data", () => {
670 armStallTimer();
671 });
672 response.on("end", () => {
673 cleanup();
674 });
675 response.on("error", (err) => fail(err));
676
677 const status = response.statusCode || 0;
678 if (status >= 300 && status < 400 && response.headers.location) {
679 cleanup();
680 settled = true;
681 response.resume();
682 resolve({ redirect: response.headers.location, response: null });
683 return;
684 }
685 if (status < 200 || status >= 300) {
686 const err = new HttpStatusError(status, rawUrl);
687 // 4xx: non-retryable; 5xx: retryable.
688 if (status >= 400 && status < 500) {
689 err.nonRetryable = true;
690 }
691 fail(err);
692 return;
693 }
694 if (settled) return;
695 settled = true;
696 // Hand the live response stream to the caller.
697 resolve({ redirect: null, response });
698 });
699 req.once("error", (err) => fail(err));
700 req.once("socket", (s) => {
701 // Belt-and-suspenders: surface socket-level errors quickly.
702 s.once("error", (err) => fail(err));
703 });
704 req.end();
705 } catch (err) {
706 fail(err);
707 }
708 };
709
710 if (proxyStr) {
711 let proxy;
712 try {
713 proxy = parseProxy(proxyStr);
714 } catch (err) {
715 fail(new NonRetryableError(
716 `Invalid proxy URL "${proxyStr}": ${err.message}`,
717 ));
718 return;
719 }
720 if (!isHttps) {
721 // Plain HTTP through proxy — send absolute URI, no CONNECT.
722 const client = http;
723 try {
724 req = client.request(
725 {
726 host: proxy.host,
727 port: proxy.port,
728 method: "GET",
729 path: rawUrl,
730 headers: {
731 Host: url.host,
732 "User-Agent": "codewhale-installer",
733 Accept: "*/*",
734 Connection: "close",
735 ...(proxy.auth ? { "Proxy-Authorization": `Basic ${proxy.auth}` } : {}),
736 },
737 },
738 (response) => {
739 res = response;
740 response.pause();
741 armStallTimer();
742 response.on("data", () => armStallTimer());
743 response.on("end", () => cleanup());
744 response.on("error", (err) => fail(err));
745 const status = response.statusCode || 0;
746 if (status >= 300 && status < 400 && response.headers.location) {
747 cleanup();
748 settled = true;
749 response.resume();
750 resolve({ redirect: response.headers.location, response: null });
751 return;
752 }
753 if (status < 200 || status >= 300) {
754 const err = new HttpStatusError(status, rawUrl);
755 if (status >= 400 && status < 500) err.nonRetryable = true;
756 fail(err);
757 return;
758 }
759 if (settled) return;
760 settled = true;
761 resolve({ redirect: null, response });
762 },
763 );
764 req.once("error", (err) => fail(err));
765 req.end();
766 } catch (err) {
767 fail(err);
768 }
769 return;
770 }
771
772 // HTTPS through proxy: CONNECT tunnel + TLS upgrade.
773 connectThroughProxy(proxy, url.hostname, port, Math.max(stallMs, 5_000))
774 .then((tcpSocket) => {
775 if (settled) {
776 try { tcpSocket.destroy(); } catch { /* ignore */ }
777 return;
778 }
779 const tlsSocket = tls.connect({
780 socket: tcpSocket,
781 servername: url.hostname,
782 ALPNProtocols: ["http/1.1"],
783 });
784 tlsSocket.once("error", (err) => fail(err));
785 tlsSocket.once("secureConnect", () => {
786 if (settled) {
787 try { tlsSocket.destroy(); } catch { /* ignore */ }
788 return;
789 }
790 const reqOptions = {
791 method: "GET",
792 createConnection: () => tlsSocket,
793 path: `${url.pathname}${url.search || ""}`,
794 headers: {
795 Host: url.host,
796 "User-Agent": "codewhale-installer",
797 Accept: "*/*",
798 Connection: "close",
799 },
800 };
801 try {
802 req = https.request(reqOptions, (response) => {
803 res = response;
804 response.pause();
805 armStallTimer();
806 response.on("data", () => armStallTimer());
807 response.on("end", () => cleanup());
808 response.on("error", (err) => fail(err));
809 const status = response.statusCode || 0;
810 if (status >= 300 && status < 400 && response.headers.location) {
811 cleanup();
812 settled = true;
813 response.resume();
814 resolve({ redirect: response.headers.location, response: null });
815 return;
816 }
817 if (status < 200 || status >= 300) {
818 const err = new HttpStatusError(status, rawUrl);
819 if (status >= 400 && status < 500) err.nonRetryable = true;
820 fail(err);
821 return;
822 }
823 if (settled) return;
824 settled = true;
825 resolve({ redirect: null, response });
826 });
827 req.once("error", (err) => fail(err));
828 req.end();
829 } catch (err) {
830 fail(err);
831 }
832 });
833 })
834 .catch((err) => fail(err));
835 return;
836 }
837
838 // No proxy — direct connection.
839 launch(null);
840 });
841 }
842
843 // ────────────────────────────────────────────────────────────────────────────
844 // Retry wrapper
845 // ────────────────────────────────────────────────────────────────────────────
846
847 function isRetryable(err) {
848 if (!err) return false;
849 if (isAbortError(err)) return false;
850 if (err.nonRetryable) return false;
851 if (err.retryable === true) return true;
852 if (err instanceof NonRetryableError) return false;
853 if (err instanceof DownloadTimeoutError) return true;
854 // withRetry() rethrows a plain Error while preserving name/status, so wrapped
855 // HTTP 5xx failures still classify as retryable during optional postinstall.
856 if (
857 (err instanceof HttpStatusError || err.name === "HttpStatusError") &&
858 typeof err.status === "number"
859 ) {
860 return err.status >= 500;
861 }
862 if (err.code && RETRYABLE_NET_CODES.has(err.code)) return true;
863 // Network-flavored messages we may see without a code.
864 const msg = String(err.message || "").toLowerCase();
865 if (msg.includes("network") && msg.includes("unreachable")) return true;
866 if (msg.includes("socket hang up")) return true;
867 if (msg.includes("aborted")) return true;
868 return false;
869 }
870
871 function backoffDelay(attempt) {
872 // attempt is 1-indexed; first retry waits ~1s.
873 const base = BASE_BACKOFF_MS * 2 ** (attempt - 1);
874 const jitter = (Math.random() * 0.4 - 0.2) * base; // ±20%
875 return Math.max(0, Math.round(base + jitter));
876 }
877
878 function sleep(ms) {
879 return new Promise((resolve) => setTimeout(resolve, ms));
880 }
881
882 function sleepWithSignal(ms, signal) {
883 if (!signal) {
884 return sleep(ms);
885 }
886 if (signal.aborted) {
887 return Promise.reject(abortError());
888 }
889 return new Promise((resolve, reject) => {
890 let timer = null;
891 const cleanup = () => {
892 if (timer) {
893 clearTimeout(timer);
894 timer = null;
895 }
896 signal.removeEventListener("abort", onAbort);
897 };
898 const onAbort = () => {
899 cleanup();
900 reject(abortError());
901 };
902 timer = setTimeout(() => {
903 cleanup();
904 resolve();
905 }, ms);
906 signal.addEventListener("abort", onAbort, { once: true });
907 });
908 }
909
910 async function withRetry(label, fn, context, signal) {
911 const resolvedContext =
912 context === undefined || context === null ? "runtime" : context;
913 let lastErr;
914 const attemptLimit = maxAttempts(resolvedContext);
915 for (let attempt = 1; attempt <= attemptLimit; attempt++) {
916 if (signal && signal.aborted) {
917 throw abortError();
918 }
919 try {
920 return await fn(attempt);
921 } catch (err) {
922 lastErr = err;
923 if (isAbortError(err) || !isRetryable(err) || attempt === attemptLimit) {
924 break;
925 }
926 const wait = backoffDelay(attempt);
927 logInfo(
928 `${label} failed (attempt ${attempt}/${attemptLimit}): ${err.message}; retrying in ${wait} ms`,
929 );
930 if (attempt === 1) {
931 const hint = installFailureHint(err);
932 if (hint) {
933 process.stderr.write(`${hint}\n`);
934 }
935 }
936 await sleepWithSignal(wait, signal);
937 if (signal && signal.aborted) {
938 throw abortError();
939 }
940 }
941 }
942 const msg = lastErr && lastErr.message ? lastErr.message : String(lastErr);
943 const wrapped = new Error(
944 `${label} failed after ${attemptLimit} attempt(s): ${msg}`,
945 );
946 // Preserve retry classification metadata because the install entrypoint uses
947 // the wrapped error to decide whether optional postinstall may ignore it.
948 if (lastErr && lastErr.code) {
949 wrapped.code = lastErr.code;
950 }
951 if (lastErr && lastErr.name) {
952 wrapped.name = lastErr.name;
953 }
954 if (lastErr && typeof lastErr.status === "number") {
955 wrapped.status = lastErr.status;
956 }
957 if (lastErr && lastErr.nonRetryable) {
958 wrapped.nonRetryable = true;
959 }
960 if (lastErr && lastErr.stack) {
961 wrapped.cause = lastErr;
962 }
963 throw wrapped;
964 }
965
966 // ────────────────────────────────────────────────────────────────────────────
967 // Public download primitives (now retry + progress aware)
968 // ────────────────────────────────────────────────────────────────────────────
969
970 async function followRedirects(url, opts = {}) {
971 const maxRedirects = 10;
972 let current = url;
973 for (let hop = 0; hop < maxRedirects; hop++) {
974 const result = await httpRequest(current, opts);
975 if (result.redirect) {
976 try {
977 current = new URL(result.redirect, current).toString();
978 } catch {
979 current = result.redirect;
980 }
981 continue;
982 }
983 return result;
984 }
985 throw new NonRetryableError(`too many redirects starting at ${url}`);
986 }
987
988 function streamToFile(response, destination, progress, signal) {
989 return new Promise((resolve, reject) => {
990 const sink = createWriteStream(destination);
991 let done = false;
992 const onAbort = () => {
993 try {
994 response.destroy();
995 } catch {
996 // ignore
997 }
998 finish(abortError());
999 };
1000 const finish = (err) => {
1001 if (done) return;
1002 done = true;
1003 if (signal) {
1004 signal.removeEventListener("abort", onAbort);
1005 }
1006 if (err) {
1007 sink.destroy();
1008 reject(err);
1009 } else {
1010 resolve();
1011 }
1012 };
1013 response.on("data", (chunk) => {
1014 if (progress) progress.onChunk(chunk.length);
1015 });
1016 response.on("error", (err) => finish(err));
1017 sink.on("error", (err) => finish(err));
1018 sink.on("finish", () => finish(null));
1019 if (signal) {
1020 if (signal.aborted) {
1021 onAbort();
1022 return;
1023 }
1024 signal.addEventListener("abort", onAbort, { once: true });
1025 }
1026 response.pipe(sink);
1027 });
1028 }
1029
1030 async function download(url, destination, options = {}) {
1031 await mkdir(path.dirname(destination), { recursive: true });
1032 const assetName = options.assetName || path.basename(destination);
1033 const context =
1034 options.context === undefined || options.context === null ? "runtime" : options.context;
1035 const attemptLimit = maxAttempts(context);
1036 await withRetry(`download ${assetName}`, async (attempt) => {
1037 const result = await followRedirects(url, {
1038 context,
1039 totalTimeoutMs: downloadTimeoutMs(context),
1040 stallMs: downloadStallMs(context),
1041 signal: options.signal,
1042 });
1043 const response = result.response;
1044 const lenHeader = response.headers["content-length"];
1045 const total = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
1046 const progress = createProgressReporter(assetName, Number.isFinite(total) ? total : 0);
1047 if (attempt > 1) {
1048 logInfo(`retry attempt ${attempt}/${attemptLimit} for ${assetName}`);
1049 }
1050 try {
1051 await streamToFile(response, destination, progress, options.signal);
1052 } catch (err) {
1053 // Ensure we don't leave a partial file confusing future attempts.
1054 try {
1055 await unlink(destination);
1056 } catch {
1057 // ignore
1058 }
1059 throw err;
1060 }
1061 progress.finish();
1062 }, context, options.signal);
1063 }
1064
1065 async function downloadText(url, options = {}) {
1066 const context =
1067 options.context === undefined || options.context === null ? "runtime" : options.context;
1068 const totalTimeoutMs =
1069 options.totalTimeoutMs === undefined || options.totalTimeoutMs === null
1070 ? downloadTimeoutMs(context)
1071 : options.totalTimeoutMs;
1072 const stallMs =
1073 options.stallMs === undefined || options.stallMs === null
1074 ? downloadStallMs(context)
1075 : options.stallMs;
1076 return withRetry(`fetch ${url}`, async () => {
1077 const result = await followRedirects(url, {
1078 context,
1079 totalTimeoutMs,
1080 stallMs,
1081 signal: options.signal,
1082 });
1083 const response = result.response;
1084 response.setEncoding("utf8");
1085 // NOTE: do NOT use `for await (const chunk of response)` here.
1086 // `httpRequest` attaches a `data` listener on the response to re-arm
1087 // the stall timer, which puts the stream in flowing mode. The async
1088 // iterator expects paused mode and will silently miss every chunk —
1089 // this manifested as an empty checksum manifest in the npm wrapper
1090 // smoke test ("Checksum manifest is missing <asset>"). Subscribing
1091 // to `data` events directly stacks alongside the stall listener and
1092 // both fire per chunk, so we collect the body correctly without
1093 // disturbing the stall detection.
1094 return new Promise((resolve, reject) => {
1095 const chunks = [];
1096 let settled = false;
1097 const signal = options.signal;
1098 const cleanup = () => {
1099 if (signal) {
1100 signal.removeEventListener("abort", onAbort);
1101 }
1102 };
1103 const finish = (error, value) => {
1104 if (settled) return;
1105 settled = true;
1106 cleanup();
1107 if (error) {
1108 reject(error);
1109 } else {
1110 resolve(value);
1111 }
1112 };
1113 const onAbort = () => {
1114 try {
1115 response.destroy();
1116 } catch {
1117 // ignore
1118 }
1119 finish(abortError());
1120 };
1121 response.on("data", (chunk) => {
1122 chunks.push(chunk);
1123 });
1124 response.on("end", () => {
1125 finish(null, chunks.join(""));
1126 });
1127 response.on("error", (error) => finish(error));
1128 if (signal) {
1129 if (signal.aborted) {
1130 onAbort();
1131 return;
1132 }
1133 signal.addEventListener("abort", onAbort, { once: true });
1134 }
1135 response.resume();
1136 });
1137 }, context, options.signal);
1138 }
1139
1140 async function readLocalVersion(file) {
1141 return readFile(file, "utf8").catch(() => "");
1142 }
1143
1144 async function fileExists(file) {
1145 try {
1146 const result = await stat(file);
1147 return result.isFile();
1148 } catch {
1149 return false;
1150 }
1151 }
1152
1153 function parseChecksumManifest(text) {
1154 const checksums = new Map();
1155 for (const line of text.split(/\r?\n/)) {
1156 const trimmed = line.trim();
1157 if (!trimmed) {
1158 continue;
1159 }
1160 const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
1161 if (!match) {
1162 throw new NonRetryableError(`Invalid checksum manifest line: ${trimmed}`);
1163 }
1164 checksums.set(match[2], match[1].toLowerCase());
1165 }
1166 return checksums;
1167 }
1168
1169 async function sha256File(filePath) {
1170 const content = await readFile(filePath);
1171 return crypto.createHash("sha256").update(content).digest("hex");
1172 }
1173
1174 async function verifyChecksum(filePath, assetName, checksums, sourceLabel) {
1175 const expected = checksums.get(assetName);
1176 if (!expected) {
1177 const from = sourceLabel ? ` from ${sourceLabel}` : "";
1178 throw new NonRetryableError(`Checksum manifest is missing ${assetName}${from}`);
1179 }
1180 const actual = await sha256File(filePath);
1181 if (actual !== expected) {
1182 // Bytes are corrupted; another fetch is unlikely to help without a fix
1183 // upstream. Mark non-retryable. Never mix a locked source's bytes with
1184 // another source's manifest.
1185 const from = sourceLabel ? ` from ${sourceLabel}` : "";
1186 throw new NonRetryableError(
1187 `Checksum mismatch for ${assetName}${from}: expected ${expected}, got ${actual}`,
1188 );
1189 }
1190 }
1191
1192 async function checksumMatches(filePath, assetName, checksums) {
1193 const expected = checksums.get(assetName);
1194 if (!expected) {
1195 throw new NonRetryableError(`Checksum manifest is missing ${assetName}`);
1196 }
1197 const actual = await sha256File(filePath);
1198 return actual === expected;
1199 }
1200
1201 function formatSourceReceipt(source, version) {
1202 return [
1203 `source=${source.id}`,
1204 `label=${source.label}`,
1205 `base=${source.baseUrl}`,
1206 `version=${version}`,
1207 "",
1208 ].join("\n");
1209 }
1210
1211 async function writeSourceReceipt(targetPath, source, version) {
1212 await writeFile(`${targetPath}.source`, formatSourceReceipt(source, version), "utf8");
1213 }
1214
1215 function assertManifestHasAssets(checksums, requiredAssets, label) {
1216 const missing = [];
1217 for (let i = 0; i < requiredAssets.length; i += 1) {
1218 const asset = requiredAssets[i];
1219 if (!checksums.has(asset)) {
1220 missing.push(asset);
1221 }
1222 }
1223 if (missing.length > 0) {
1224 throw new NonRetryableError(
1225 `${label} checksum manifest is missing ${missing.join(", ")}`,
1226 );
1227 }
1228 }
1229
1230 async function fetchChecksumManifest(url, options) {
1231 const fetchText = options.fetchText || downloadText;
1232 const text = await fetchText(url, {
1233 context: options.context,
1234 signal: options.signal,
1235 totalTimeoutMs:
1236 options.totalTimeoutMs === undefined || options.totalTimeoutMs === null
1237 ? MANIFEST_TIMEOUT_MS
1238 : options.totalTimeoutMs,
1239 stallMs:
1240 options.stallMs === undefined || options.stallMs === null
1241 ? MANIFEST_STALL_MS
1242 : options.stallMs,
1243 });
1244 return parseChecksumManifest(text);
1245 }
1246
1247 async function loadSourceManifest(source, options) {
1248 const url = releaseAssetUrlFromBase(CHECKSUM_MANIFEST, source.baseUrl);
1249 const checksums = await fetchChecksumManifest(url, options);
1250 assertManifestHasAssets(checksums, options.requiredAssets || [], source.label);
1251 return {
1252 id: source.id,
1253 label: source.label,
1254 baseUrl: source.baseUrl,
1255 checksums,
1256 };
1257 }
1258
1259 function aggregateSourceErrors(options, failures) {
1260 const parts = [];
1261 let allNonRetryable = failures.length > 0;
1262 let anyRetryable = false;
1263 for (let i = 0; i < failures.length; i += 1) {
1264 const failure = failures[i];
1265 const message =
1266 failure.error && failure.error.message
1267 ? failure.error.message
1268 : String(failure.error);
1269 parts.push(`${failure.source.label}: ${message}`);
1270 if (
1271 !(
1272 failure.error &&
1273 (failure.error.nonRetryable || failure.error instanceof NonRetryableError)
1274 )
1275 ) {
1276 allNonRetryable = false;
1277 }
1278 if (isRetryable(failure.error)) {
1279 anyRetryable = true;
1280 }
1281 }
1282 const err = new Error(
1283 `No usable first-party release source for v${options.version}. ${parts.join("; ")}`,
1284 );
1285 if (allNonRetryable) {
1286 err.nonRetryable = true;
1287 } else if (anyRetryable) {
1288 err.retryable = true;
1289 }
1290 return err;
1291 }
1292
1293 async function raceFirstPartyManifests(sources, options) {
1294 logInfo(
1295 `probing ${sources.map((source) => source.label).join(" and ")} checksum manifests`,
1296 );
1297 const controllers = sources.map(() => new AbortController());
1298
1299 return new Promise((resolve, reject) => {
1300 let remaining = sources.length;
1301 const failures = [];
1302 let settled = false;
1303
1304 const finishSuccess = (index, selected) => {
1305 if (settled) {
1306 return;
1307 }
1308 settled = true;
1309 for (let i = 0; i < controllers.length; i += 1) {
1310 if (i !== index) {
1311 try {
1312 controllers[i].abort();
1313 } catch {
1314 // ignore
1315 }
1316 }
1317 }
1318 logInfo(`selected ${selected.label} for v${options.version}`);
1319 resolve(selected);
1320 };
1321
1322 const finishFailure = (source, error) => {
1323 if (settled) {
1324 return;
1325 }
1326 if (isAbortError(error)) {
1327 remaining -= 1;
1328 if (remaining === 0) {
1329 settled = true;
1330 reject(aggregateSourceErrors(options, failures));
1331 }
1332 return;
1333 }
1334 failures.push({ source, error });
1335 remaining -= 1;
1336 if (remaining === 0) {
1337 settled = true;
1338 reject(aggregateSourceErrors(options, failures));
1339 }
1340 };
1341
1342 for (let i = 0; i < sources.length; i += 1) {
1343 const source = sources[i];
1344 loadSourceManifest(source, {
1345 context: options.context,
1346 fetchText: options.fetchText,
1347 requiredAssets: options.requiredAssets,
1348 signal: controllers[i].signal,
1349 }).then(
1350 (selected) => finishSuccess(i, selected),
1351 (error) => finishFailure(source, error),
1352 );
1353 }
1354 });
1355 }
1356
1357 async function selectReleaseSource(options) {
1358 const version = options.version;
1359 const repo = options.repo || "Hmbown/CodeWhale";
1360 const env = options.env || process.env;
1361 const platform =
1362 options.platform === undefined || options.platform === null
1363 ? os.platform()
1364 : options.platform;
1365 const arch =
1366 options.arch === undefined || options.arch === null ? os.arch() : options.arch;
1367 const requiredAssets = options.requiredAssets || [];
1368 const context =
1369 options.context === undefined || options.context === null
1370 ? "runtime"
1371 : options.context;
1372 const fetchText = options.fetchText;
1373 const override = explicitReleaseBase(env);
1374 if (override) {
1375 logInfo(`using explicit release base for v${version}`);
1376 return loadSourceManifest(
1377 {
1378 id: "override",
1379 label: "explicit release base",
1380 baseUrl: override,
1381 },
1382 {
1383 context,
1384 fetchText,
1385 requiredAssets,
1386 },
1387 );
1388 }
1389 if (usesCnbMirror(env)) {
1390 assertCnbMirrorSupportedPlatform(platform, arch);
1391 logInfo(`using CNB first-party mirror for v${version}`);
1392 return loadSourceManifest(
1393 {
1394 id: "cnb",
1395 label: "CNB first-party mirror",
1396 baseUrl: cnbReleaseBaseUrl(version),
1397 },
1398 {
1399 context,
1400 fetchText,
1401 requiredAssets,
1402 },
1403 );
1404 }
1405 if (shouldRaceFirstPartyMirrors(env, platform, arch)) {
1406 const sources = options.sources || firstPartyReleaseSources(version, repo);
1407 return raceFirstPartyManifests(sources, {
1408 version,
1409 context,
1410 fetchText,
1411 requiredAssets,
1412 });
1413 }
1414 logInfo(`using GitHub Releases for v${version}`);
1415 return loadSourceManifest(
1416 {
1417 id: "github",
1418 label: "GitHub Releases",
1419 baseUrl: githubReleaseBaseUrl(version, repo),
1420 },
1421 {
1422 context,
1423 fetchText,
1424 requiredAssets,
1425 },
1426 );
1427 }
1428
1429 async function loadChecksums(version, repo, options = {}) {
1430 return parseChecksumManifest(await downloadText(checksumManifestUrl(version, repo), options));
1431 }
1432
1433 function existingBinaryCandidates(targetPath, assetName) {
1434 const candidates = [targetPath];
1435 const assetPath = path.join(path.dirname(targetPath), assetName);
1436 if (assetPath !== targetPath) {
1437 candidates.push(assetPath);
1438 }
1439 return candidates;
1440 }
1441
1442 async function adoptExistingBinaryIfValid(targetPath, assetName, version, getChecksums, marker) {
1443 const candidates = [];
1444 for (const candidate of existingBinaryCandidates(targetPath, assetName)) {
1445 if (await fileExists(candidate)) {
1446 candidates.push(candidate);
1447 }
1448 }
1449 if (candidates.length === 0) {
1450 return false;
1451 }
1452
1453 const checksums = await getChecksums();
1454 for (const candidate of candidates) {
1455 if (!(await checksumMatches(candidate, assetName, checksums))) {
1456 continue;
1457 }
1458 preflightGlibc(candidate);
1459 if (candidate !== targetPath) {
1460 await rename(candidate, targetPath);
1461 }
1462 if (process.platform !== "win32") {
1463 await chmod(targetPath, 0o755);
1464 }
1465 await writeFile(marker, String(version), "utf8");
1466 return true;
1467 }
1468 return false;
1469 }
1470
1471 async function resolveLockedSource(options) {
1472 let sourceId = options.sourceId;
1473 let sourceLabel = options.sourceLabel;
1474 let baseUrl = options.baseUrl;
1475 if (options.getSource) {
1476 const source = await options.getSource();
1477 sourceId = source.id;
1478 sourceLabel = source.label;
1479 baseUrl = source.baseUrl;
1480 }
1481 return { sourceId, sourceLabel, baseUrl };
1482 }
1483
1484 async function ensureBinary(targetPath, assetName, version, repo, getChecksums, options = {}) {
1485 const marker = `${targetPath}.version`;
1486 const env = options.env || process.env;
1487 const downloadIfNeeded = shouldForceDownload(env);
1488 if (!downloadIfNeeded) {
1489 const existing = await fileExists(targetPath);
1490 if (existing) {
1491 const markerVersion = await readLocalVersion(marker);
1492 if (markerVersion === String(version)) {
1493 return targetPath;
1494 }
1495 }
1496 if (await adoptExistingBinaryIfValid(targetPath, assetName, version, getChecksums, marker)) {
1497 const locked = await resolveLockedSource(options);
1498 if (locked.sourceId) {
1499 await writeSourceReceipt(targetPath, {
1500 id: locked.sourceId,
1501 label: locked.sourceLabel || locked.sourceId,
1502 baseUrl: locked.baseUrl || "",
1503 }, version);
1504 }
1505 return targetPath;
1506 }
1507 }
1508 const checksums = await getChecksums();
1509 const locked = await resolveLockedSource(options);
1510 const url = locked.baseUrl
1511 ? releaseAssetUrlFromBase(assetName, locked.baseUrl)
1512 : releaseAssetUrl(assetName, version, repo);
1513 const destination = `${targetPath}.${process.pid}.${Date.now()}.download`;
1514 const downloadFn = options.download || download;
1515 const progressName = locked.sourceLabel
1516 ? `${assetName} from ${locked.sourceLabel}`
1517 : assetName;
1518 await downloadFn(url, destination, { assetName: progressName, context: options.context });
1519 try {
1520 await verifyChecksum(destination, assetName, checksums, locked.sourceLabel);
1521 preflightGlibc(destination);
1522 } catch (error) {
1523 await unlink(destination).catch(() => {});
1524 throw error;
1525 }
1526 if (process.platform !== "win32") {
1527 await chmod(destination, 0o755);
1528 }
1529 await rename(destination, targetPath);
1530 await writeFile(marker, String(version), "utf8");
1531 if (locked.sourceId) {
1532 await writeSourceReceipt(targetPath, {
1533 id: locked.sourceId,
1534 label: locked.sourceLabel || locked.sourceId,
1535 baseUrl: locked.baseUrl || "",
1536 }, version);
1537 }
1538 return targetPath;
1539 }
1540
1541 // Optional install may only downgrade retryable download failures to warnings.
1542 // Unsupported platforms, checksum mismatches, glibc compatibility errors, and
1543 // malformed release metadata must still fail with actionable diagnostics.
1544 function shouldIgnoreInstallFailure(
1545 context,
1546 error,
1547 argv = process.argv.slice(2),
1548 env = process.env,
1549 ) {
1550 return isInstallContext(context) && isOptionalInstall(argv, env) && isRetryable(error);
1551 }
1552
1553 async function run(options = {}) {
1554 const context =
1555 options.context === undefined || options.context === null ? "runtime" : options.context;
1556 const env = options.env || process.env;
1557 if (shouldDisableInstall(env)) {
1558 return;
1559 }
1560 if (shouldSkipOptionalPostinstall(context, process.argv.slice(2), env)) {
1561 logInfo(
1562 "pnpm optional postinstall detected; skipping install-time download. The binary will be checked on first run.",
1563 );
1564 return;
1565 }
1566 const version = resolvePackageVersion(pkg, env);
1567 const repo = resolveRepo(env);
1568 const paths = options.paths || binaryPaths();
1569 const releaseDir = options.releaseDir || releaseBinaryDirectory();
1570 await mkdir(releaseDir, { recursive: true });
1571
1572 let sourcePromise;
1573 const getSource = () => {
1574 if (!sourcePromise) {
1575 sourcePromise = selectReleaseSource({
1576 version,
1577 repo,
1578 requiredAssets: [paths.codewhale.asset, paths.codew.asset],
1579 context,
1580 env,
1581 platform: options.platform,
1582 arch: options.arch,
1583 sources: options.sources,
1584 fetchText: options.fetchText,
1585 });
1586 }
1587 return sourcePromise;
1588 };
1589 const getChecksums = () => getSource().then((source) => source.checksums);
1590
1591 await Promise.all([
1592 ensureBinary(paths.codewhale.target, paths.codewhale.asset, version, repo, getChecksums, {
1593 context,
1594 getSource,
1595 download: options.download,
1596 env,
1597 }),
1598 ensureBinary(paths.codew.target, paths.codew.asset, version, repo, getChecksums, {
1599 context,
1600 getSource,
1601 download: options.download,
1602 env,
1603 }),
1604 ]); // single binary
1605 }
1606
1607 async function getBinaryPath(name) {
1608 await run({ context: "runtime" });
1609 const paths = binaryPaths();
1610 if (name === "codewhale") {
1611 return paths.codewhale.target;
1612 }
1613 if (name === "codew") {
1614 return paths.codew.target;
1615 }
1616 if (name === "codewhale-tui") {
1617 // v0.9.5 single-binary: codewhale-tui is now an alias to codewhale for backwards compat
1618 return paths.codewhale.target;
1619 }
1620 throw new Error(`Unknown binary: ${name}`);
1621 }
1622
1623 module.exports = {
1624 getBinaryPath,
1625 installFailureHint,
1626 run,
1627 _internal: {
1628 resolvePackageVersion,
1629 resolveRepo,
1630 isOptionalInstall,
1631 shouldForceDownload,
1632 shouldDisableInstall,
1633 isQuietInstall,
1634 adoptExistingBinaryIfValid,
1635 shouldIgnoreInstallFailure,
1636 shouldSkipOptionalPostinstall,
1637 httpRequest,
1638 defaultTimeoutMs,
1639 defaultStallMs,
1640 downloadTimeoutMs,
1641 downloadStallMs,
1642 binaryPaths,
1643 ensureBinary,
1644 maxAttempts,
1645 withRetry,
1646 selectReleaseSource,
1647 downloadText,
1648 download,
1649 parseChecksumManifest,
1650 MANIFEST_TIMEOUT_MS,
1651 MANIFEST_STALL_MS,
1652 },
1653 };
1654
1655 if (require.main === module) {
1656 run({ context: "install" }).catch((error) => {
1657 console.error("codewhale install failed:", error.message);
1658 const hint = installFailureHint(error);
1659 if (hint) {
1660 console.error(hint);
1661 }
1662 if (shouldIgnoreInstallFailure("install", error)) {
1663 console.error(
1664 "Optional install enabled; continuing without a usable binary. The download will be retried on first run.",
1665 );
1666 process.exit(0);
1667 }
1668 process.exit(1);
1669 });
1670 }
1671
1671 lines JAVASCRIPT