diff --git a/npm/deepseek-tui/README.md b/npm/deepseek-tui/README.md index 6ef4c5f3..13807f95 100644 --- a/npm/deepseek-tui/README.md +++ b/npm/deepseek-tui/README.md @@ -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 diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index 5759a8f7..c2891002 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -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" diff --git a/npm/deepseek-tui/scripts/install.js b/npm/deepseek-tui/scripts/install.js index 55dc542b..884aa311 100644 --- a/npm/deepseek-tui/scripts/install.js +++ b/npm/deepseek-tui/scripts/install.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); } diff --git a/npm/deepseek-tui/test/postinstall.test.js b/npm/deepseek-tui/test/postinstall.test.js new file mode 100644 index 00000000..5b72f14c --- /dev/null +++ b/npm/deepseek-tui/test/postinstall.test.js @@ -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; + } + } +});