fix(npm): make postinstall download failures recoverable
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user