fix(npm): make postinstall download failures recoverable

This commit is contained in:
Hunter Bown
2026-05-07 15:16:17 -05:00
parent 74b2f2f911
commit 3f3395e00e
4 changed files with 222 additions and 39 deletions
+9 -5
View File
@@ -17,8 +17,10 @@ npm install deepseek-tui
npx deepseek-tui --help
```
`postinstall` downloads platform binaries into `bin/downloads/` and exposes
`deepseek` and `deepseek-tui` commands.
`postinstall` tries to download platform binaries into `bin/downloads/` and
exposes `deepseek` and `deepseek-tui` commands. If GitHub release assets are
temporarily unreachable, install continues and the wrapper retries the download
on first run.
## First run
@@ -60,8 +62,9 @@ Prebuilt binaries for the GitHub release are downloaded automatically:
- Windows x64
Other platform/architecture combinations (musl, riscv64, FreeBSD, …) aren't
shipped as prebuilts. The `postinstall` will exit with a clear error pointing
you at `cargo install deepseek-tui-cli deepseek-tui --locked` and the full
shipped as prebuilts. Unsupported platforms, checksum failures, and glibc
compatibility problems still fail with a clear error pointing you at
`cargo install deepseek-tui-cli deepseek-tui --locked` and the full
[docs/INSTALL.md](https://github.com/Hmbown/DeepSeek-TUI/blob/main/docs/INSTALL.md)
build-from-source guide.
@@ -75,7 +78,8 @@ build-from-source guide.
must contain `deepseek-artifacts-sha256.txt` and the platform binaries.
- Set `DEEPSEEK_TUI_FORCE_DOWNLOAD=1` to force download even when the cached binary is already present.
- Set `DEEPSEEK_TUI_DISABLE_INSTALL=1` to skip install-time download.
- Set `DEEPSEEK_TUI_OPTIONAL_INSTALL=1` to make the `postinstall` step warn and exit `0` on download/extract errors instead of failing `npm install` (useful in CI matrices).
- Set `DEEPSEEK_TUI_OPTIONAL_INSTALL=1` to make install-time retryable download
failures warn and exit `0` instead of failing `npm install`.
## Release integrity
+1 -1
View File
@@ -28,7 +28,7 @@
},
"scripts": {
"release:check": "node scripts/verify-release-assets.js",
"postinstall": "node scripts/install.js",
"postinstall": "node scripts/install.js --optional",
"prepublishOnly": "node scripts/verify-release-assets.js",
"prepack": "node scripts/install.js",
"test": "node --test test/*.test.js"
+121 -33
View File
@@ -35,14 +35,19 @@ const pkg = require("../package.json");
const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes per attempt
const DEFAULT_STALL_MS = 30_000; // abort if no bytes for 30s
const OPTIONAL_TIMEOUT_MS = 15_000; // fail fast during optional npm postinstall
const OPTIONAL_STALL_MS = 5_000; // avoid long hangs when install can recover on first run
const MAX_ATTEMPTS = 5;
const OPTIONAL_MAX_ATTEMPTS = 1; // runtime keeps the full retry budget on first launch
const BASE_BACKOFF_MS = 1_000;
const RETRYABLE_NET_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"EDOWNLOADTIMEOUT",
"ETIMEDOUT",
"EAI_AGAIN",
"ENOTFOUND",
"ENETUNREACH",
"EHOSTUNREACH",
"EPIPE",
@@ -86,6 +91,38 @@ function resolveRepo() {
return process.env.DEEPSEEK_TUI_GITHUB_REPO || process.env.DEEPSEEK_GITHUB_REPO || "Hmbown/DeepSeek-TUI";
}
function isOptionalInstall(argv = process.argv.slice(2), env = process.env) {
return (
argv.includes("--optional") ||
env.DEEPSEEK_TUI_OPTIONAL_INSTALL === "1" ||
env.DEEPSEEK_OPTIONAL_INSTALL === "1"
);
}
function isInstallContext(context) {
return context === "install";
}
// Optional install only relaxes npm postinstall behavior. Runtime downloads
// keep the normal retry/timeout budget so first-run recovery stays resilient.
function defaultTimeoutMs(context = "runtime", env = process.env) {
return isInstallContext(context) && isOptionalInstall(undefined, env)
? OPTIONAL_TIMEOUT_MS
: DEFAULT_TIMEOUT_MS;
}
function defaultStallMs(context = "runtime", env = process.env) {
return isInstallContext(context) && isOptionalInstall(undefined, env)
? OPTIONAL_STALL_MS
: DEFAULT_STALL_MS;
}
function maxAttempts(context = "runtime", env = process.env) {
return isInstallContext(context) && isOptionalInstall(undefined, env)
? OPTIONAL_MAX_ATTEMPTS
: MAX_ATTEMPTS;
}
function binaryPaths() {
const { deepseek, tui } = detectBinaryNames();
const releaseDir = releaseBinaryDirectory();
@@ -174,17 +211,17 @@ function envInt(name, fallback) {
return parsed;
}
function downloadTimeoutMs() {
function downloadTimeoutMs(context = "runtime") {
return envInt(
"DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS",
envInt("DEEPSEEK_DOWNLOAD_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
envInt("DEEPSEEK_DOWNLOAD_TIMEOUT_MS", defaultTimeoutMs(context)),
);
}
function downloadStallMs() {
function downloadStallMs(context = "runtime") {
return envInt(
"DEEPSEEK_TUI_DOWNLOAD_STALL_MS",
envInt("DEEPSEEK_DOWNLOAD_STALL_MS", DEFAULT_STALL_MS),
envInt("DEEPSEEK_DOWNLOAD_STALL_MS", defaultStallMs(context)),
);
}
@@ -412,13 +449,15 @@ function connectThroughProxy(proxy, targetHost, targetPort, timeoutMs) {
// ────────────────────────────────────────────────────────────────────────────
function httpRequest(rawUrl, opts = {}) {
const context =
opts.context === undefined || opts.context === null ? "runtime" : opts.context;
const totalTimeoutMs =
opts.totalTimeoutMs === undefined || opts.totalTimeoutMs === null
? downloadTimeoutMs()
? downloadTimeoutMs(context)
: opts.totalTimeoutMs;
const stallMs =
opts.stallMs === undefined || opts.stallMs === null
? downloadStallMs()
? downloadStallMs(context)
: opts.stallMs;
return new Promise((resolve, reject) => {
@@ -708,7 +747,12 @@ function isRetryable(err) {
if (err.nonRetryable) return false;
if (err instanceof NonRetryableError) return false;
if (err instanceof DownloadTimeoutError) return true;
if (err instanceof HttpStatusError) {
// withRetry() rethrows a plain Error while preserving name/status, so wrapped
// HTTP 5xx failures still classify as retryable during optional postinstall.
if (
(err instanceof HttpStatusError || err.name === "HttpStatusError") &&
typeof err.status === "number"
) {
return err.status >= 500;
}
if (err.code && RETRYABLE_NET_CODES.has(err.code)) return true;
@@ -731,27 +775,42 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function withRetry(label, fn) {
async function withRetry(label, fn, context = "runtime") {
let lastErr;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const attemptLimit = maxAttempts(context);
for (let attempt = 1; attempt <= attemptLimit; attempt++) {
try {
return await fn(attempt);
} catch (err) {
lastErr = err;
if (!isRetryable(err) || attempt === MAX_ATTEMPTS) {
if (!isRetryable(err) || attempt === attemptLimit) {
break;
}
const wait = backoffDelay(attempt);
logInfo(
`${label} failed (attempt ${attempt}/${MAX_ATTEMPTS}): ${err.message}; retrying in ${wait} ms`,
`${label} failed (attempt ${attempt}/${attemptLimit}): ${err.message}; retrying in ${wait} ms`,
);
await sleep(wait);
}
}
const msg = lastErr && lastErr.message ? lastErr.message : String(lastErr);
const wrapped = new Error(
`${label} failed after ${MAX_ATTEMPTS} attempt(s): ${msg}`,
`${label} failed after ${attemptLimit} attempt(s): ${msg}`,
);
// Preserve retry classification metadata because the install entrypoint uses
// the wrapped error to decide whether optional postinstall may ignore it.
if (lastErr && lastErr.code) {
wrapped.code = lastErr.code;
}
if (lastErr && lastErr.name) {
wrapped.name = lastErr.name;
}
if (lastErr && typeof lastErr.status === "number") {
wrapped.status = lastErr.status;
}
if (lastErr && lastErr.nonRetryable) {
wrapped.nonRetryable = true;
}
if (lastErr && lastErr.stack) {
wrapped.cause = lastErr;
}
@@ -762,7 +821,7 @@ async function withRetry(label, fn) {
// Public download primitives (now retry + progress aware)
// ────────────────────────────────────────────────────────────────────────────
async function followRedirects(url, opts) {
async function followRedirects(url, opts = {}) {
const maxRedirects = 10;
let current = url;
for (let hop = 0; hop < maxRedirects; hop++) {
@@ -807,17 +866,21 @@ function streamToFile(response, destination, progress) {
async function download(url, destination, options = {}) {
await mkdir(path.dirname(destination), { recursive: true });
const assetName = options.assetName || path.basename(destination);
const context =
options.context === undefined || options.context === null ? "runtime" : options.context;
const attemptLimit = maxAttempts(context);
await withRetry(`download ${assetName}`, async (attempt) => {
const result = await followRedirects(url, {
totalTimeoutMs: downloadTimeoutMs(),
stallMs: downloadStallMs(),
context,
totalTimeoutMs: downloadTimeoutMs(context),
stallMs: downloadStallMs(context),
});
const response = result.response;
const lenHeader = response.headers["content-length"];
const total = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
const progress = createProgressReporter(assetName, Number.isFinite(total) ? total : 0);
if (attempt > 1) {
logInfo(`retry attempt ${attempt}/${MAX_ATTEMPTS} for ${assetName}`);
logInfo(`retry attempt ${attempt}/${attemptLimit} for ${assetName}`);
}
try {
await streamToFile(response, destination, progress);
@@ -831,14 +894,17 @@ async function download(url, destination, options = {}) {
throw err;
}
progress.finish();
});
}, context);
}
async function downloadText(url) {
async function downloadText(url, options = {}) {
const context =
options.context === undefined || options.context === null ? "runtime" : options.context;
return withRetry(`fetch ${url}`, async () => {
const result = await followRedirects(url, {
totalTimeoutMs: downloadTimeoutMs(),
stallMs: downloadStallMs(),
context,
totalTimeoutMs: downloadTimeoutMs(context),
stallMs: downloadStallMs(context),
});
const response = result.response;
response.setEncoding("utf8");
@@ -861,7 +927,7 @@ async function downloadText(url) {
});
response.on("error", reject);
});
});
}, context);
}
async function readLocalVersion(file) {
@@ -913,11 +979,11 @@ async function verifyChecksum(filePath, assetName, checksums) {
}
}
async function loadChecksums(version, repo) {
return parseChecksumManifest(await downloadText(checksumManifestUrl(version, repo)));
async function loadChecksums(version, repo, options = {}) {
return parseChecksumManifest(await downloadText(checksumManifestUrl(version, repo), options));
}
async function ensureBinary(targetPath, assetName, version, repo, getChecksums) {
async function ensureBinary(targetPath, assetName, version, repo, getChecksums, options = {}) {
const marker = `${targetPath}.version`;
const downloadIfNeeded =
process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD === "1" || process.env.DEEPSEEK_FORCE_DOWNLOAD === "1";
@@ -933,7 +999,7 @@ async function ensureBinary(targetPath, assetName, version, repo, getChecksums)
const checksums = await getChecksums();
const url = releaseAssetUrl(assetName, version, repo);
const destination = `${targetPath}.${process.pid}.${Date.now()}.download`;
await download(url, destination, { assetName });
await download(url, destination, { assetName, context: options.context });
try {
await verifyChecksum(destination, assetName, checksums);
preflightGlibc(destination);
@@ -949,7 +1015,21 @@ async function ensureBinary(targetPath, assetName, version, repo, getChecksums)
return targetPath;
}
async function run() {
// Optional install may only downgrade retryable download failures to warnings.
// Unsupported platforms, checksum mismatches, glibc compatibility errors, and
// malformed release metadata must still fail with actionable diagnostics.
function shouldIgnoreInstallFailure(
context,
error,
argv = process.argv.slice(2),
env = process.env,
) {
return isInstallContext(context) && isOptionalInstall(argv, env) && isRetryable(error);
}
async function run(options = {}) {
const context =
options.context === undefined || options.context === null ? "runtime" : options.context;
if (process.env.DEEPSEEK_TUI_DISABLE_INSTALL === "1" || process.env.DEEPSEEK_DISABLE_INSTALL === "1") {
return;
}
@@ -962,19 +1042,19 @@ async function run() {
let checksumsPromise;
const getChecksums = () => {
if (!checksumsPromise) {
checksumsPromise = loadChecksums(version, repo);
checksumsPromise = loadChecksums(version, repo, { context });
}
return checksumsPromise;
};
await Promise.all([
ensureBinary(paths.deepseek.target, paths.deepseek.asset, version, repo, getChecksums),
ensureBinary(paths.tui.target, paths.tui.asset, version, repo, getChecksums),
ensureBinary(paths.deepseek.target, paths.deepseek.asset, version, repo, getChecksums, { context }),
ensureBinary(paths.tui.target, paths.tui.asset, version, repo, getChecksums, { context }),
]);
}
async function getBinaryPath(name) {
await run();
await run({ context: "runtime" });
const paths = binaryPaths();
if (name === "deepseek") {
return paths.deepseek.target;
@@ -989,18 +1069,26 @@ module.exports = {
getBinaryPath,
installFailureHint,
run,
_internal: {
isOptionalInstall,
shouldIgnoreInstallFailure,
defaultTimeoutMs,
defaultStallMs,
maxAttempts,
withRetry,
},
};
if (require.main === module) {
run().catch((error) => {
run({ context: "install" }).catch((error) => {
console.error("deepseek-tui install failed:", error.message);
const hint = installFailureHint(error);
if (hint) {
console.error(hint);
}
if (process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL === "1") {
if (shouldIgnoreInstallFailure("install", error)) {
console.error(
"DEEPSEEK_TUI_OPTIONAL_INSTALL=1 set; continuing without a usable binary.",
"Optional install enabled; continuing without a usable binary. The download will be retried on first run.",
);
process.exit(0);
}
+91
View File
@@ -0,0 +1,91 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const pkg = require("../package.json");
const { _internal } = require("../scripts/install");
test("postinstall opts into optional install mode", () => {
assert.equal(pkg.scripts.postinstall, "node scripts/install.js --optional");
});
test("optional install can be enabled by command-line flag or env", () => {
assert.equal(_internal.isOptionalInstall(["--optional"], {}), true);
assert.equal(_internal.isOptionalInstall([], {}), false);
assert.equal(_internal.isOptionalInstall([], { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), true);
assert.equal(_internal.isOptionalInstall([], { DEEPSEEK_OPTIONAL_INSTALL: "1" }), true);
});
test("optional mode only changes install-time defaults", () => {
assert.equal(_internal.maxAttempts("install", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 1);
assert.equal(_internal.maxAttempts("runtime", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 5);
assert.equal(_internal.defaultTimeoutMs("install", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 15_000);
assert.equal(_internal.defaultTimeoutMs("runtime", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 300_000);
assert.equal(_internal.defaultStallMs("install", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 5_000);
assert.equal(_internal.defaultStallMs("runtime", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 30_000);
});
test("optional install only swallows retryable download failures", () => {
const socketHangUp = new Error("socket hang up");
assert.equal(
_internal.shouldIgnoreInstallFailure("install", socketHangUp, ["--optional"], {}),
true,
);
const timedOut = new Error("download exceeded total timeout of 15000 ms");
timedOut.code = "EDOWNLOADTIMEOUT";
assert.equal(
_internal.shouldIgnoreInstallFailure("install", timedOut, ["--optional"], {}),
true,
);
const unsupported = new Error("Unsupported platform: freebsd");
assert.equal(
_internal.shouldIgnoreInstallFailure("install", unsupported, ["--optional"], {}),
false,
);
const badChecksum = new Error("Checksum mismatch for deepseek-linux-x64");
badChecksum.nonRetryable = true;
assert.equal(
_internal.shouldIgnoreInstallFailure("install", badChecksum, ["--optional"], {}),
false,
);
const glibc = new Error("requires glibc 2.34 or newer");
glibc.nonRetryable = true;
assert.equal(
_internal.shouldIgnoreInstallFailure("install", glibc, ["--optional"], {}),
false,
);
});
test("optional install still swallows wrapped http 5xx failures", async () => {
const previous = process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL;
process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL = "1";
const http5xx = new Error("Request failed with status 502: https://example.invalid");
http5xx.name = "HttpStatusError";
http5xx.status = 502;
try {
await assert.rejects(
_internal.withRetry("fetch https://example.invalid", async () => {
throw http5xx;
}, "install"),
(wrapped) => {
assert.equal(wrapped.name, "HttpStatusError");
assert.equal(wrapped.status, 502);
assert.equal(
_internal.shouldIgnoreInstallFailure("install", wrapped, ["--optional"], {}),
true,
);
return true;
},
);
} finally {
if (previous === undefined) {
delete process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL;
} else {
process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL = previous;
}
}
});