@tokenade/cli 0.5.2 → 0.5.5

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.
Files changed (2) hide show
  1. package/install.js +233 -36
  2. package/package.json +6 -3
package/install.js CHANGED
@@ -7,6 +7,15 @@
7
7
  * Closed-source commercial binary, distributed via the npm registry (the trust
8
8
  * anchor) — same pattern as esbuild/biome/swc. Nothing is executed blindly:
9
9
  * the checksum comes from downloads.tokenade.net/manifest.json.
10
+ *
11
+ * Network robustness (why this file is more than a one-liner): a postinstall
12
+ * runs on whatever machine `npm install` runs on — corporate laptops behind a
13
+ * proxy, CI with flaky egress, Alpine containers. The three things that break a
14
+ * "can't download" install in the field, in order: (1) an HTTPS proxy that
15
+ * node:https does NOT honour on its own, (2) no timeout so a black-holed socket
16
+ * hangs forever, (3) no retry so one blip is fatal. We handle all three, and on
17
+ * failure we print enough context (platform, target, url, proxy, http status)
18
+ * that the cause is obvious instead of a bare "ECONNREFUSED".
10
19
  */
11
20
 
12
21
  "use strict";
@@ -14,71 +23,234 @@
14
23
  const fs = require("node:fs");
15
24
  const path = require("node:path");
16
25
  const os = require("node:os");
26
+ const http = require("node:http");
17
27
  const https = require("node:https");
18
28
  const crypto = require("node:crypto");
19
- const tar = require("tar");
29
+ const { URL } = require("node:url");
20
30
 
21
31
  const DOWNLOADS_BASE =
22
32
  process.env.TOKENADE_DOWNLOADS_BASE || "https://downloads.tokenade.net";
23
33
  const MANIFEST_URL = `${DOWNLOADS_BASE}/manifest.json`;
24
34
  const VENDOR = path.join(__dirname, "vendor");
25
35
 
36
+ // Per-request wall clock. A download that stalls past this is treated as a
37
+ // network error and retried, rather than hanging the whole `npm install`.
38
+ const REQUEST_TIMEOUT_MS = Number(process.env.TOKENADE_HTTP_TIMEOUT_MS) || 30000;
39
+ const MAX_ATTEMPTS = 4;
40
+
41
+ // Is this Linux userland musl (Alpine, Void-musl, …) rather than glibc?
42
+ // A glibc node reports its runtime glibc version in the process report header;
43
+ // a musl node does not. We fall back to looking for the musl loader on disk.
44
+ // When genuinely unsure we return false (glibc) — that preserves the historical
45
+ // x86_64-gnu mapping for the overwhelming glibc majority, so a detection miss is
46
+ // never worse than the status quo.
47
+ function isMuslLinux() {
48
+ if (process.platform !== "linux") return false;
49
+ try {
50
+ const header = process.report && process.report.getReport().header;
51
+ if (header && typeof header.glibcVersionRuntime === "string") return false;
52
+ if (header && "glibcVersionRuntime" in header) {
53
+ // key present but not a string ⇒ no glibc runtime ⇒ musl
54
+ return true;
55
+ }
56
+ } catch {
57
+ // fall through to the on-disk probe
58
+ }
59
+ try {
60
+ return fs
61
+ .readdirSync("/lib")
62
+ .some((f) => f.startsWith("ld-musl-"));
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
26
68
  function rustTarget() {
27
69
  const p = process.platform;
28
70
  const a = process.arch;
29
71
  if (p === "darwin")
30
72
  return a === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
31
- if (p === "linux")
32
- return a === "arm64"
33
- ? "aarch64-unknown-linux-musl"
73
+ if (p === "linux") {
74
+ if (a === "arm64") return "aarch64-unknown-linux-musl";
75
+ // x86_64: a fully-static musl build runs on glibc too, but musl's DNS
76
+ // resolver ignores /etc/nsswitch.conf — so we only hand musl to machines
77
+ // whose libc is already musl (Alpine et al.), and keep the proven glibc
78
+ // build as the default everywhere else.
79
+ return isMuslLinux()
80
+ ? "x86_64-unknown-linux-musl"
34
81
  : "x86_64-unknown-linux-gnu";
82
+ }
35
83
  if (p === "win32") return "x86_64-pc-windows-gnu";
36
84
  throw new Error(`unsupported platform: ${p}/${a}`);
37
85
  }
38
86
 
39
- function get(url, redirects = 0) {
87
+ // Resolve the proxy that applies to `targetUrl`, honouring the standard
88
+ // HTTPS_PROXY / HTTP_PROXY / ALL_PROXY and NO_PROXY env vars (both cases).
89
+ // Returns null when no proxy applies — including when NO_PROXY matches.
90
+ function proxyFor(targetUrl) {
91
+ const u = new URL(targetUrl);
92
+ const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
93
+ for (const raw of noProxy.split(",").map((s) => s.trim()).filter(Boolean)) {
94
+ if (raw === "*") return null;
95
+ const entry = raw.replace(/^\./, "").toLowerCase();
96
+ const host = u.hostname.toLowerCase();
97
+ if (host === entry || host.endsWith(`.${entry}`)) return null;
98
+ }
99
+ const isHttps = u.protocol === "https:";
100
+ const proxy =
101
+ (isHttps
102
+ ? process.env.HTTPS_PROXY || process.env.https_proxy
103
+ : process.env.HTTP_PROXY || process.env.http_proxy) ||
104
+ process.env.ALL_PROXY ||
105
+ process.env.all_proxy;
106
+ return proxy || null;
107
+ }
108
+
109
+ // Never echo proxy credentials back to the terminal in a diagnostic.
110
+ function redact(proxyUrl) {
111
+ try {
112
+ const u = new URL(proxyUrl);
113
+ if (u.username || u.password) {
114
+ u.username = "***";
115
+ u.password = "";
116
+ }
117
+ return u.toString();
118
+ } catch {
119
+ return proxyUrl;
120
+ }
121
+ }
122
+
123
+ // One HTTP(S) GET, following redirects, optionally tunnelled through a proxy
124
+ // via CONNECT. Resolves to the live response stream (status 200).
125
+ function get(targetUrl, redirects = 0) {
40
126
  return new Promise((resolve, reject) => {
41
127
  if (redirects > 5) return reject(new Error("too many redirects"));
42
- https
43
- .get(url, (res) => {
44
- if (
45
- res.statusCode &&
46
- res.statusCode >= 300 &&
47
- res.statusCode < 400 &&
48
- res.headers.location
49
- ) {
50
- res.resume();
51
- return resolve(get(res.headers.location, redirects + 1));
52
- }
128
+
129
+ const onResponse = (res) => {
130
+ const code = res.statusCode || 0;
131
+ if (code >= 300 && code < 400 && res.headers.location) {
132
+ res.resume();
133
+ const next = new URL(res.headers.location, targetUrl).toString();
134
+ return resolve(get(next, redirects + 1));
135
+ }
136
+ if (code !== 200) {
137
+ res.resume();
138
+ const err = new Error(`GET ${targetUrl} → HTTP ${code}`);
139
+ err.httpStatus = code;
140
+ return reject(err);
141
+ }
142
+ resolve(res);
143
+ };
144
+
145
+ const proxy = proxyFor(targetUrl);
146
+ let req;
147
+ if (proxy) {
148
+ const t = new URL(targetUrl);
149
+ const p = new URL(proxy);
150
+ const port = t.port || (t.protocol === "https:" ? 443 : 80);
151
+ const headers = { host: `${t.hostname}:${port}` };
152
+ if (p.username || p.password) {
153
+ const auth = Buffer.from(
154
+ `${decodeURIComponent(p.username)}:${decodeURIComponent(p.password)}`,
155
+ ).toString("base64");
156
+ headers["proxy-authorization"] = `Basic ${auth}`;
157
+ }
158
+ const connectReq = http.request({
159
+ host: p.hostname,
160
+ port: p.port || 80,
161
+ method: "CONNECT",
162
+ path: `${t.hostname}:${port}`,
163
+ headers,
164
+ });
165
+ connectReq.on("connect", (res, socket) => {
53
166
  if (res.statusCode !== 200) {
54
- res.resume();
55
- return reject(new Error(`GET ${url} → HTTP ${res.statusCode}`));
167
+ socket.destroy();
168
+ const err = new Error(
169
+ `proxy CONNECT ${redact(proxy)} → HTTP ${res.statusCode}`,
170
+ );
171
+ err.httpStatus = res.statusCode;
172
+ return reject(err);
56
173
  }
57
- resolve(res);
58
- })
59
- .on("error", reject);
174
+ https
175
+ .get(
176
+ {
177
+ host: t.hostname,
178
+ port,
179
+ path: `${t.pathname}${t.search}`,
180
+ socket,
181
+ agent: false,
182
+ servername: t.hostname,
183
+ },
184
+ onResponse,
185
+ )
186
+ .on("error", reject);
187
+ });
188
+ req = connectReq;
189
+ } else {
190
+ req = https.get(targetUrl, onResponse);
191
+ }
192
+ req.on("error", reject);
193
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => {
194
+ req.destroy(new Error(`timeout after ${REQUEST_TIMEOUT_MS}ms`));
195
+ });
196
+ if (req !== undefined && typeof req.end === "function" && proxy) req.end();
60
197
  });
61
198
  }
62
199
 
200
+ // A network error is worth retrying; an HTTP 4xx (other than 429) or a bad
201
+ // checksum is not — those won't fix themselves on a second attempt.
202
+ function isRetryable(err) {
203
+ if (err && typeof err.httpStatus === "number") {
204
+ return err.httpStatus === 429 || err.httpStatus >= 500;
205
+ }
206
+ return true; // ECONNRESET / ETIMEDOUT / ENOTFOUND / EAI_AGAIN / timeout / proxy
207
+ }
208
+
209
+ function sleep(ms) {
210
+ return new Promise((r) => setTimeout(r, ms));
211
+ }
212
+
213
+ async function withRetry(label, fn) {
214
+ let lastErr;
215
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
216
+ try {
217
+ return await fn();
218
+ } catch (err) {
219
+ lastErr = err;
220
+ if (attempt === MAX_ATTEMPTS || !isRetryable(err)) break;
221
+ const backoff = 500 * 2 ** (attempt - 1);
222
+ console.error(
223
+ ` ${label} failed (${err.message}) — retrying in ${backoff}ms (${attempt}/${MAX_ATTEMPTS - 1})`,
224
+ );
225
+ await sleep(backoff);
226
+ }
227
+ }
228
+ throw lastErr;
229
+ }
230
+
63
231
  async function fetchJson(url) {
64
- const res = await get(url);
65
- const chunks = [];
66
- for await (const c of res) chunks.push(c);
67
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
232
+ return withRetry("manifest fetch", async () => {
233
+ const res = await get(url);
234
+ const chunks = [];
235
+ for await (const c of res) chunks.push(c);
236
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
237
+ });
68
238
  }
69
239
 
70
240
  async function downloadTo(url, dest) {
71
- const res = await get(url);
72
- const hash = crypto.createHash("sha256");
73
- await new Promise((resolve, reject) => {
74
- const out = fs.createWriteStream(dest);
75
- res.on("data", (d) => hash.update(d));
76
- res.pipe(out);
77
- out.on("finish", resolve);
78
- out.on("error", reject);
79
- res.on("error", reject);
241
+ return withRetry("download", async () => {
242
+ const res = await get(url);
243
+ const hash = crypto.createHash("sha256");
244
+ await new Promise((resolve, reject) => {
245
+ const out = fs.createWriteStream(dest);
246
+ res.on("data", (d) => hash.update(d));
247
+ res.on("error", reject);
248
+ res.pipe(out);
249
+ out.on("finish", resolve);
250
+ out.on("error", reject);
251
+ });
252
+ return hash.digest("hex");
80
253
  });
81
- return hash.digest("hex");
82
254
  }
83
255
 
84
256
  async function main() {
@@ -101,6 +273,19 @@ async function main() {
101
273
  );
102
274
  }
103
275
 
276
+ // require("tar") lazily: a missing/half-installed dep should surface its own
277
+ // clear error here, not masquerade as a download failure at module load.
278
+ let tar;
279
+ try {
280
+ tar = require("tar");
281
+ } catch (e) {
282
+ fs.rmSync(tmp, { force: true });
283
+ throw new Error(
284
+ `download succeeded but the 'tar' dependency is missing (${e.message}); ` +
285
+ "reinstall without --ignore-scripts / --no-optional",
286
+ );
287
+ }
288
+
104
289
  fs.rmSync(VENDOR, { recursive: true, force: true });
105
290
  fs.mkdirSync(VENDOR, { recursive: true });
106
291
  await tar.x({ file: tmp, cwd: VENDOR });
@@ -124,9 +309,21 @@ async function main() {
124
309
  }
125
310
 
126
311
  main().catch((err) => {
312
+ let target;
313
+ try {
314
+ target = rustTarget();
315
+ } catch {
316
+ target = `${process.platform}/${process.arch}`;
317
+ }
127
318
  console.error(`✗ tokenade install failed: ${err.message}`);
128
319
  console.error(
129
- " You can retry, or download manually from https://downloads.tokenade.net",
320
+ ` platform ${process.platform}/${process.arch} (target ${target}), node ${process.version}`,
321
+ );
322
+ const proxy = proxyFor(MANIFEST_URL);
323
+ if (proxy) console.error(` via proxy ${redact(proxy)}`);
324
+ console.error(` manifest ${MANIFEST_URL}`);
325
+ console.error(
326
+ " Retry; if you're behind a corporate proxy set HTTPS_PROXY, or download manually from https://downloads.tokenade.net",
130
327
  );
131
328
  process.exit(1);
132
329
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenade/cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.5",
4
4
  "description": "Tokenade — cut your AI coding agent's token bill. Installs the Tokenade CLI + MCP server (a local, paid token-reduction tool; activate via your browser).",
5
5
  "homepage": "https://tokenade.net",
6
6
  "license": "UNLICENSED",
@@ -12,7 +12,7 @@
12
12
  "postinstall": "node install.js"
13
13
  },
14
14
  "dependencies": {
15
- "tar": "^7.4.3"
15
+ "tar": "^7.5.16"
16
16
  },
17
17
  "files": [
18
18
  "bin/",
@@ -42,5 +42,8 @@
42
42
  ],
43
43
  "publishConfig": {
44
44
  "access": "public"
45
- }
45
+ },
46
+ "main": "install.js",
47
+ "author": "",
48
+ "type": "commonjs"
46
49
  }