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