@tokenade/cli 0.5.1 → 0.5.3

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 +197 -33
  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,15 +23,21 @@
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
+
26
41
  function rustTarget() {
27
42
  const p = process.platform;
28
43
  const a = process.arch;
@@ -36,49 +51,173 @@ function rustTarget() {
36
51
  throw new Error(`unsupported platform: ${p}/${a}`);
37
52
  }
38
53
 
39
- function get(url, redirects = 0) {
54
+ // Resolve the proxy that applies to `targetUrl`, honouring the standard
55
+ // HTTPS_PROXY / HTTP_PROXY / ALL_PROXY and NO_PROXY env vars (both cases).
56
+ // Returns null when no proxy applies — including when NO_PROXY matches.
57
+ function proxyFor(targetUrl) {
58
+ const u = new URL(targetUrl);
59
+ const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
60
+ for (const raw of noProxy.split(",").map((s) => s.trim()).filter(Boolean)) {
61
+ if (raw === "*") return null;
62
+ const entry = raw.replace(/^\./, "").toLowerCase();
63
+ const host = u.hostname.toLowerCase();
64
+ if (host === entry || host.endsWith(`.${entry}`)) return null;
65
+ }
66
+ const isHttps = u.protocol === "https:";
67
+ const proxy =
68
+ (isHttps
69
+ ? process.env.HTTPS_PROXY || process.env.https_proxy
70
+ : process.env.HTTP_PROXY || process.env.http_proxy) ||
71
+ process.env.ALL_PROXY ||
72
+ process.env.all_proxy;
73
+ return proxy || null;
74
+ }
75
+
76
+ // Never echo proxy credentials back to the terminal in a diagnostic.
77
+ function redact(proxyUrl) {
78
+ try {
79
+ const u = new URL(proxyUrl);
80
+ if (u.username || u.password) {
81
+ u.username = "***";
82
+ u.password = "";
83
+ }
84
+ return u.toString();
85
+ } catch {
86
+ return proxyUrl;
87
+ }
88
+ }
89
+
90
+ // One HTTP(S) GET, following redirects, optionally tunnelled through a proxy
91
+ // via CONNECT. Resolves to the live response stream (status 200).
92
+ function get(targetUrl, redirects = 0) {
40
93
  return new Promise((resolve, reject) => {
41
94
  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
- }
95
+
96
+ const onResponse = (res) => {
97
+ const code = res.statusCode || 0;
98
+ if (code >= 300 && code < 400 && res.headers.location) {
99
+ res.resume();
100
+ const next = new URL(res.headers.location, targetUrl).toString();
101
+ return resolve(get(next, redirects + 1));
102
+ }
103
+ if (code !== 200) {
104
+ res.resume();
105
+ const err = new Error(`GET ${targetUrl} → HTTP ${code}`);
106
+ err.httpStatus = code;
107
+ return reject(err);
108
+ }
109
+ resolve(res);
110
+ };
111
+
112
+ const proxy = proxyFor(targetUrl);
113
+ let req;
114
+ if (proxy) {
115
+ const t = new URL(targetUrl);
116
+ const p = new URL(proxy);
117
+ const port = t.port || (t.protocol === "https:" ? 443 : 80);
118
+ const headers = { host: `${t.hostname}:${port}` };
119
+ if (p.username || p.password) {
120
+ const auth = Buffer.from(
121
+ `${decodeURIComponent(p.username)}:${decodeURIComponent(p.password)}`,
122
+ ).toString("base64");
123
+ headers["proxy-authorization"] = `Basic ${auth}`;
124
+ }
125
+ const connectReq = http.request({
126
+ host: p.hostname,
127
+ port: p.port || 80,
128
+ method: "CONNECT",
129
+ path: `${t.hostname}:${port}`,
130
+ headers,
131
+ });
132
+ connectReq.on("connect", (res, socket) => {
53
133
  if (res.statusCode !== 200) {
54
- res.resume();
55
- return reject(new Error(`GET ${url} → HTTP ${res.statusCode}`));
134
+ socket.destroy();
135
+ const err = new Error(
136
+ `proxy CONNECT ${redact(proxy)} → HTTP ${res.statusCode}`,
137
+ );
138
+ err.httpStatus = res.statusCode;
139
+ return reject(err);
56
140
  }
57
- resolve(res);
58
- })
59
- .on("error", reject);
141
+ https
142
+ .get(
143
+ {
144
+ host: t.hostname,
145
+ port,
146
+ path: `${t.pathname}${t.search}`,
147
+ socket,
148
+ agent: false,
149
+ servername: t.hostname,
150
+ },
151
+ onResponse,
152
+ )
153
+ .on("error", reject);
154
+ });
155
+ req = connectReq;
156
+ } else {
157
+ req = https.get(targetUrl, onResponse);
158
+ }
159
+ req.on("error", reject);
160
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => {
161
+ req.destroy(new Error(`timeout after ${REQUEST_TIMEOUT_MS}ms`));
162
+ });
163
+ if (req !== undefined && typeof req.end === "function" && proxy) req.end();
60
164
  });
61
165
  }
62
166
 
167
+ // A network error is worth retrying; an HTTP 4xx (other than 429) or a bad
168
+ // checksum is not — those won't fix themselves on a second attempt.
169
+ function isRetryable(err) {
170
+ if (err && typeof err.httpStatus === "number") {
171
+ return err.httpStatus === 429 || err.httpStatus >= 500;
172
+ }
173
+ return true; // ECONNRESET / ETIMEDOUT / ENOTFOUND / EAI_AGAIN / timeout / proxy
174
+ }
175
+
176
+ function sleep(ms) {
177
+ return new Promise((r) => setTimeout(r, ms));
178
+ }
179
+
180
+ async function withRetry(label, fn) {
181
+ let lastErr;
182
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
183
+ try {
184
+ return await fn();
185
+ } catch (err) {
186
+ lastErr = err;
187
+ if (attempt === MAX_ATTEMPTS || !isRetryable(err)) break;
188
+ const backoff = 500 * 2 ** (attempt - 1);
189
+ console.error(
190
+ ` ${label} failed (${err.message}) — retrying in ${backoff}ms (${attempt}/${MAX_ATTEMPTS - 1})`,
191
+ );
192
+ await sleep(backoff);
193
+ }
194
+ }
195
+ throw lastErr;
196
+ }
197
+
63
198
  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"));
199
+ return withRetry("manifest fetch", async () => {
200
+ const res = await get(url);
201
+ const chunks = [];
202
+ for await (const c of res) chunks.push(c);
203
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
204
+ });
68
205
  }
69
206
 
70
207
  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);
208
+ return withRetry("download", async () => {
209
+ const res = await get(url);
210
+ const hash = crypto.createHash("sha256");
211
+ await new Promise((resolve, reject) => {
212
+ const out = fs.createWriteStream(dest);
213
+ res.on("data", (d) => hash.update(d));
214
+ res.on("error", reject);
215
+ res.pipe(out);
216
+ out.on("finish", resolve);
217
+ out.on("error", reject);
218
+ });
219
+ return hash.digest("hex");
80
220
  });
81
- return hash.digest("hex");
82
221
  }
83
222
 
84
223
  async function main() {
@@ -101,6 +240,19 @@ async function main() {
101
240
  );
102
241
  }
103
242
 
243
+ // require("tar") lazily: a missing/half-installed dep should surface its own
244
+ // clear error here, not masquerade as a download failure at module load.
245
+ let tar;
246
+ try {
247
+ tar = require("tar");
248
+ } catch (e) {
249
+ fs.rmSync(tmp, { force: true });
250
+ throw new Error(
251
+ `download succeeded but the 'tar' dependency is missing (${e.message}); ` +
252
+ "reinstall without --ignore-scripts / --no-optional",
253
+ );
254
+ }
255
+
104
256
  fs.rmSync(VENDOR, { recursive: true, force: true });
105
257
  fs.mkdirSync(VENDOR, { recursive: true });
106
258
  await tar.x({ file: tmp, cwd: VENDOR });
@@ -124,9 +276,21 @@ async function main() {
124
276
  }
125
277
 
126
278
  main().catch((err) => {
279
+ let target;
280
+ try {
281
+ target = rustTarget();
282
+ } catch {
283
+ target = `${process.platform}/${process.arch}`;
284
+ }
127
285
  console.error(`✗ tokenade install failed: ${err.message}`);
128
286
  console.error(
129
- " You can retry, or download manually from https://downloads.tokenade.net",
287
+ ` platform ${process.platform}/${process.arch} (target ${target}), node ${process.version}`,
288
+ );
289
+ const proxy = proxyFor(MANIFEST_URL);
290
+ if (proxy) console.error(` via proxy ${redact(proxy)}`);
291
+ console.error(` manifest ${MANIFEST_URL}`);
292
+ console.error(
293
+ " Retry; if you're behind a corporate proxy set HTTPS_PROXY, or download manually from https://downloads.tokenade.net",
130
294
  );
131
295
  process.exit(1);
132
296
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenade/cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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
  }