@yousali/codetok 0.2.2 → 0.3.1

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
@@ -11,6 +11,7 @@ npm install -g @yousali/codetok
11
11
  ## How it works
12
12
 
13
13
  - During `postinstall`, this package downloads the matching binary from GitHub Releases.
14
+ - Downloads use a `180s` timeout with up to `3` retries and exponential backoff.
14
15
  - The `codetok` command then runs that native binary.
15
16
 
16
17
  Supported targets:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yousali/codetok",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Track and aggregate token usage across AI coding CLI tools",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/miss-you/codetok#readme",
@@ -15,7 +15,8 @@
15
15
  "codetok": "bin/codetok.js"
16
16
  },
17
17
  "scripts": {
18
- "postinstall": "node scripts/install.mjs"
18
+ "postinstall": "node scripts/install.mjs",
19
+ "test:installer": "node --test test/download.test.mjs"
19
20
  },
20
21
  "dependencies": {
21
22
  "extract-zip": "^2.0.1",
@@ -0,0 +1,149 @@
1
+ import fs from 'node:fs';
2
+ import { promises as fsp } from 'node:fs';
3
+ import https from 'node:https';
4
+
5
+ export const maxRedirects = 5;
6
+ export const requestTimeoutMs = 180_000;
7
+ export const maxDownloadRetries = 3;
8
+ export const retryBaseDelayMs = 1_000;
9
+ export const retriableStatusCodes = new Set([408, 425, 429, 500, 502, 503, 504]);
10
+ export const retriableErrorCodes = new Set([
11
+ 'ETIMEDOUT',
12
+ 'ECONNRESET',
13
+ 'ECONNREFUSED',
14
+ 'EPIPE',
15
+ 'EHOSTUNREACH',
16
+ 'ENETUNREACH',
17
+ 'ENOTFOUND',
18
+ 'EAI_AGAIN',
19
+ ]);
20
+
21
+ export async function downloadToFile(url, destPath, options = {}) {
22
+ const {
23
+ maxRetries = maxDownloadRetries,
24
+ baseDelayMs = retryBaseDelayMs,
25
+ logger = (message) => console.warn(message),
26
+ sleepFn = sleep,
27
+ removeFile = (targetPath) => fsp.rm(targetPath, { force: true }),
28
+ downloadOnce = (downloadURL, targetPath) => downloadToFileOnce(downloadURL, targetPath, 0),
29
+ isRetriable = isRetriableDownloadError,
30
+ } = options;
31
+
32
+ let lastErr;
33
+ let attemptsMade = 0;
34
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
35
+ attemptsMade = attempt + 1;
36
+ if (attempt > 0) {
37
+ const delayMs = baseDelayMs * 2 ** (attempt - 1);
38
+ logger(`[codetok] retry ${attempt}/${maxRetries} in ${delayMs}ms: ${url}`);
39
+ await sleepFn(delayMs);
40
+ }
41
+
42
+ await removeFile(destPath).catch(() => {});
43
+
44
+ try {
45
+ await downloadOnce(url, destPath);
46
+ return;
47
+ } catch (err) {
48
+ lastErr = err;
49
+ if (!isRetriable(err) || attempt === maxRetries) {
50
+ break;
51
+ }
52
+ }
53
+ }
54
+
55
+ const attemptWord = attemptsMade === 1 ? 'attempt' : 'attempts';
56
+ const message = lastErr ? lastErr.message : 'unknown download error';
57
+ throw new Error(`download failed after ${attemptsMade} ${attemptWord} for ${url}: ${message}`);
58
+ }
59
+
60
+ export async function downloadToFileOnce(url, destPath, redirects = 0) {
61
+ if (redirects > maxRedirects) {
62
+ throw new Error(`too many redirects while downloading ${url}`);
63
+ }
64
+
65
+ return new Promise((resolve, reject) => {
66
+ let done = false;
67
+ const finish = (err) => {
68
+ if (done) {
69
+ return;
70
+ }
71
+ done = true;
72
+ if (err) {
73
+ reject(err);
74
+ return;
75
+ }
76
+ resolve();
77
+ };
78
+
79
+ const req = https.get(
80
+ url,
81
+ {
82
+ headers: {
83
+ 'User-Agent': 'codetok-npm-installer',
84
+ },
85
+ },
86
+ (res) => {
87
+ if (
88
+ res.statusCode &&
89
+ [301, 302, 303, 307, 308].includes(res.statusCode) &&
90
+ res.headers.location
91
+ ) {
92
+ const nextURL = new URL(res.headers.location, url).toString();
93
+ res.resume();
94
+ downloadToFileOnce(nextURL, destPath, redirects + 1)
95
+ .then(() => finish())
96
+ .catch(finish);
97
+ return;
98
+ }
99
+
100
+ if (res.statusCode !== 200) {
101
+ res.resume();
102
+ const statusErr = new Error(`download failed (${res.statusCode}) for ${url}`);
103
+ statusErr.statusCode = res.statusCode;
104
+ finish(statusErr);
105
+ return;
106
+ }
107
+
108
+ const ws = fs.createWriteStream(destPath);
109
+ ws.on('error', (err) => {
110
+ res.destroy();
111
+ finish(err);
112
+ });
113
+ ws.on('finish', () => finish());
114
+ res.on('error', (err) => {
115
+ ws.destroy();
116
+ finish(err);
117
+ });
118
+ res.pipe(ws);
119
+ }
120
+ );
121
+
122
+ req.setTimeout(requestTimeoutMs, () => {
123
+ const timeoutErr = new Error(`download timeout after ${requestTimeoutMs}ms for ${url}`);
124
+ timeoutErr.code = 'ETIMEDOUT';
125
+ req.destroy(timeoutErr);
126
+ });
127
+ req.on('error', finish);
128
+ });
129
+ }
130
+
131
+ export function isRetriableDownloadError(err) {
132
+ if (!err) {
133
+ return false;
134
+ }
135
+
136
+ if (typeof err.statusCode === 'number') {
137
+ return retriableStatusCodes.has(err.statusCode);
138
+ }
139
+
140
+ if (typeof err.code === 'string' && retriableErrorCodes.has(err.code)) {
141
+ return true;
142
+ }
143
+
144
+ return typeof err.message === 'string' && err.message.toLowerCase().includes('timeout');
145
+ }
146
+
147
+ export function sleep(ms) {
148
+ return new Promise((resolve) => setTimeout(resolve, ms));
149
+ }
@@ -1,13 +1,13 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import { promises as fsp } from 'node:fs';
4
- import https from 'node:https';
5
4
  import os from 'node:os';
6
5
  import path from 'node:path';
7
6
  import { fileURLToPath } from 'node:url';
8
7
 
9
8
  import extract from 'extract-zip';
10
9
  import { x as extractTar } from 'tar';
10
+ import { downloadToFile } from './download.mjs';
11
11
 
12
12
  const __filename = fileURLToPath(import.meta.url);
13
13
  const __dirname = path.dirname(__filename);
@@ -18,8 +18,6 @@ const vendorDir = path.join(pkgRoot, 'vendor');
18
18
  const project = 'codetok';
19
19
  const owner = 'miss-you';
20
20
  const repo = 'codetok';
21
- const maxRedirects = 5;
22
- const requestTimeoutMs = 60_000;
23
21
 
24
22
  const isWindows = process.platform === 'win32';
25
23
  const binaryName = isWindows ? 'codetok.exe' : 'codetok';
@@ -157,71 +155,6 @@ async function sha256(filePath) {
157
155
  });
158
156
  }
159
157
 
160
- async function downloadToFile(url, destPath, redirects = 0) {
161
- if (redirects > maxRedirects) {
162
- throw new Error(`too many redirects while downloading ${url}`);
163
- }
164
-
165
- return new Promise((resolve, reject) => {
166
- let done = false;
167
- const finish = (err) => {
168
- if (done) {
169
- return;
170
- }
171
- done = true;
172
- if (err) {
173
- reject(err);
174
- return;
175
- }
176
- resolve();
177
- };
178
-
179
- const req = https.get(
180
- url,
181
- {
182
- headers: {
183
- 'User-Agent': 'codetok-npm-installer',
184
- },
185
- },
186
- (res) => {
187
- if (
188
- res.statusCode &&
189
- [301, 302, 303, 307, 308].includes(res.statusCode) &&
190
- res.headers.location
191
- ) {
192
- const nextURL = new URL(res.headers.location, url).toString();
193
- res.resume();
194
- downloadToFile(nextURL, destPath, redirects + 1).then(() => finish()).catch(finish);
195
- return;
196
- }
197
-
198
- if (res.statusCode !== 200) {
199
- res.resume();
200
- finish(new Error(`download failed (${res.statusCode}) for ${url}`));
201
- return;
202
- }
203
-
204
- const ws = fs.createWriteStream(destPath);
205
- ws.on('error', (err) => {
206
- res.destroy();
207
- finish(err);
208
- });
209
- ws.on('finish', () => finish());
210
- res.on('error', (err) => {
211
- ws.destroy();
212
- finish(err);
213
- });
214
- res.pipe(ws);
215
- }
216
- );
217
-
218
- req.setTimeout(requestTimeoutMs, () => {
219
- req.destroy(new Error(`download timeout after ${requestTimeoutMs}ms for ${url}`));
220
- });
221
- req.on('error', finish);
222
- });
223
- }
224
-
225
158
  async function findFileByName(rootDir, name) {
226
159
  const entries = await fsp.readdir(rootDir, { withFileTypes: true });
227
160
  for (const entry of entries) {