deepseek-tui 0.8.17 → 0.8.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,8 +17,10 @@ npm install deepseek-tui
17
17
  npx deepseek-tui --help
18
18
  ```
19
19
 
20
- `postinstall` downloads platform binaries into `bin/downloads/` and exposes
21
- `deepseek` and `deepseek-tui` commands.
20
+ `postinstall` tries to download platform binaries into `bin/downloads/` and
21
+ exposes `deepseek` and `deepseek-tui` commands. If GitHub release assets are
22
+ temporarily unreachable, install continues and the wrapper retries the download
23
+ on first run.
22
24
 
23
25
  ## First run
24
26
 
@@ -60,8 +62,9 @@ Prebuilt binaries for the GitHub release are downloaded automatically:
60
62
  - Windows x64
61
63
 
62
64
  Other platform/architecture combinations (musl, riscv64, FreeBSD, …) aren't
63
- shipped as prebuilts. The `postinstall` will exit with a clear error pointing
64
- you at `cargo install deepseek-tui-cli deepseek-tui --locked` and the full
65
+ shipped as prebuilts. Unsupported platforms, checksum failures, and glibc
66
+ compatibility problems still fail with a clear error pointing you at
67
+ `cargo install deepseek-tui-cli deepseek-tui --locked` and the full
65
68
  [docs/INSTALL.md](https://github.com/Hmbown/DeepSeek-TUI/blob/main/docs/INSTALL.md)
66
69
  build-from-source guide.
67
70
 
@@ -75,7 +78,8 @@ build-from-source guide.
75
78
  must contain `deepseek-artifacts-sha256.txt` and the platform binaries.
76
79
  - Set `DEEPSEEK_TUI_FORCE_DOWNLOAD=1` to force download even when the cached binary is already present.
77
80
  - Set `DEEPSEEK_TUI_DISABLE_INSTALL=1` to skip install-time download.
78
- - 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).
81
+ - Set `DEEPSEEK_TUI_OPTIONAL_INSTALL=1` to make install-time retryable download
82
+ failures warn and exit `0` instead of failing `npm install`.
79
83
 
80
84
  ## Release integrity
81
85
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "deepseek-tui",
3
- "version": "0.8.17",
4
- "deepseekBinaryVersion": "0.8.17",
3
+ "version": "0.8.20",
4
+ "deepseekBinaryVersion": "0.8.20",
5
5
  "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.",
6
6
  "author": "Hmbown",
7
7
  "license": "MIT",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "scripts": {
30
30
  "release:check": "node scripts/verify-release-assets.js",
31
- "postinstall": "node scripts/install.js",
31
+ "postinstall": "node scripts/install.js --optional",
32
32
  "prepublishOnly": "node scripts/verify-release-assets.js",
33
33
  "prepack": "node scripts/install.js",
34
34
  "test": "node --test test/*.test.js"
@@ -35,14 +35,19 @@ const pkg = require("../package.json");
35
35
 
36
36
  const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes per attempt
37
37
  const DEFAULT_STALL_MS = 30_000; // abort if no bytes for 30s
38
+ const OPTIONAL_TIMEOUT_MS = 15_000; // fail fast during optional npm postinstall
39
+ const OPTIONAL_STALL_MS = 5_000; // avoid long hangs when install can recover on first run
38
40
  const MAX_ATTEMPTS = 5;
41
+ const OPTIONAL_MAX_ATTEMPTS = 1; // runtime keeps the full retry budget on first launch
39
42
  const BASE_BACKOFF_MS = 1_000;
40
43
 
41
44
  const RETRYABLE_NET_CODES = new Set([
42
45
  "ECONNRESET",
43
46
  "ECONNREFUSED",
47
+ "EDOWNLOADTIMEOUT",
44
48
  "ETIMEDOUT",
45
49
  "EAI_AGAIN",
50
+ "ENOTFOUND",
46
51
  "ENETUNREACH",
47
52
  "EHOSTUNREACH",
48
53
  "EPIPE",
@@ -86,6 +91,38 @@ function resolveRepo() {
86
91
  return process.env.DEEPSEEK_TUI_GITHUB_REPO || process.env.DEEPSEEK_GITHUB_REPO || "Hmbown/DeepSeek-TUI";
87
92
  }
88
93
 
94
+ function isOptionalInstall(argv = process.argv.slice(2), env = process.env) {
95
+ return (
96
+ argv.includes("--optional") ||
97
+ env.DEEPSEEK_TUI_OPTIONAL_INSTALL === "1" ||
98
+ env.DEEPSEEK_OPTIONAL_INSTALL === "1"
99
+ );
100
+ }
101
+
102
+ function isInstallContext(context) {
103
+ return context === "install";
104
+ }
105
+
106
+ // Optional install only relaxes npm postinstall behavior. Runtime downloads
107
+ // keep the normal retry/timeout budget so first-run recovery stays resilient.
108
+ function defaultTimeoutMs(context = "runtime", env = process.env) {
109
+ return isInstallContext(context) && isOptionalInstall(undefined, env)
110
+ ? OPTIONAL_TIMEOUT_MS
111
+ : DEFAULT_TIMEOUT_MS;
112
+ }
113
+
114
+ function defaultStallMs(context = "runtime", env = process.env) {
115
+ return isInstallContext(context) && isOptionalInstall(undefined, env)
116
+ ? OPTIONAL_STALL_MS
117
+ : DEFAULT_STALL_MS;
118
+ }
119
+
120
+ function maxAttempts(context = "runtime", env = process.env) {
121
+ return isInstallContext(context) && isOptionalInstall(undefined, env)
122
+ ? OPTIONAL_MAX_ATTEMPTS
123
+ : MAX_ATTEMPTS;
124
+ }
125
+
89
126
  function binaryPaths() {
90
127
  const { deepseek, tui } = detectBinaryNames();
91
128
  const releaseDir = releaseBinaryDirectory();
@@ -174,17 +211,17 @@ function envInt(name, fallback) {
174
211
  return parsed;
175
212
  }
176
213
 
177
- function downloadTimeoutMs() {
214
+ function downloadTimeoutMs(context = "runtime") {
178
215
  return envInt(
179
216
  "DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS",
180
- envInt("DEEPSEEK_DOWNLOAD_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
217
+ envInt("DEEPSEEK_DOWNLOAD_TIMEOUT_MS", defaultTimeoutMs(context)),
181
218
  );
182
219
  }
183
220
 
184
- function downloadStallMs() {
221
+ function downloadStallMs(context = "runtime") {
185
222
  return envInt(
186
223
  "DEEPSEEK_TUI_DOWNLOAD_STALL_MS",
187
- envInt("DEEPSEEK_DOWNLOAD_STALL_MS", DEFAULT_STALL_MS),
224
+ envInt("DEEPSEEK_DOWNLOAD_STALL_MS", defaultStallMs(context)),
188
225
  );
189
226
  }
190
227
 
@@ -412,13 +449,15 @@ function connectThroughProxy(proxy, targetHost, targetPort, timeoutMs) {
412
449
  // ────────────────────────────────────────────────────────────────────────────
413
450
 
414
451
  function httpRequest(rawUrl, opts = {}) {
452
+ const context =
453
+ opts.context === undefined || opts.context === null ? "runtime" : opts.context;
415
454
  const totalTimeoutMs =
416
455
  opts.totalTimeoutMs === undefined || opts.totalTimeoutMs === null
417
- ? downloadTimeoutMs()
456
+ ? downloadTimeoutMs(context)
418
457
  : opts.totalTimeoutMs;
419
458
  const stallMs =
420
459
  opts.stallMs === undefined || opts.stallMs === null
421
- ? downloadStallMs()
460
+ ? downloadStallMs(context)
422
461
  : opts.stallMs;
423
462
 
424
463
  return new Promise((resolve, reject) => {
@@ -708,7 +747,12 @@ function isRetryable(err) {
708
747
  if (err.nonRetryable) return false;
709
748
  if (err instanceof NonRetryableError) return false;
710
749
  if (err instanceof DownloadTimeoutError) return true;
711
- if (err instanceof HttpStatusError) {
750
+ // withRetry() rethrows a plain Error while preserving name/status, so wrapped
751
+ // HTTP 5xx failures still classify as retryable during optional postinstall.
752
+ if (
753
+ (err instanceof HttpStatusError || err.name === "HttpStatusError") &&
754
+ typeof err.status === "number"
755
+ ) {
712
756
  return err.status >= 500;
713
757
  }
714
758
  if (err.code && RETRYABLE_NET_CODES.has(err.code)) return true;
@@ -731,27 +775,42 @@ function sleep(ms) {
731
775
  return new Promise((resolve) => setTimeout(resolve, ms));
732
776
  }
733
777
 
734
- async function withRetry(label, fn) {
778
+ async function withRetry(label, fn, context = "runtime") {
735
779
  let lastErr;
736
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
780
+ const attemptLimit = maxAttempts(context);
781
+ for (let attempt = 1; attempt <= attemptLimit; attempt++) {
737
782
  try {
738
783
  return await fn(attempt);
739
784
  } catch (err) {
740
785
  lastErr = err;
741
- if (!isRetryable(err) || attempt === MAX_ATTEMPTS) {
786
+ if (!isRetryable(err) || attempt === attemptLimit) {
742
787
  break;
743
788
  }
744
789
  const wait = backoffDelay(attempt);
745
790
  logInfo(
746
- `${label} failed (attempt ${attempt}/${MAX_ATTEMPTS}): ${err.message}; retrying in ${wait} ms`,
791
+ `${label} failed (attempt ${attempt}/${attemptLimit}): ${err.message}; retrying in ${wait} ms`,
747
792
  );
748
793
  await sleep(wait);
749
794
  }
750
795
  }
751
796
  const msg = lastErr && lastErr.message ? lastErr.message : String(lastErr);
752
797
  const wrapped = new Error(
753
- `${label} failed after ${MAX_ATTEMPTS} attempt(s): ${msg}`,
798
+ `${label} failed after ${attemptLimit} attempt(s): ${msg}`,
754
799
  );
800
+ // Preserve retry classification metadata because the install entrypoint uses
801
+ // the wrapped error to decide whether optional postinstall may ignore it.
802
+ if (lastErr && lastErr.code) {
803
+ wrapped.code = lastErr.code;
804
+ }
805
+ if (lastErr && lastErr.name) {
806
+ wrapped.name = lastErr.name;
807
+ }
808
+ if (lastErr && typeof lastErr.status === "number") {
809
+ wrapped.status = lastErr.status;
810
+ }
811
+ if (lastErr && lastErr.nonRetryable) {
812
+ wrapped.nonRetryable = true;
813
+ }
755
814
  if (lastErr && lastErr.stack) {
756
815
  wrapped.cause = lastErr;
757
816
  }
@@ -762,7 +821,7 @@ async function withRetry(label, fn) {
762
821
  // Public download primitives (now retry + progress aware)
763
822
  // ────────────────────────────────────────────────────────────────────────────
764
823
 
765
- async function followRedirects(url, opts) {
824
+ async function followRedirects(url, opts = {}) {
766
825
  const maxRedirects = 10;
767
826
  let current = url;
768
827
  for (let hop = 0; hop < maxRedirects; hop++) {
@@ -807,17 +866,21 @@ function streamToFile(response, destination, progress) {
807
866
  async function download(url, destination, options = {}) {
808
867
  await mkdir(path.dirname(destination), { recursive: true });
809
868
  const assetName = options.assetName || path.basename(destination);
869
+ const context =
870
+ options.context === undefined || options.context === null ? "runtime" : options.context;
871
+ const attemptLimit = maxAttempts(context);
810
872
  await withRetry(`download ${assetName}`, async (attempt) => {
811
873
  const result = await followRedirects(url, {
812
- totalTimeoutMs: downloadTimeoutMs(),
813
- stallMs: downloadStallMs(),
874
+ context,
875
+ totalTimeoutMs: downloadTimeoutMs(context),
876
+ stallMs: downloadStallMs(context),
814
877
  });
815
878
  const response = result.response;
816
879
  const lenHeader = response.headers["content-length"];
817
880
  const total = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
818
881
  const progress = createProgressReporter(assetName, Number.isFinite(total) ? total : 0);
819
882
  if (attempt > 1) {
820
- logInfo(`retry attempt ${attempt}/${MAX_ATTEMPTS} for ${assetName}`);
883
+ logInfo(`retry attempt ${attempt}/${attemptLimit} for ${assetName}`);
821
884
  }
822
885
  try {
823
886
  await streamToFile(response, destination, progress);
@@ -831,14 +894,17 @@ async function download(url, destination, options = {}) {
831
894
  throw err;
832
895
  }
833
896
  progress.finish();
834
- });
897
+ }, context);
835
898
  }
836
899
 
837
- async function downloadText(url) {
900
+ async function downloadText(url, options = {}) {
901
+ const context =
902
+ options.context === undefined || options.context === null ? "runtime" : options.context;
838
903
  return withRetry(`fetch ${url}`, async () => {
839
904
  const result = await followRedirects(url, {
840
- totalTimeoutMs: downloadTimeoutMs(),
841
- stallMs: downloadStallMs(),
905
+ context,
906
+ totalTimeoutMs: downloadTimeoutMs(context),
907
+ stallMs: downloadStallMs(context),
842
908
  });
843
909
  const response = result.response;
844
910
  response.setEncoding("utf8");
@@ -861,7 +927,7 @@ async function downloadText(url) {
861
927
  });
862
928
  response.on("error", reject);
863
929
  });
864
- });
930
+ }, context);
865
931
  }
866
932
 
867
933
  async function readLocalVersion(file) {
@@ -913,11 +979,11 @@ async function verifyChecksum(filePath, assetName, checksums) {
913
979
  }
914
980
  }
915
981
 
916
- async function loadChecksums(version, repo) {
917
- return parseChecksumManifest(await downloadText(checksumManifestUrl(version, repo)));
982
+ async function loadChecksums(version, repo, options = {}) {
983
+ return parseChecksumManifest(await downloadText(checksumManifestUrl(version, repo), options));
918
984
  }
919
985
 
920
- async function ensureBinary(targetPath, assetName, version, repo, getChecksums) {
986
+ async function ensureBinary(targetPath, assetName, version, repo, getChecksums, options = {}) {
921
987
  const marker = `${targetPath}.version`;
922
988
  const downloadIfNeeded =
923
989
  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)
933
999
  const checksums = await getChecksums();
934
1000
  const url = releaseAssetUrl(assetName, version, repo);
935
1001
  const destination = `${targetPath}.${process.pid}.${Date.now()}.download`;
936
- await download(url, destination, { assetName });
1002
+ await download(url, destination, { assetName, context: options.context });
937
1003
  try {
938
1004
  await verifyChecksum(destination, assetName, checksums);
939
1005
  preflightGlibc(destination);
@@ -949,7 +1015,21 @@ async function ensureBinary(targetPath, assetName, version, repo, getChecksums)
949
1015
  return targetPath;
950
1016
  }
951
1017
 
952
- async function run() {
1018
+ // Optional install may only downgrade retryable download failures to warnings.
1019
+ // Unsupported platforms, checksum mismatches, glibc compatibility errors, and
1020
+ // malformed release metadata must still fail with actionable diagnostics.
1021
+ function shouldIgnoreInstallFailure(
1022
+ context,
1023
+ error,
1024
+ argv = process.argv.slice(2),
1025
+ env = process.env,
1026
+ ) {
1027
+ return isInstallContext(context) && isOptionalInstall(argv, env) && isRetryable(error);
1028
+ }
1029
+
1030
+ async function run(options = {}) {
1031
+ const context =
1032
+ options.context === undefined || options.context === null ? "runtime" : options.context;
953
1033
  if (process.env.DEEPSEEK_TUI_DISABLE_INSTALL === "1" || process.env.DEEPSEEK_DISABLE_INSTALL === "1") {
954
1034
  return;
955
1035
  }
@@ -962,19 +1042,19 @@ async function run() {
962
1042
  let checksumsPromise;
963
1043
  const getChecksums = () => {
964
1044
  if (!checksumsPromise) {
965
- checksumsPromise = loadChecksums(version, repo);
1045
+ checksumsPromise = loadChecksums(version, repo, { context });
966
1046
  }
967
1047
  return checksumsPromise;
968
1048
  };
969
1049
 
970
1050
  await Promise.all([
971
- ensureBinary(paths.deepseek.target, paths.deepseek.asset, version, repo, getChecksums),
972
- ensureBinary(paths.tui.target, paths.tui.asset, version, repo, getChecksums),
1051
+ ensureBinary(paths.deepseek.target, paths.deepseek.asset, version, repo, getChecksums, { context }),
1052
+ ensureBinary(paths.tui.target, paths.tui.asset, version, repo, getChecksums, { context }),
973
1053
  ]);
974
1054
  }
975
1055
 
976
1056
  async function getBinaryPath(name) {
977
- await run();
1057
+ await run({ context: "runtime" });
978
1058
  const paths = binaryPaths();
979
1059
  if (name === "deepseek") {
980
1060
  return paths.deepseek.target;
@@ -989,18 +1069,26 @@ module.exports = {
989
1069
  getBinaryPath,
990
1070
  installFailureHint,
991
1071
  run,
1072
+ _internal: {
1073
+ isOptionalInstall,
1074
+ shouldIgnoreInstallFailure,
1075
+ defaultTimeoutMs,
1076
+ defaultStallMs,
1077
+ maxAttempts,
1078
+ withRetry,
1079
+ },
992
1080
  };
993
1081
 
994
1082
  if (require.main === module) {
995
- run().catch((error) => {
1083
+ run({ context: "install" }).catch((error) => {
996
1084
  console.error("deepseek-tui install failed:", error.message);
997
1085
  const hint = installFailureHint(error);
998
1086
  if (hint) {
999
1087
  console.error(hint);
1000
1088
  }
1001
- if (process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL === "1") {
1089
+ if (shouldIgnoreInstallFailure("install", error)) {
1002
1090
  console.error(
1003
- "DEEPSEEK_TUI_OPTIONAL_INSTALL=1 set; continuing without a usable binary.",
1091
+ "Optional install enabled; continuing without a usable binary. The download will be retried on first run.",
1004
1092
  );
1005
1093
  process.exit(0);
1006
1094
  }
@@ -29,7 +29,7 @@ test("install failure hint explains release base override for blocked GitHub dow
29
29
  try {
30
30
  const error = Object.assign(
31
31
  new Error(
32
- "fetch https://github.com/Hmbown/DeepSeek-TUI/releases/download/v0.8.17/deepseek-artifacts-sha256.txt failed after 5 attempts:\ngetaddrinfo ENOTFOUND github.com",
32
+ "fetch https://github.com/Hmbown/DeepSeek-TUI/releases/download/v0.8.19/deepseek-artifacts-sha256.txt failed after 5 attempts:\ngetaddrinfo ENOTFOUND github.com",
33
33
  ),
34
34
  { code: "ENOTFOUND" },
35
35
  );
@@ -0,0 +1,91 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const pkg = require("../package.json");
5
+ const { _internal } = require("../scripts/install");
6
+
7
+ test("postinstall opts into optional install mode", () => {
8
+ assert.equal(pkg.scripts.postinstall, "node scripts/install.js --optional");
9
+ });
10
+
11
+ test("optional install can be enabled by command-line flag or env", () => {
12
+ assert.equal(_internal.isOptionalInstall(["--optional"], {}), true);
13
+ assert.equal(_internal.isOptionalInstall([], {}), false);
14
+ assert.equal(_internal.isOptionalInstall([], { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), true);
15
+ assert.equal(_internal.isOptionalInstall([], { DEEPSEEK_OPTIONAL_INSTALL: "1" }), true);
16
+ });
17
+
18
+ test("optional mode only changes install-time defaults", () => {
19
+ assert.equal(_internal.maxAttempts("install", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 1);
20
+ assert.equal(_internal.maxAttempts("runtime", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 5);
21
+ assert.equal(_internal.defaultTimeoutMs("install", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 15_000);
22
+ assert.equal(_internal.defaultTimeoutMs("runtime", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 300_000);
23
+ assert.equal(_internal.defaultStallMs("install", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 5_000);
24
+ assert.equal(_internal.defaultStallMs("runtime", { DEEPSEEK_TUI_OPTIONAL_INSTALL: "1" }), 30_000);
25
+ });
26
+
27
+ test("optional install only swallows retryable download failures", () => {
28
+ const socketHangUp = new Error("socket hang up");
29
+ assert.equal(
30
+ _internal.shouldIgnoreInstallFailure("install", socketHangUp, ["--optional"], {}),
31
+ true,
32
+ );
33
+
34
+ const timedOut = new Error("download exceeded total timeout of 15000 ms");
35
+ timedOut.code = "EDOWNLOADTIMEOUT";
36
+ assert.equal(
37
+ _internal.shouldIgnoreInstallFailure("install", timedOut, ["--optional"], {}),
38
+ true,
39
+ );
40
+
41
+ const unsupported = new Error("Unsupported platform: freebsd");
42
+ assert.equal(
43
+ _internal.shouldIgnoreInstallFailure("install", unsupported, ["--optional"], {}),
44
+ false,
45
+ );
46
+
47
+ const badChecksum = new Error("Checksum mismatch for deepseek-linux-x64");
48
+ badChecksum.nonRetryable = true;
49
+ assert.equal(
50
+ _internal.shouldIgnoreInstallFailure("install", badChecksum, ["--optional"], {}),
51
+ false,
52
+ );
53
+
54
+ const glibc = new Error("requires glibc 2.34 or newer");
55
+ glibc.nonRetryable = true;
56
+ assert.equal(
57
+ _internal.shouldIgnoreInstallFailure("install", glibc, ["--optional"], {}),
58
+ false,
59
+ );
60
+ });
61
+
62
+ test("optional install still swallows wrapped http 5xx failures", async () => {
63
+ const previous = process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL;
64
+ process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL = "1";
65
+ const http5xx = new Error("Request failed with status 502: https://example.invalid");
66
+ http5xx.name = "HttpStatusError";
67
+ http5xx.status = 502;
68
+
69
+ try {
70
+ await assert.rejects(
71
+ _internal.withRetry("fetch https://example.invalid", async () => {
72
+ throw http5xx;
73
+ }, "install"),
74
+ (wrapped) => {
75
+ assert.equal(wrapped.name, "HttpStatusError");
76
+ assert.equal(wrapped.status, 502);
77
+ assert.equal(
78
+ _internal.shouldIgnoreInstallFailure("install", wrapped, ["--optional"], {}),
79
+ true,
80
+ );
81
+ return true;
82
+ },
83
+ );
84
+ } finally {
85
+ if (previous === undefined) {
86
+ delete process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL;
87
+ } else {
88
+ process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL = previous;
89
+ }
90
+ }
91
+ });