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