@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.
- package/install.js +233 -36
- 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
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|