deepseek-tui 0.8.16 → 0.8.18

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
 
@@ -70,9 +73,13 @@ build-from-source guide.
70
73
  - Default binary version comes from `deepseekBinaryVersion` in `package.json`.
71
74
  - Set `DEEPSEEK_TUI_VERSION` or `DEEPSEEK_VERSION` to override the release version.
72
75
  - Set `DEEPSEEK_TUI_GITHUB_REPO` or `DEEPSEEK_GITHUB_REPO` to override the source repo (defaults to `Hmbown/DeepSeek-TUI`).
76
+ - Set `DEEPSEEK_TUI_RELEASE_BASE_URL` to use an internal or mirrored
77
+ release-asset directory when GitHub Releases is unavailable. The directory
78
+ must contain `deepseek-artifacts-sha256.txt` and the platform binaries.
73
79
  - Set `DEEPSEEK_TUI_FORCE_DOWNLOAD=1` to force download even when the cached binary is already present.
74
80
  - Set `DEEPSEEK_TUI_DISABLE_INSTALL=1` to skip install-time download.
75
- - 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`.
76
83
 
77
84
  ## Release integrity
78
85
 
@@ -80,5 +87,3 @@ build-from-source guide.
80
87
  exist for the target GitHub release before publishing.
81
88
  - Install-time downloads are verified against the release checksum manifest before
82
89
  the wrapper marks them executable.
83
- - Set `DEEPSEEK_TUI_RELEASE_BASE_URL` to point the installer at a local or
84
- staged release-asset directory for smoke tests.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "deepseek-tui",
3
- "version": "0.8.16",
4
- "deepseekBinaryVersion": "0.8.16",
3
+ "version": "0.8.18",
4
+ "deepseekBinaryVersion": "0.8.18",
5
5
  "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.",
6
6
  "author": "Hmbown",
7
7
  "license": "MIT",
@@ -28,9 +28,10 @@
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
- "prepack": "node scripts/install.js"
33
+ "prepack": "node scripts/install.js",
34
+ "test": "node --test test/*.test.js"
34
35
  },
35
36
  "engines": {
36
37
  "node": ">=18"
@@ -42,6 +43,7 @@
42
43
  "files": [
43
44
  "bin/*.js",
44
45
  "scripts/*.js",
46
+ "test/*.js",
45
47
  "README.md",
46
48
  "package.json"
47
49
  ]
@@ -1,3 +1,18 @@
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
+ "deepseek-tui: 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 deepseek-tui`.\n",
9
+ );
10
+ process.exit(1);
11
+ }
12
+ }
13
+
14
+ assertSupportedNode();
15
+
1
16
  const fs = require("fs");
2
17
  const https = require("https");
3
18
  const http = require("http");
@@ -20,14 +35,19 @@ const pkg = require("../package.json");
20
35
 
21
36
  const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes per attempt
22
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
23
40
  const MAX_ATTEMPTS = 5;
41
+ const OPTIONAL_MAX_ATTEMPTS = 1; // runtime keeps the full retry budget on first launch
24
42
  const BASE_BACKOFF_MS = 1_000;
25
43
 
26
44
  const RETRYABLE_NET_CODES = new Set([
27
45
  "ECONNRESET",
28
46
  "ECONNREFUSED",
47
+ "EDOWNLOADTIMEOUT",
29
48
  "ETIMEDOUT",
30
49
  "EAI_AGAIN",
50
+ "ENOTFOUND",
31
51
  "ENETUNREACH",
32
52
  "EHOSTUNREACH",
33
53
  "EPIPE",
@@ -71,6 +91,38 @@ function resolveRepo() {
71
91
  return process.env.DEEPSEEK_TUI_GITHUB_REPO || process.env.DEEPSEEK_GITHUB_REPO || "Hmbown/DeepSeek-TUI";
72
92
  }
73
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
+
74
126
  function binaryPaths() {
75
127
  const { deepseek, tui } = detectBinaryNames();
76
128
  const releaseDir = releaseBinaryDirectory();
@@ -105,6 +157,48 @@ function logInfo(message) {
105
157
  process.stderr.write(`deepseek-tui: ${message}\n`);
106
158
  }
107
159
 
160
+ function installFailureHint(error) {
161
+ const message = error && error.message ? String(error.message) : "";
162
+ const code = error && error.code ? String(error.code) : "";
163
+ const releaseBase =
164
+ process.env.DEEPSEEK_TUI_RELEASE_BASE_URL ||
165
+ process.env.DEEPSEEK_RELEASE_BASE_URL;
166
+ const networkMarkers = [
167
+ "github.com",
168
+ "ENOTFOUND",
169
+ "EAI_AGAIN",
170
+ "ETIMEDOUT",
171
+ "ECONNRESET",
172
+ "ENETUNREACH",
173
+ "EHOSTUNREACH",
174
+ "EDOWNLOADTIMEOUT",
175
+ ];
176
+ const looksLikeNetworkDownloadFailure = networkMarkers.some(
177
+ (marker) => message.includes(marker) || code === marker,
178
+ );
179
+ if (!looksLikeNetworkDownloadFailure) {
180
+ return "";
181
+ }
182
+
183
+ if (releaseBase) {
184
+ return [
185
+ "deepseek-tui install hint:",
186
+ ` DEEPSEEK_TUI_RELEASE_BASE_URL is set to ${releaseBase}`,
187
+ " Verify that this directory contains deepseek-artifacts-sha256.txt",
188
+ " plus the deepseek/deepseek-tui binary assets for your platform.",
189
+ ].join("\n");
190
+ }
191
+
192
+ return [
193
+ "deepseek-tui install hint:",
194
+ " The npm package downloads prebuilt binaries from GitHub Releases.",
195
+ " If GitHub is unavailable on this network, mirror the release assets and set:",
196
+ " DEEPSEEK_TUI_RELEASE_BASE_URL=https://<mirror>/<release-asset-directory>/",
197
+ " The directory must contain deepseek-artifacts-sha256.txt and the platform binaries.",
198
+ " See docs/INSTALL.md#npm-download-is-slow-or-times-out-from-mainland-china.",
199
+ ].join("\n");
200
+ }
201
+
108
202
  function envInt(name, fallback) {
109
203
  const raw = process.env[name];
110
204
  if (!raw) {
@@ -117,17 +211,17 @@ function envInt(name, fallback) {
117
211
  return parsed;
118
212
  }
119
213
 
120
- function downloadTimeoutMs() {
214
+ function downloadTimeoutMs(context = "runtime") {
121
215
  return envInt(
122
216
  "DEEPSEEK_TUI_DOWNLOAD_TIMEOUT_MS",
123
- envInt("DEEPSEEK_DOWNLOAD_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
217
+ envInt("DEEPSEEK_DOWNLOAD_TIMEOUT_MS", defaultTimeoutMs(context)),
124
218
  );
125
219
  }
126
220
 
127
- function downloadStallMs() {
221
+ function downloadStallMs(context = "runtime") {
128
222
  return envInt(
129
223
  "DEEPSEEK_TUI_DOWNLOAD_STALL_MS",
130
- envInt("DEEPSEEK_DOWNLOAD_STALL_MS", DEFAULT_STALL_MS),
224
+ envInt("DEEPSEEK_DOWNLOAD_STALL_MS", defaultStallMs(context)),
131
225
  );
132
226
  }
133
227
 
@@ -355,8 +449,16 @@ function connectThroughProxy(proxy, targetHost, targetPort, timeoutMs) {
355
449
  // ────────────────────────────────────────────────────────────────────────────
356
450
 
357
451
  function httpRequest(rawUrl, opts = {}) {
358
- const totalTimeoutMs = opts.totalTimeoutMs ?? downloadTimeoutMs();
359
- const stallMs = opts.stallMs ?? downloadStallMs();
452
+ const context =
453
+ opts.context === undefined || opts.context === null ? "runtime" : opts.context;
454
+ const totalTimeoutMs =
455
+ opts.totalTimeoutMs === undefined || opts.totalTimeoutMs === null
456
+ ? downloadTimeoutMs(context)
457
+ : opts.totalTimeoutMs;
458
+ const stallMs =
459
+ opts.stallMs === undefined || opts.stallMs === null
460
+ ? downloadStallMs(context)
461
+ : opts.stallMs;
360
462
 
361
463
  return new Promise((resolve, reject) => {
362
464
  let url;
@@ -645,7 +747,12 @@ function isRetryable(err) {
645
747
  if (err.nonRetryable) return false;
646
748
  if (err instanceof NonRetryableError) return false;
647
749
  if (err instanceof DownloadTimeoutError) return true;
648
- 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
+ ) {
649
756
  return err.status >= 500;
650
757
  }
651
758
  if (err.code && RETRYABLE_NET_CODES.has(err.code)) return true;
@@ -668,27 +775,42 @@ function sleep(ms) {
668
775
  return new Promise((resolve) => setTimeout(resolve, ms));
669
776
  }
670
777
 
671
- async function withRetry(label, fn) {
778
+ async function withRetry(label, fn, context = "runtime") {
672
779
  let lastErr;
673
- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
780
+ const attemptLimit = maxAttempts(context);
781
+ for (let attempt = 1; attempt <= attemptLimit; attempt++) {
674
782
  try {
675
783
  return await fn(attempt);
676
784
  } catch (err) {
677
785
  lastErr = err;
678
- if (!isRetryable(err) || attempt === MAX_ATTEMPTS) {
786
+ if (!isRetryable(err) || attempt === attemptLimit) {
679
787
  break;
680
788
  }
681
789
  const wait = backoffDelay(attempt);
682
790
  logInfo(
683
- `${label} failed (attempt ${attempt}/${MAX_ATTEMPTS}): ${err.message}; retrying in ${wait} ms`,
791
+ `${label} failed (attempt ${attempt}/${attemptLimit}): ${err.message}; retrying in ${wait} ms`,
684
792
  );
685
793
  await sleep(wait);
686
794
  }
687
795
  }
688
796
  const msg = lastErr && lastErr.message ? lastErr.message : String(lastErr);
689
797
  const wrapped = new Error(
690
- `${label} failed after ${MAX_ATTEMPTS} attempt(s): ${msg}`,
798
+ `${label} failed after ${attemptLimit} attempt(s): ${msg}`,
691
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
+ }
692
814
  if (lastErr && lastErr.stack) {
693
815
  wrapped.cause = lastErr;
694
816
  }
@@ -699,7 +821,7 @@ async function withRetry(label, fn) {
699
821
  // Public download primitives (now retry + progress aware)
700
822
  // ────────────────────────────────────────────────────────────────────────────
701
823
 
702
- async function followRedirects(url, opts) {
824
+ async function followRedirects(url, opts = {}) {
703
825
  const maxRedirects = 10;
704
826
  let current = url;
705
827
  for (let hop = 0; hop < maxRedirects; hop++) {
@@ -744,17 +866,21 @@ function streamToFile(response, destination, progress) {
744
866
  async function download(url, destination, options = {}) {
745
867
  await mkdir(path.dirname(destination), { recursive: true });
746
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);
747
872
  await withRetry(`download ${assetName}`, async (attempt) => {
748
873
  const result = await followRedirects(url, {
749
- totalTimeoutMs: downloadTimeoutMs(),
750
- stallMs: downloadStallMs(),
874
+ context,
875
+ totalTimeoutMs: downloadTimeoutMs(context),
876
+ stallMs: downloadStallMs(context),
751
877
  });
752
878
  const response = result.response;
753
879
  const lenHeader = response.headers["content-length"];
754
880
  const total = lenHeader ? Number.parseInt(lenHeader, 10) : 0;
755
881
  const progress = createProgressReporter(assetName, Number.isFinite(total) ? total : 0);
756
882
  if (attempt > 1) {
757
- logInfo(`retry attempt ${attempt}/${MAX_ATTEMPTS} for ${assetName}`);
883
+ logInfo(`retry attempt ${attempt}/${attemptLimit} for ${assetName}`);
758
884
  }
759
885
  try {
760
886
  await streamToFile(response, destination, progress);
@@ -768,14 +894,17 @@ async function download(url, destination, options = {}) {
768
894
  throw err;
769
895
  }
770
896
  progress.finish();
771
- });
897
+ }, context);
772
898
  }
773
899
 
774
- async function downloadText(url) {
900
+ async function downloadText(url, options = {}) {
901
+ const context =
902
+ options.context === undefined || options.context === null ? "runtime" : options.context;
775
903
  return withRetry(`fetch ${url}`, async () => {
776
904
  const result = await followRedirects(url, {
777
- totalTimeoutMs: downloadTimeoutMs(),
778
- stallMs: downloadStallMs(),
905
+ context,
906
+ totalTimeoutMs: downloadTimeoutMs(context),
907
+ stallMs: downloadStallMs(context),
779
908
  });
780
909
  const response = result.response;
781
910
  response.setEncoding("utf8");
@@ -798,7 +927,7 @@ async function downloadText(url) {
798
927
  });
799
928
  response.on("error", reject);
800
929
  });
801
- });
930
+ }, context);
802
931
  }
803
932
 
804
933
  async function readLocalVersion(file) {
@@ -850,11 +979,11 @@ async function verifyChecksum(filePath, assetName, checksums) {
850
979
  }
851
980
  }
852
981
 
853
- async function loadChecksums(version, repo) {
854
- return parseChecksumManifest(await downloadText(checksumManifestUrl(version, repo)));
982
+ async function loadChecksums(version, repo, options = {}) {
983
+ return parseChecksumManifest(await downloadText(checksumManifestUrl(version, repo), options));
855
984
  }
856
985
 
857
- async function ensureBinary(targetPath, assetName, version, repo, getChecksums) {
986
+ async function ensureBinary(targetPath, assetName, version, repo, getChecksums, options = {}) {
858
987
  const marker = `${targetPath}.version`;
859
988
  const downloadIfNeeded =
860
989
  process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD === "1" || process.env.DEEPSEEK_FORCE_DOWNLOAD === "1";
@@ -870,7 +999,7 @@ async function ensureBinary(targetPath, assetName, version, repo, getChecksums)
870
999
  const checksums = await getChecksums();
871
1000
  const url = releaseAssetUrl(assetName, version, repo);
872
1001
  const destination = `${targetPath}.${process.pid}.${Date.now()}.download`;
873
- await download(url, destination, { assetName });
1002
+ await download(url, destination, { assetName, context: options.context });
874
1003
  try {
875
1004
  await verifyChecksum(destination, assetName, checksums);
876
1005
  preflightGlibc(destination);
@@ -886,7 +1015,21 @@ async function ensureBinary(targetPath, assetName, version, repo, getChecksums)
886
1015
  return targetPath;
887
1016
  }
888
1017
 
889
- 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;
890
1033
  if (process.env.DEEPSEEK_TUI_DISABLE_INSTALL === "1" || process.env.DEEPSEEK_DISABLE_INSTALL === "1") {
891
1034
  return;
892
1035
  }
@@ -899,19 +1042,19 @@ async function run() {
899
1042
  let checksumsPromise;
900
1043
  const getChecksums = () => {
901
1044
  if (!checksumsPromise) {
902
- checksumsPromise = loadChecksums(version, repo);
1045
+ checksumsPromise = loadChecksums(version, repo, { context });
903
1046
  }
904
1047
  return checksumsPromise;
905
1048
  };
906
1049
 
907
1050
  await Promise.all([
908
- ensureBinary(paths.deepseek.target, paths.deepseek.asset, version, repo, getChecksums),
909
- 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 }),
910
1053
  ]);
911
1054
  }
912
1055
 
913
1056
  async function getBinaryPath(name) {
914
- await run();
1057
+ await run({ context: "runtime" });
915
1058
  const paths = binaryPaths();
916
1059
  if (name === "deepseek") {
917
1060
  return paths.deepseek.target;
@@ -924,15 +1067,28 @@ async function getBinaryPath(name) {
924
1067
 
925
1068
  module.exports = {
926
1069
  getBinaryPath,
1070
+ installFailureHint,
927
1071
  run,
1072
+ _internal: {
1073
+ isOptionalInstall,
1074
+ shouldIgnoreInstallFailure,
1075
+ defaultTimeoutMs,
1076
+ defaultStallMs,
1077
+ maxAttempts,
1078
+ withRetry,
1079
+ },
928
1080
  };
929
1081
 
930
1082
  if (require.main === module) {
931
- run().catch((error) => {
1083
+ run({ context: "install" }).catch((error) => {
932
1084
  console.error("deepseek-tui install failed:", error.message);
933
- if (process.env.DEEPSEEK_TUI_OPTIONAL_INSTALL === "1") {
1085
+ const hint = installFailureHint(error);
1086
+ if (hint) {
1087
+ console.error(hint);
1088
+ }
1089
+ if (shouldIgnoreInstallFailure("install", error)) {
934
1090
  console.error(
935
- "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.",
936
1092
  );
937
1093
  process.exit(0);
938
1094
  }
package/scripts/run.js CHANGED
@@ -3,9 +3,8 @@ const { getBinaryPath } = require("./install");
3
3
 
4
4
  const pkg = require("../package.json");
5
5
 
6
- function isVersionFlag() {
7
- const args = process.argv.slice(2);
8
- return args.includes("--version") || args.includes("-v") || args.includes("-V");
6
+ function isVersionFlag(args = process.argv.slice(2)) {
7
+ return args.includes("--version") || args.includes("-V");
9
8
  }
10
9
 
11
10
  function handleVersionFallback(binaryName) {
@@ -46,6 +45,7 @@ module.exports = {
46
45
  run,
47
46
  runDeepseek,
48
47
  runDeepseekTui,
48
+ _internal: { isVersionFlag },
49
49
  };
50
50
 
51
51
  if (require.main === module) {
@@ -0,0 +1,71 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+
6
+ const installScript = fs.readFileSync(
7
+ path.join(__dirname, "..", "scripts", "install.js"),
8
+ "utf8",
9
+ );
10
+ const { installFailureHint } = require("../scripts/install");
11
+
12
+ test("install script checks Node support before loading helpers", () => {
13
+ const guardIndex = installScript.indexOf("assertSupportedNode();");
14
+ const firstRequireIndex = installScript.indexOf("require(");
15
+
16
+ assert.notEqual(guardIndex, -1);
17
+ assert.notEqual(firstRequireIndex, -1);
18
+ assert.ok(guardIndex < firstRequireIndex);
19
+ });
20
+
21
+ test("install script remains parseable before the Node support guard runs", () => {
22
+ assert.equal(installScript.includes("??"), false);
23
+ assert.equal(installScript.includes("?."), false);
24
+ });
25
+
26
+ test("install failure hint explains release base override for blocked GitHub downloads", () => {
27
+ const previous = process.env.DEEPSEEK_TUI_RELEASE_BASE_URL;
28
+ delete process.env.DEEPSEEK_TUI_RELEASE_BASE_URL;
29
+ try {
30
+ const error = Object.assign(
31
+ new Error(
32
+ "fetch https://github.com/Hmbown/DeepSeek-TUI/releases/download/v0.8.18/deepseek-artifacts-sha256.txt failed after 5 attempts:\ngetaddrinfo ENOTFOUND github.com",
33
+ ),
34
+ { code: "ENOTFOUND" },
35
+ );
36
+
37
+ const hint = installFailureHint(error);
38
+
39
+ assert.match(hint, /DEEPSEEK_TUI_RELEASE_BASE_URL/);
40
+ assert.match(hint, /deepseek-artifacts-sha256\.txt/);
41
+ assert.match(hint, /platform binaries/);
42
+ } finally {
43
+ if (previous === undefined) {
44
+ delete process.env.DEEPSEEK_TUI_RELEASE_BASE_URL;
45
+ } else {
46
+ process.env.DEEPSEEK_TUI_RELEASE_BASE_URL = previous;
47
+ }
48
+ }
49
+ });
50
+
51
+ test("install failure hint checks configured release base when override is already set", () => {
52
+ const previous = process.env.DEEPSEEK_TUI_RELEASE_BASE_URL;
53
+ process.env.DEEPSEEK_TUI_RELEASE_BASE_URL = "https://mirror.example/deepseek/";
54
+ try {
55
+ const error = Object.assign(new Error("download stalled"), {
56
+ code: "EDOWNLOADTIMEOUT",
57
+ });
58
+
59
+ const hint = installFailureHint(error);
60
+
61
+ assert.match(hint, /is set to https:\/\/mirror\.example\/deepseek\//);
62
+ assert.match(hint, /deepseek-artifacts-sha256\.txt/);
63
+ assert.doesNotMatch(hint, /If GitHub is unavailable/);
64
+ } finally {
65
+ if (previous === undefined) {
66
+ delete process.env.DEEPSEEK_TUI_RELEASE_BASE_URL;
67
+ } else {
68
+ process.env.DEEPSEEK_TUI_RELEASE_BASE_URL = previous;
69
+ }
70
+ }
71
+ });
@@ -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
+ });
@@ -0,0 +1,11 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { _internal } = require("../scripts/run");
5
+
6
+ test("version fallback handles only version flags", () => {
7
+ assert.equal(_internal.isVersionFlag(["--version"]), true);
8
+ assert.equal(_internal.isVersionFlag(["-V"]), true);
9
+ assert.equal(_internal.isVersionFlag(["-v"]), false);
10
+ assert.equal(_internal.isVersionFlag(["--verbose"]), false);
11
+ });