@mcptoolshop/registry-stats 3.2.0 → 3.2.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/README.md +12 -2
- package/dist/cli.js +288 -106
- package/dist/index.cjs +210 -46
- package/dist/index.d.cts +63 -4
- package/dist/index.d.ts +63 -4
- package/dist/index.js +210 -46
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { writeFileSync, existsSync as existsSync2 } from "fs";
|
|
5
|
-
import { resolve as resolve2 } from "path";
|
|
4
|
+
import { writeFileSync, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
5
|
+
import { resolve as resolve2, dirname as dirname2, join } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
6
7
|
|
|
7
8
|
// src/types.ts
|
|
8
9
|
var RegistryError = class extends Error {
|
|
@@ -20,9 +21,10 @@ var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
|
20
21
|
var MAX_RETRIES = 3;
|
|
21
22
|
var BASE_DELAY = 1e3;
|
|
22
23
|
var registryLocks = /* @__PURE__ */ new Map();
|
|
24
|
+
var MAX_LOCK_ENTRIES = 50;
|
|
23
25
|
var REGISTRY_DELAYS = {
|
|
24
|
-
npm:
|
|
25
|
-
// ~
|
|
26
|
+
npm: 800,
|
|
27
|
+
// ~1.25 req/s — safe for 91+ scoped packages (429s at 400ms)
|
|
26
28
|
pypi: 2200,
|
|
27
29
|
// 30 req/60s = 1 per 2s, with headroom
|
|
28
30
|
docker: 4e3
|
|
@@ -34,13 +36,27 @@ function acquireSlot(registry) {
|
|
|
34
36
|
const prev = registryLocks.get(registry) ?? Promise.resolve();
|
|
35
37
|
const slot = prev.then(() => new Promise((r) => setTimeout(r, minDelay)));
|
|
36
38
|
registryLocks.set(registry, slot);
|
|
39
|
+
if (registryLocks.size > MAX_LOCK_ENTRIES) {
|
|
40
|
+
const oldest = registryLocks.keys().next().value;
|
|
41
|
+
if (oldest !== void 0) registryLocks.delete(oldest);
|
|
42
|
+
}
|
|
37
43
|
return prev;
|
|
38
44
|
}
|
|
39
|
-
async function
|
|
45
|
+
async function fetchRetryCore(url, registry, init, preRequest) {
|
|
40
46
|
let lastError;
|
|
41
47
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
42
|
-
await
|
|
43
|
-
|
|
48
|
+
if (preRequest) await preRequest();
|
|
49
|
+
let res;
|
|
50
|
+
try {
|
|
51
|
+
res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
54
|
+
lastError = new RegistryError(registry, 0, `Network error: ${message} \u2014 ${url}`);
|
|
55
|
+
if (attempt === MAX_RETRIES) break;
|
|
56
|
+
const backoff2 = BASE_DELAY * Math.pow(2, attempt);
|
|
57
|
+
await new Promise((r) => setTimeout(r, backoff2));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
44
60
|
if (res.status === 404) return null;
|
|
45
61
|
if (res.ok) return res.json();
|
|
46
62
|
const retryAfter = res.headers.get("retry-after");
|
|
@@ -57,41 +73,25 @@ async function fetchWithRetry(url, registry, init) {
|
|
|
57
73
|
const delay = Math.max(backoff, retryAfterMs);
|
|
58
74
|
await new Promise((r) => setTimeout(r, delay));
|
|
59
75
|
}
|
|
60
|
-
throw lastError;
|
|
76
|
+
throw lastError ?? new RegistryError(registry, 0, `Fetch failed after ${MAX_RETRIES} retries: ${url}`);
|
|
77
|
+
}
|
|
78
|
+
async function fetchWithRetry(url, registry, init) {
|
|
79
|
+
return fetchRetryCore(url, registry, init, () => acquireSlot(registry));
|
|
61
80
|
}
|
|
62
81
|
async function fetchDirect(url, registry, init) {
|
|
63
|
-
|
|
64
|
-
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
65
|
-
const res = await fetch(url, { signal: AbortSignal.timeout(3e4), ...init });
|
|
66
|
-
if (res.status === 404) return null;
|
|
67
|
-
if (res.ok) return res.json();
|
|
68
|
-
const retryAfter = res.headers.get("retry-after");
|
|
69
|
-
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : void 0;
|
|
70
|
-
lastError = new RegistryError(
|
|
71
|
-
registry,
|
|
72
|
-
res.status,
|
|
73
|
-
`${res.statusText}: ${url}`,
|
|
74
|
-
retryAfterSeconds
|
|
75
|
-
);
|
|
76
|
-
if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
|
|
77
|
-
const backoff = BASE_DELAY * Math.pow(2, attempt);
|
|
78
|
-
const retryAfterMs = retryAfterSeconds ? retryAfterSeconds * 1e3 : 0;
|
|
79
|
-
const delay = Math.max(backoff, retryAfterMs);
|
|
80
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
81
|
-
}
|
|
82
|
-
throw lastError;
|
|
82
|
+
return fetchRetryCore(url, registry, init);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// src/providers/npm.ts
|
|
86
86
|
var API = "https://api.npmjs.org/downloads";
|
|
87
87
|
var npm = {
|
|
88
88
|
name: "npm",
|
|
89
|
-
async getStats(
|
|
89
|
+
async getStats(pkg2) {
|
|
90
90
|
const end = /* @__PURE__ */ new Date();
|
|
91
91
|
const start = new Date(end);
|
|
92
92
|
start.setDate(start.getDate() - 30);
|
|
93
93
|
const data = await fetchWithRetry(
|
|
94
|
-
`${API}/range/${fmt(start)}:${fmt(end)}/${
|
|
94
|
+
`${API}/range/${fmt(start)}:${fmt(end)}/${encodeNpmPackage(pkg2)}`,
|
|
95
95
|
"npm"
|
|
96
96
|
);
|
|
97
97
|
if (!data || !data.downloads || data.downloads.length === 0) return null;
|
|
@@ -101,12 +101,12 @@ var npm = {
|
|
|
101
101
|
const lastMonth = days.reduce((s, d) => s + d.downloads, 0);
|
|
102
102
|
return {
|
|
103
103
|
registry: "npm",
|
|
104
|
-
package:
|
|
104
|
+
package: pkg2,
|
|
105
105
|
downloads: { lastDay, lastWeek, lastMonth },
|
|
106
106
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
107
107
|
};
|
|
108
108
|
},
|
|
109
|
-
async getRange(
|
|
109
|
+
async getRange(pkg2, start, end) {
|
|
110
110
|
const startDate = new Date(start);
|
|
111
111
|
const endDate = new Date(end);
|
|
112
112
|
const maxDays = 549;
|
|
@@ -118,7 +118,7 @@ var npm = {
|
|
|
118
118
|
const actualEnd = chunkEnd > endDate ? endDate : chunkEnd;
|
|
119
119
|
const s = fmt(cursor);
|
|
120
120
|
const e = fmt(actualEnd);
|
|
121
|
-
const data = await fetchWithRetry(`${API}/range/${s}:${e}/${
|
|
121
|
+
const data = await fetchWithRetry(`${API}/range/${s}:${e}/${encodeNpmPackage(pkg2)}`, "npm");
|
|
122
122
|
if (data) {
|
|
123
123
|
for (const d of data.downloads) {
|
|
124
124
|
chunks.push({ date: d.day, downloads: d.downloads });
|
|
@@ -154,22 +154,26 @@ async function npmBulkPoint(packages, period = "last-month") {
|
|
|
154
154
|
function fmt(d) {
|
|
155
155
|
return d.toISOString().slice(0, 10);
|
|
156
156
|
}
|
|
157
|
+
function encodeNpmPackage(pkg2) {
|
|
158
|
+
return encodeURIComponent(pkg2);
|
|
159
|
+
}
|
|
157
160
|
|
|
158
161
|
// src/providers/pypi.ts
|
|
159
162
|
var API2 = "https://pypistats.org/api";
|
|
160
163
|
var pypi = {
|
|
161
164
|
name: "pypi",
|
|
162
165
|
rateLimit: { maxRequests: 30, windowSeconds: 60, authRaisesLimit: false },
|
|
163
|
-
async getStats(
|
|
166
|
+
async getStats(pkg2) {
|
|
167
|
+
const safePkg = encodeURIComponent(pkg2);
|
|
164
168
|
const [recent, overall] = await Promise.all([
|
|
165
|
-
fetchWithRetry(`${API2}/packages/${
|
|
166
|
-
fetchWithRetry(`${API2}/packages/${
|
|
169
|
+
fetchWithRetry(`${API2}/packages/${safePkg}/recent`, "pypi"),
|
|
170
|
+
fetchWithRetry(`${API2}/packages/${safePkg}/overall?mirrors=false`, "pypi")
|
|
167
171
|
]);
|
|
168
172
|
if (!recent && !overall) return null;
|
|
169
173
|
const total = overall?.data?.filter((d) => d.category === "without_mirrors")?.reduce((sum, d) => sum + d.downloads, 0);
|
|
170
174
|
return {
|
|
171
175
|
registry: "pypi",
|
|
172
|
-
package:
|
|
176
|
+
package: pkg2,
|
|
173
177
|
downloads: {
|
|
174
178
|
total: total || void 0,
|
|
175
179
|
lastDay: recent?.data.last_day,
|
|
@@ -179,9 +183,10 @@ var pypi = {
|
|
|
179
183
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
180
184
|
};
|
|
181
185
|
},
|
|
182
|
-
async getRange(
|
|
186
|
+
async getRange(pkg2, start, end) {
|
|
187
|
+
const safePkg = encodeURIComponent(pkg2);
|
|
183
188
|
const data = await fetchWithRetry(
|
|
184
|
-
`${API2}/packages/${
|
|
189
|
+
`${API2}/packages/${safePkg}/overall?mirrors=false`,
|
|
185
190
|
"pypi"
|
|
186
191
|
);
|
|
187
192
|
if (!data) return [];
|
|
@@ -199,12 +204,12 @@ var pypi = {
|
|
|
199
204
|
var SEARCH_API = "https://azuresearch-usnc.nuget.org/query";
|
|
200
205
|
var nuget = {
|
|
201
206
|
name: "nuget",
|
|
202
|
-
async getStats(
|
|
203
|
-
const url = `${SEARCH_API}?q=packageid:${encodeURIComponent(
|
|
207
|
+
async getStats(pkg2) {
|
|
208
|
+
const url = `${SEARCH_API}?q=packageid:${encodeURIComponent(pkg2)}&take=1`;
|
|
204
209
|
const json2 = await fetchWithRetry(url, "nuget");
|
|
205
210
|
if (!json2) return null;
|
|
206
211
|
const match = json2.data.find(
|
|
207
|
-
(d) => d.id.toLowerCase() ===
|
|
212
|
+
(d) => d.id.toLowerCase() === pkg2.toLowerCase()
|
|
208
213
|
);
|
|
209
214
|
if (!match) return null;
|
|
210
215
|
return {
|
|
@@ -228,7 +233,7 @@ function getStat(stats2, name) {
|
|
|
228
233
|
}
|
|
229
234
|
var vscode = {
|
|
230
235
|
name: "vscode",
|
|
231
|
-
async getStats(
|
|
236
|
+
async getStats(pkg2) {
|
|
232
237
|
let json2;
|
|
233
238
|
try {
|
|
234
239
|
json2 = await fetchWithRetry(API3, "vscode", {
|
|
@@ -240,7 +245,7 @@ var vscode = {
|
|
|
240
245
|
body: JSON.stringify({
|
|
241
246
|
filters: [
|
|
242
247
|
{
|
|
243
|
-
criteria: [{ filterType: 7, value:
|
|
248
|
+
criteria: [{ filterType: 7, value: pkg2 }]
|
|
244
249
|
}
|
|
245
250
|
],
|
|
246
251
|
flags: 256
|
|
@@ -279,12 +284,12 @@ var API4 = "https://hub.docker.com/v2/repositories";
|
|
|
279
284
|
var docker = {
|
|
280
285
|
name: "docker",
|
|
281
286
|
rateLimit: { maxRequests: 10, windowSeconds: 3600, authRaisesLimit: true },
|
|
282
|
-
async getStats(
|
|
287
|
+
async getStats(pkg2, options) {
|
|
283
288
|
const headers = {};
|
|
284
289
|
if (options?.dockerToken) {
|
|
285
290
|
headers["Authorization"] = `Bearer ${options.dockerToken}`;
|
|
286
291
|
}
|
|
287
|
-
const safePkg =
|
|
292
|
+
const safePkg = pkg2.split("/").map((s) => encodeURIComponent(s)).join("/");
|
|
288
293
|
const json2 = await fetchWithRetry(`${API4}/${safePkg}`, "docker", { headers });
|
|
289
294
|
if (!json2 || !json2.name || !json2.namespace) return null;
|
|
290
295
|
return {
|
|
@@ -304,13 +309,16 @@ var docker = {
|
|
|
304
309
|
|
|
305
310
|
// src/calc.ts
|
|
306
311
|
var calc = {
|
|
312
|
+
/** Sum all downloads in the given records. */
|
|
307
313
|
total(records) {
|
|
308
314
|
return records.reduce((sum, r) => sum + r.downloads, 0);
|
|
309
315
|
},
|
|
316
|
+
/** Compute average daily downloads. Returns 0 for empty input. */
|
|
310
317
|
avg(records) {
|
|
311
318
|
if (records.length === 0) return 0;
|
|
312
319
|
return calc.total(records) / records.length;
|
|
313
320
|
},
|
|
321
|
+
/** Group records by a custom key function. */
|
|
314
322
|
group(records, fn) {
|
|
315
323
|
const groups = {};
|
|
316
324
|
for (const r of records) {
|
|
@@ -319,12 +327,15 @@ var calc = {
|
|
|
319
327
|
}
|
|
320
328
|
return groups;
|
|
321
329
|
},
|
|
330
|
+
/** Group records by month (YYYY-MM keys). */
|
|
322
331
|
monthly(records) {
|
|
323
332
|
return calc.group(records, (r) => r.date.slice(0, 7));
|
|
324
333
|
},
|
|
334
|
+
/** Group records by year (YYYY keys). */
|
|
325
335
|
yearly(records) {
|
|
326
336
|
return calc.group(records, (r) => r.date.slice(0, 4));
|
|
327
337
|
},
|
|
338
|
+
/** Sum downloads within each group. */
|
|
328
339
|
groupTotals(grouped) {
|
|
329
340
|
const result = {};
|
|
330
341
|
for (const [key, records] of Object.entries(grouped)) {
|
|
@@ -332,6 +343,7 @@ var calc = {
|
|
|
332
343
|
}
|
|
333
344
|
return result;
|
|
334
345
|
},
|
|
346
|
+
/** Average downloads within each group. */
|
|
335
347
|
groupAvgs(grouped) {
|
|
336
348
|
const result = {};
|
|
337
349
|
for (const [key, records] of Object.entries(grouped)) {
|
|
@@ -339,6 +351,7 @@ var calc = {
|
|
|
339
351
|
}
|
|
340
352
|
return result;
|
|
341
353
|
},
|
|
354
|
+
/** Detect trend direction (up/down/flat) by comparing recent vs previous window averages. */
|
|
342
355
|
trend(records, windowDays = 7) {
|
|
343
356
|
if (records.length < windowDays * 2) {
|
|
344
357
|
return { slope: 0, direction: "flat", changePercent: 0 };
|
|
@@ -354,6 +367,7 @@ var calc = {
|
|
|
354
367
|
const direction = slope > threshold ? "up" : slope < -threshold ? "down" : "flat";
|
|
355
368
|
return { slope: Math.round(slope * 100) / 100, direction, changePercent: Math.round(changePercent * 100) / 100 };
|
|
356
369
|
},
|
|
370
|
+
/** Compute a simple moving average over a sliding window. */
|
|
357
371
|
movingAvg(records, windowDays = 7) {
|
|
358
372
|
if (records.length < windowDays) return [];
|
|
359
373
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
@@ -370,6 +384,7 @@ var calc = {
|
|
|
370
384
|
}
|
|
371
385
|
return result;
|
|
372
386
|
},
|
|
387
|
+
/** Compute a 0-100 popularity score based on log-scaled recent daily downloads. */
|
|
373
388
|
popularity(records) {
|
|
374
389
|
if (records.length === 0) return 0;
|
|
375
390
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
@@ -379,6 +394,7 @@ var calc = {
|
|
|
379
394
|
const score = Math.max(0, Math.min(100, Math.log10(Math.max(1, avgDaily)) / 6 * 100));
|
|
380
395
|
return Math.round(score * 10) / 10;
|
|
381
396
|
},
|
|
397
|
+
/** Convert records to CSV string with date,downloads columns. */
|
|
382
398
|
toCSV(records) {
|
|
383
399
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
384
400
|
const lines = ["date,downloads"];
|
|
@@ -387,6 +403,7 @@ var calc = {
|
|
|
387
403
|
}
|
|
388
404
|
return lines.join("\n");
|
|
389
405
|
},
|
|
406
|
+
/** Convert records to a ChartData object suitable for chart libraries. */
|
|
390
407
|
toChartData(records, label = "downloads") {
|
|
391
408
|
const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
|
|
392
409
|
return {
|
|
@@ -406,7 +423,14 @@ function loadConfig(startDir) {
|
|
|
406
423
|
const configPath = resolve(dir, CONFIG_NAME);
|
|
407
424
|
if (existsSync(configPath)) {
|
|
408
425
|
const raw = readFileSync(configPath, "utf-8");
|
|
409
|
-
|
|
426
|
+
let parsed;
|
|
427
|
+
try {
|
|
428
|
+
parsed = JSON.parse(raw);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
431
|
+
throw new Error(`Invalid JSON in ${configPath}: ${message}`);
|
|
432
|
+
}
|
|
433
|
+
return validateConfig(parsed, configPath);
|
|
410
434
|
}
|
|
411
435
|
const parent = dirname(dir);
|
|
412
436
|
if (parent === dir) break;
|
|
@@ -414,6 +438,50 @@ function loadConfig(startDir) {
|
|
|
414
438
|
}
|
|
415
439
|
return null;
|
|
416
440
|
}
|
|
441
|
+
function validateConfig(raw, source) {
|
|
442
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
443
|
+
throw new Error(`Config in ${source} must be a JSON object, got ${Array.isArray(raw) ? "array" : typeof raw}`);
|
|
444
|
+
}
|
|
445
|
+
const obj = raw;
|
|
446
|
+
const config = {};
|
|
447
|
+
if (obj.registries !== void 0) {
|
|
448
|
+
if (!Array.isArray(obj.registries) || !obj.registries.every((r) => typeof r === "string")) {
|
|
449
|
+
throw new Error(`Config "registries" must be an array of strings in ${source}`);
|
|
450
|
+
}
|
|
451
|
+
config.registries = obj.registries;
|
|
452
|
+
}
|
|
453
|
+
if (obj.packages !== void 0) {
|
|
454
|
+
if (typeof obj.packages !== "object" || obj.packages === null || Array.isArray(obj.packages)) {
|
|
455
|
+
throw new Error(`Config "packages" must be an object in ${source}`);
|
|
456
|
+
}
|
|
457
|
+
config.packages = obj.packages;
|
|
458
|
+
}
|
|
459
|
+
if (obj.cache !== void 0) {
|
|
460
|
+
if (typeof obj.cache !== "boolean") {
|
|
461
|
+
throw new Error(`Config "cache" must be a boolean in ${source}`);
|
|
462
|
+
}
|
|
463
|
+
config.cache = obj.cache;
|
|
464
|
+
}
|
|
465
|
+
if (obj.cacheTtlMs !== void 0) {
|
|
466
|
+
if (typeof obj.cacheTtlMs !== "number" || obj.cacheTtlMs <= 0 || !Number.isFinite(obj.cacheTtlMs)) {
|
|
467
|
+
throw new Error(`Config "cacheTtlMs" must be a positive number in ${source}`);
|
|
468
|
+
}
|
|
469
|
+
config.cacheTtlMs = obj.cacheTtlMs;
|
|
470
|
+
}
|
|
471
|
+
if (obj.concurrency !== void 0) {
|
|
472
|
+
if (typeof obj.concurrency !== "number" || obj.concurrency < 1 || !Number.isInteger(obj.concurrency)) {
|
|
473
|
+
throw new Error(`Config "concurrency" must be a positive integer in ${source}`);
|
|
474
|
+
}
|
|
475
|
+
config.concurrency = obj.concurrency;
|
|
476
|
+
}
|
|
477
|
+
if (obj.dockerToken !== void 0) {
|
|
478
|
+
if (typeof obj.dockerToken !== "string") {
|
|
479
|
+
throw new Error(`Config "dockerToken" must be a string in ${source}`);
|
|
480
|
+
}
|
|
481
|
+
config.dockerToken = obj.dockerToken;
|
|
482
|
+
}
|
|
483
|
+
return config;
|
|
484
|
+
}
|
|
417
485
|
var STARTER = `{
|
|
418
486
|
"registries": ["npm", "pypi", "nuget", "vscode", "docker"],
|
|
419
487
|
"packages": {
|
|
@@ -433,6 +501,36 @@ function starterConfig() {
|
|
|
433
501
|
|
|
434
502
|
// src/server.ts
|
|
435
503
|
import { createServer as httpCreateServer } from "http";
|
|
504
|
+
function createRateLimiter(maxRequests, windowSeconds) {
|
|
505
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
506
|
+
const cleanup = setInterval(() => {
|
|
507
|
+
const now = Date.now();
|
|
508
|
+
for (const [ip, bucket] of buckets) {
|
|
509
|
+
if (now > bucket.resetAt) buckets.delete(ip);
|
|
510
|
+
}
|
|
511
|
+
}, 6e4);
|
|
512
|
+
cleanup.unref();
|
|
513
|
+
return {
|
|
514
|
+
/** Returns true if the request is allowed, false if rate-limited. */
|
|
515
|
+
allow(ip) {
|
|
516
|
+
const now = Date.now();
|
|
517
|
+
const bucket = buckets.get(ip);
|
|
518
|
+
if (!bucket || now > bucket.resetAt) {
|
|
519
|
+
buckets.set(ip, { count: 1, resetAt: now + windowSeconds * 1e3 });
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
bucket.count++;
|
|
523
|
+
return bucket.count <= maxRequests;
|
|
524
|
+
},
|
|
525
|
+
/** Exposed for testing — clear all buckets. */
|
|
526
|
+
reset() {
|
|
527
|
+
buckets.clear();
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
function sanitizeFilename(value) {
|
|
532
|
+
return value.replace(/[^a-zA-Z0-9._@/-]/g, "_").slice(0, 200);
|
|
533
|
+
}
|
|
436
534
|
function json(res, data, status = 200) {
|
|
437
535
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
438
536
|
res.end(JSON.stringify(data));
|
|
@@ -452,13 +550,43 @@ function parseUrl(url) {
|
|
|
452
550
|
}
|
|
453
551
|
return { path, query };
|
|
454
552
|
}
|
|
553
|
+
function withTimeout(promise, ms) {
|
|
554
|
+
return new Promise((resolve3, reject) => {
|
|
555
|
+
const timer = setTimeout(() => reject(new Error("__TIMEOUT__")), ms);
|
|
556
|
+
promise.then(
|
|
557
|
+
(v) => {
|
|
558
|
+
clearTimeout(timer);
|
|
559
|
+
resolve3(v);
|
|
560
|
+
},
|
|
561
|
+
(e) => {
|
|
562
|
+
clearTimeout(timer);
|
|
563
|
+
reject(e);
|
|
564
|
+
}
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
function getClientIp(req) {
|
|
569
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
570
|
+
if (typeof forwarded === "string") return forwarded.split(",")[0].trim();
|
|
571
|
+
return req.socket.remoteAddress ?? "0.0.0.0";
|
|
572
|
+
}
|
|
455
573
|
function createHandler(opts) {
|
|
456
574
|
const options = { ...opts };
|
|
457
575
|
if (!options.cache) {
|
|
458
576
|
options.cache = createCache();
|
|
459
577
|
}
|
|
578
|
+
const corsOrigin = opts?.corsOrigin ?? "*";
|
|
579
|
+
const timeoutMs = opts?.requestTimeoutMs ?? 3e4;
|
|
580
|
+
const limiter = createRateLimiter(
|
|
581
|
+
opts?.rateLimitMax ?? 60,
|
|
582
|
+
opts?.rateLimitWindowSeconds ?? 60
|
|
583
|
+
);
|
|
460
584
|
return async (req, res) => {
|
|
461
|
-
res.setHeader("
|
|
585
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
586
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
587
|
+
res.setHeader("X-XSS-Protection", "1; mode=block");
|
|
588
|
+
res.setHeader("Cache-Control", "no-store");
|
|
589
|
+
res.setHeader("Access-Control-Allow-Origin", corsOrigin);
|
|
462
590
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
463
591
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
464
592
|
if (req.method === "OPTIONS") {
|
|
@@ -466,6 +594,11 @@ function createHandler(opts) {
|
|
|
466
594
|
res.end();
|
|
467
595
|
return;
|
|
468
596
|
}
|
|
597
|
+
const clientIp = getClientIp(req);
|
|
598
|
+
if (!limiter.allow(clientIp)) {
|
|
599
|
+
error(res, "Too many requests", 429);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
469
602
|
if (req.method !== "GET") {
|
|
470
603
|
error(res, "Method not allowed", 405);
|
|
471
604
|
return;
|
|
@@ -474,17 +607,17 @@ function createHandler(opts) {
|
|
|
474
607
|
try {
|
|
475
608
|
if (path[0] === "stats") {
|
|
476
609
|
if (path.length === 2) {
|
|
477
|
-
const
|
|
478
|
-
const results = await stats.all(
|
|
610
|
+
const pkg2 = decodeURIComponent(path[1]);
|
|
611
|
+
const results = await withTimeout(stats.all(pkg2, options), timeoutMs);
|
|
479
612
|
json(res, results);
|
|
480
613
|
return;
|
|
481
614
|
}
|
|
482
615
|
if (path.length >= 3) {
|
|
483
616
|
const registry = path[1];
|
|
484
|
-
const
|
|
485
|
-
const result = await stats(registry,
|
|
617
|
+
const pkg2 = path.slice(2).join("/");
|
|
618
|
+
const result = await withTimeout(stats(registry, pkg2, options), timeoutMs);
|
|
486
619
|
if (!result) {
|
|
487
|
-
error(res, `Package "${
|
|
620
|
+
error(res, `Package "${pkg2}" not found on ${registry}`, 404);
|
|
488
621
|
return;
|
|
489
622
|
}
|
|
490
623
|
json(res, result);
|
|
@@ -492,31 +625,34 @@ function createHandler(opts) {
|
|
|
492
625
|
}
|
|
493
626
|
}
|
|
494
627
|
if (path[0] === "compare" && path.length >= 2) {
|
|
495
|
-
const
|
|
628
|
+
const pkg2 = decodeURIComponent(path[1]);
|
|
496
629
|
const registries = query.registries ? query.registries.split(",") : void 0;
|
|
497
|
-
const result = await stats.compare(
|
|
630
|
+
const result = await withTimeout(stats.compare(pkg2, registries, options), timeoutMs);
|
|
498
631
|
json(res, result);
|
|
499
632
|
return;
|
|
500
633
|
}
|
|
501
634
|
if (path[0] === "range" && path.length >= 3) {
|
|
502
635
|
const registry = path[1];
|
|
503
|
-
const
|
|
636
|
+
const pkg2 = path.slice(2).join("/");
|
|
504
637
|
const { start, end, format } = query;
|
|
505
638
|
if (!start || !end) {
|
|
506
639
|
error(res, "Missing start and end query parameters");
|
|
507
640
|
return;
|
|
508
641
|
}
|
|
509
|
-
const data = await stats.range(registry,
|
|
642
|
+
const data = await withTimeout(stats.range(registry, pkg2, start, end, options), timeoutMs);
|
|
510
643
|
if (format === "csv") {
|
|
644
|
+
const safePkg = sanitizeFilename(pkg2);
|
|
645
|
+
const safeStart = sanitizeFilename(start);
|
|
646
|
+
const safeEnd = sanitizeFilename(end);
|
|
511
647
|
res.writeHead(200, {
|
|
512
648
|
"Content-Type": "text/csv",
|
|
513
|
-
"Content-Disposition": `attachment; filename="${
|
|
649
|
+
"Content-Disposition": `attachment; filename="${safePkg}-${safeStart}-${safeEnd}.csv"`
|
|
514
650
|
});
|
|
515
651
|
res.end(calc.toCSV(data));
|
|
516
652
|
return;
|
|
517
653
|
}
|
|
518
654
|
if (format === "chart") {
|
|
519
|
-
json(res, calc.toChartData(data, `${
|
|
655
|
+
json(res, calc.toChartData(data, `${pkg2} (${registry})`));
|
|
520
656
|
return;
|
|
521
657
|
}
|
|
522
658
|
json(res, data);
|
|
@@ -536,11 +672,13 @@ function createHandler(opts) {
|
|
|
536
672
|
}
|
|
537
673
|
error(res, "Not found", 404);
|
|
538
674
|
} catch (e) {
|
|
539
|
-
if (e
|
|
675
|
+
if (e?.message === "__TIMEOUT__") {
|
|
676
|
+
error(res, "Gateway timeout", 504);
|
|
677
|
+
} else if (e instanceof RegistryError) {
|
|
540
678
|
const status = e.statusCode === 429 ? 429 : e.statusCode === 404 ? 404 : e.statusCode >= 500 ? 502 : 500;
|
|
541
679
|
error(res, e.message, status);
|
|
542
680
|
} else {
|
|
543
|
-
error(res,
|
|
681
|
+
error(res, "Internal server error", 500);
|
|
544
682
|
}
|
|
545
683
|
}
|
|
546
684
|
};
|
|
@@ -548,7 +686,11 @@ function createHandler(opts) {
|
|
|
548
686
|
function serve(opts) {
|
|
549
687
|
const port = opts?.port ?? 3e3;
|
|
550
688
|
const handler = createHandler({
|
|
551
|
-
cache: opts?.cache !== false ? createCache() : void 0
|
|
689
|
+
cache: opts?.cache !== false ? createCache() : void 0,
|
|
690
|
+
corsOrigin: opts?.corsOrigin,
|
|
691
|
+
rateLimitMax: opts?.rateLimitMax,
|
|
692
|
+
rateLimitWindowSeconds: opts?.rateLimitWindowSeconds,
|
|
693
|
+
requestTimeoutMs: opts?.requestTimeoutMs
|
|
552
694
|
});
|
|
553
695
|
const server = httpCreateServer(handler);
|
|
554
696
|
server.listen(port, () => {
|
|
@@ -564,6 +706,18 @@ Endpoints:`);
|
|
|
564
706
|
}
|
|
565
707
|
|
|
566
708
|
// src/index.ts
|
|
709
|
+
var VALID_PKG_NAME = /^[@a-zA-Z0-9][\w./@-]*$/;
|
|
710
|
+
function validatePackageName(pkg2, registry) {
|
|
711
|
+
if (!pkg2 || typeof pkg2 !== "string") {
|
|
712
|
+
throw new RegistryError(registry, 0, `Invalid package name: must be a non-empty string`);
|
|
713
|
+
}
|
|
714
|
+
if (pkg2.includes("..") || pkg2.includes("\\")) {
|
|
715
|
+
throw new RegistryError(registry, 0, `Invalid package name "${pkg2}": path traversal not allowed`);
|
|
716
|
+
}
|
|
717
|
+
if (!VALID_PKG_NAME.test(pkg2)) {
|
|
718
|
+
throw new RegistryError(registry, 0, `Invalid package name "${pkg2}": contains illegal characters`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
567
721
|
function createCache() {
|
|
568
722
|
const store = /* @__PURE__ */ new Map();
|
|
569
723
|
return {
|
|
@@ -608,25 +762,26 @@ var providers = {
|
|
|
608
762
|
docker
|
|
609
763
|
};
|
|
610
764
|
var DEFAULT_TTL = 3e5;
|
|
611
|
-
async function stats(registry,
|
|
765
|
+
async function stats(registry, pkg2, options) {
|
|
766
|
+
validatePackageName(pkg2, registry);
|
|
612
767
|
const provider = providers[registry];
|
|
613
768
|
if (!provider) {
|
|
614
769
|
throw new RegistryError(registry, 0, `Unknown registry "${registry}". Use registerProvider() to add custom registries.`);
|
|
615
770
|
}
|
|
616
771
|
const cache = options?.cache;
|
|
617
772
|
if (cache) {
|
|
618
|
-
const key = `stats:${registry}:${
|
|
773
|
+
const key = `stats:${registry}:${pkg2}`;
|
|
619
774
|
const cached = cache.get(key);
|
|
620
775
|
if (cached) return cached;
|
|
621
|
-
const result = await provider.getStats(
|
|
776
|
+
const result = await provider.getStats(pkg2, options);
|
|
622
777
|
if (result) cache.set(key, result, options?.cacheTtlMs ?? DEFAULT_TTL);
|
|
623
778
|
return result;
|
|
624
779
|
}
|
|
625
|
-
return provider.getStats(
|
|
780
|
+
return provider.getStats(pkg2, options);
|
|
626
781
|
}
|
|
627
|
-
stats.all = async function all(
|
|
782
|
+
stats.all = async function all(pkg2, options) {
|
|
628
783
|
const results = await Promise.allSettled(
|
|
629
|
-
Object.values(providers).map((p) => p.getStats(
|
|
784
|
+
Object.values(providers).map((p) => p.getStats(pkg2, options))
|
|
630
785
|
);
|
|
631
786
|
return results.filter(
|
|
632
787
|
(r) => r.status === "fulfilled" && r.value !== null
|
|
@@ -642,32 +797,32 @@ stats.bulk = async function bulk(registry, packages, options) {
|
|
|
642
797
|
}
|
|
643
798
|
const concurrency = options?.concurrency ?? 5;
|
|
644
799
|
const limit = pLimit(concurrency);
|
|
645
|
-
return Promise.all(packages.map((
|
|
800
|
+
return Promise.all(packages.map((pkg2) => limit(() => stats(registry, pkg2, options))));
|
|
646
801
|
};
|
|
647
802
|
async function npmBulkStats(packages, options) {
|
|
648
803
|
const scoped = [];
|
|
649
804
|
const unscoped = [];
|
|
650
|
-
for (const
|
|
651
|
-
if (
|
|
652
|
-
scoped.push(
|
|
805
|
+
for (const pkg2 of packages) {
|
|
806
|
+
if (pkg2.startsWith("@")) {
|
|
807
|
+
scoped.push(pkg2);
|
|
653
808
|
} else {
|
|
654
|
-
unscoped.push(
|
|
809
|
+
unscoped.push(pkg2);
|
|
655
810
|
}
|
|
656
811
|
}
|
|
657
812
|
const bulkMonth = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-month") : /* @__PURE__ */ new Map();
|
|
658
813
|
const bulkWeek = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-week") : /* @__PURE__ */ new Map();
|
|
659
814
|
const bulkDay = unscoped.length > 0 ? await npmBulkPoint(unscoped, "last-day") : /* @__PURE__ */ new Map();
|
|
660
815
|
const unscopedResults = /* @__PURE__ */ new Map();
|
|
661
|
-
for (const
|
|
662
|
-
const month = bulkMonth.get(
|
|
663
|
-
const week = bulkWeek.get(
|
|
664
|
-
const day = bulkDay.get(
|
|
816
|
+
for (const pkg2 of unscoped) {
|
|
817
|
+
const month = bulkMonth.get(pkg2);
|
|
818
|
+
const week = bulkWeek.get(pkg2);
|
|
819
|
+
const day = bulkDay.get(pkg2);
|
|
665
820
|
if (month === void 0 && week === void 0 && day === void 0) {
|
|
666
|
-
unscopedResults.set(
|
|
821
|
+
unscopedResults.set(pkg2, null);
|
|
667
822
|
} else {
|
|
668
|
-
unscopedResults.set(
|
|
823
|
+
unscopedResults.set(pkg2, {
|
|
669
824
|
registry: "npm",
|
|
670
|
-
package:
|
|
825
|
+
package: pkg2,
|
|
671
826
|
downloads: {
|
|
672
827
|
lastDay: day,
|
|
673
828
|
lastWeek: week,
|
|
@@ -679,13 +834,13 @@ async function npmBulkStats(packages, options) {
|
|
|
679
834
|
}
|
|
680
835
|
const limit = pLimit(1);
|
|
681
836
|
const scopedResults = await Promise.all(
|
|
682
|
-
scoped.map((
|
|
837
|
+
scoped.map((pkg2) => limit(() => stats("npm", pkg2, options)))
|
|
683
838
|
);
|
|
684
839
|
const scopedMap = /* @__PURE__ */ new Map();
|
|
685
|
-
scoped.forEach((
|
|
686
|
-
return packages.map((
|
|
840
|
+
scoped.forEach((pkg2, i) => scopedMap.set(pkg2, scopedResults[i]));
|
|
841
|
+
return packages.map((pkg2) => unscopedResults.get(pkg2) ?? scopedMap.get(pkg2) ?? null);
|
|
687
842
|
}
|
|
688
|
-
stats.range = async function range(registry,
|
|
843
|
+
stats.range = async function range(registry, pkg2, start, end, options) {
|
|
689
844
|
const provider = providers[registry];
|
|
690
845
|
if (!provider) {
|
|
691
846
|
throw new RegistryError(registry, 0, `Unknown registry "${registry}".`);
|
|
@@ -699,20 +854,20 @@ stats.range = async function range(registry, pkg, start, end, options) {
|
|
|
699
854
|
}
|
|
700
855
|
const cache = options?.cache;
|
|
701
856
|
if (cache) {
|
|
702
|
-
const key = `range:${registry}:${
|
|
857
|
+
const key = `range:${registry}:${pkg2}:${start}:${end}`;
|
|
703
858
|
const cached = cache.get(key);
|
|
704
859
|
if (cached) return cached;
|
|
705
|
-
const result = await provider.getRange(
|
|
860
|
+
const result = await provider.getRange(pkg2, start, end);
|
|
706
861
|
cache.set(key, result, options?.cacheTtlMs ?? DEFAULT_TTL);
|
|
707
862
|
return result;
|
|
708
863
|
}
|
|
709
|
-
return provider.getRange(
|
|
864
|
+
return provider.getRange(pkg2, start, end);
|
|
710
865
|
};
|
|
711
|
-
stats.compare = async function compare(
|
|
866
|
+
stats.compare = async function compare(pkg2, registries, options) {
|
|
712
867
|
const regs = registries ?? Object.keys(providers);
|
|
713
868
|
const results = await Promise.allSettled(
|
|
714
869
|
regs.map(async (reg) => {
|
|
715
|
-
const result = await stats(reg,
|
|
870
|
+
const result = await stats(reg, pkg2, options);
|
|
716
871
|
return result ? { reg, result } : null;
|
|
717
872
|
})
|
|
718
873
|
);
|
|
@@ -723,7 +878,7 @@ stats.compare = async function compare(pkg, registries, options) {
|
|
|
723
878
|
}
|
|
724
879
|
}
|
|
725
880
|
return {
|
|
726
|
-
package:
|
|
881
|
+
package: pkg2,
|
|
727
882
|
registries: registryMap,
|
|
728
883
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
729
884
|
};
|
|
@@ -755,6 +910,9 @@ stats.mine = async function mine(maintainer, options) {
|
|
|
755
910
|
};
|
|
756
911
|
|
|
757
912
|
// src/cli.ts
|
|
913
|
+
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
914
|
+
var pkg = JSON.parse(readFileSync2(join(__dirname, "..", "package.json"), "utf-8"));
|
|
915
|
+
var VERSION = pkg.version;
|
|
758
916
|
function usage() {
|
|
759
917
|
console.log(`
|
|
760
918
|
Usage: registry-stats [package] [options]
|
|
@@ -771,7 +929,9 @@ Options:
|
|
|
771
929
|
Only npm and pypi support this
|
|
772
930
|
--compare Compare package across registries side-by-side
|
|
773
931
|
--format Output format: table (default), json, csv, chart
|
|
932
|
+
--json Shorthand for --format json
|
|
774
933
|
--init Create a starter registry-stats.config.json
|
|
934
|
+
--version, -V Show version
|
|
775
935
|
--help, -h Show this help
|
|
776
936
|
|
|
777
937
|
Subcommands:
|
|
@@ -902,7 +1062,8 @@ async function runConfigPackages(config, format) {
|
|
|
902
1062
|
try {
|
|
903
1063
|
const result = await stats(registry, pkgId, opts);
|
|
904
1064
|
if (result) results.push(result);
|
|
905
|
-
} catch {
|
|
1065
|
+
} catch (e) {
|
|
1066
|
+
console.error(`Warning: failed to fetch ${registry} for ${displayName}: ${e.message}`);
|
|
906
1067
|
}
|
|
907
1068
|
});
|
|
908
1069
|
await Promise.all(fetches);
|
|
@@ -931,8 +1092,8 @@ async function runMine(maintainer, format, config) {
|
|
|
931
1092
|
process.stderr.write(` Discovering packages for ${maintainer}...`);
|
|
932
1093
|
const results = await stats.mine(maintainer, {
|
|
933
1094
|
...opts,
|
|
934
|
-
onProgress(done, total,
|
|
935
|
-
process.stderr.write(`\r Fetching stats... ${done}/${total} (${
|
|
1095
|
+
onProgress(done, total, pkg2) {
|
|
1096
|
+
process.stderr.write(`\r Fetching stats... ${done}/${total} (${pkg2})${"".padEnd(20)}`);
|
|
936
1097
|
}
|
|
937
1098
|
});
|
|
938
1099
|
process.stderr.write("\r" + " ".repeat(80) + "\r");
|
|
@@ -948,6 +1109,10 @@ async function runMine(maintainer, format, config) {
|
|
|
948
1109
|
}
|
|
949
1110
|
async function main() {
|
|
950
1111
|
const args = process.argv.slice(2);
|
|
1112
|
+
if (args.includes("--version") || args.includes("-V")) {
|
|
1113
|
+
console.log(`registry-stats ${VERSION}`);
|
|
1114
|
+
process.exit(0);
|
|
1115
|
+
}
|
|
951
1116
|
if (args.includes("--help") || args.includes("-h")) {
|
|
952
1117
|
usage();
|
|
953
1118
|
process.exit(0);
|
|
@@ -967,17 +1132,22 @@ async function main() {
|
|
|
967
1132
|
for (let i = 1; i < args.length; i++) {
|
|
968
1133
|
if (args[i] === "--port" && args[i + 1]) {
|
|
969
1134
|
port = parseInt(args[++i], 10);
|
|
1135
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
1136
|
+
console.error("Error: --port must be a number between 1 and 65535");
|
|
1137
|
+
process.exit(1);
|
|
1138
|
+
}
|
|
970
1139
|
}
|
|
971
1140
|
}
|
|
972
1141
|
serve({ port });
|
|
973
1142
|
return;
|
|
974
1143
|
}
|
|
975
|
-
let
|
|
1144
|
+
let pkg2;
|
|
976
1145
|
let registry;
|
|
977
1146
|
let range2;
|
|
978
1147
|
let format = "table";
|
|
979
1148
|
let compare2 = false;
|
|
980
1149
|
let mineUser;
|
|
1150
|
+
const unknownFlags = [];
|
|
981
1151
|
for (let i = 0; i < args.length; i++) {
|
|
982
1152
|
if ((args[i] === "--registry" || args[i] === "-r") && args[i + 1]) {
|
|
983
1153
|
registry = args[++i];
|
|
@@ -991,16 +1161,25 @@ async function main() {
|
|
|
991
1161
|
compare2 = true;
|
|
992
1162
|
} else if (args[i] === "--mine" && args[i + 1]) {
|
|
993
1163
|
mineUser = args[++i];
|
|
994
|
-
} else if (!args[i].startsWith("-") && !
|
|
995
|
-
|
|
1164
|
+
} else if (!args[i].startsWith("-") && !pkg2) {
|
|
1165
|
+
pkg2 = args[i];
|
|
1166
|
+
} else if (args[i].startsWith("-")) {
|
|
1167
|
+
unknownFlags.push(args[i]);
|
|
996
1168
|
}
|
|
997
1169
|
}
|
|
1170
|
+
if (unknownFlags.length > 0) {
|
|
1171
|
+
console.error(`Warning: unknown option(s): ${unknownFlags.join(", ")}`);
|
|
1172
|
+
}
|
|
998
1173
|
const config = loadConfig();
|
|
1174
|
+
if ((format === "csv" || format === "chart") && !range2) {
|
|
1175
|
+
console.error(`Warning: --format ${format} only produces meaningful output with --range. Falling back to table.`);
|
|
1176
|
+
format = "table";
|
|
1177
|
+
}
|
|
999
1178
|
if (mineUser) {
|
|
1000
1179
|
await runMine(mineUser, format, config);
|
|
1001
1180
|
return;
|
|
1002
1181
|
}
|
|
1003
|
-
if (!
|
|
1182
|
+
if (!pkg2) {
|
|
1004
1183
|
if (!config) {
|
|
1005
1184
|
usage();
|
|
1006
1185
|
process.exit(0);
|
|
@@ -1012,7 +1191,7 @@ async function main() {
|
|
|
1012
1191
|
try {
|
|
1013
1192
|
if (compare2) {
|
|
1014
1193
|
const registries = registry ? [registry] : void 0;
|
|
1015
|
-
const result = await stats.compare(
|
|
1194
|
+
const result = await stats.compare(pkg2, registries, opts);
|
|
1016
1195
|
if (format === "json") {
|
|
1017
1196
|
console.log(JSON.stringify(result, null, 2));
|
|
1018
1197
|
} else {
|
|
@@ -1027,18 +1206,18 @@ async function main() {
|
|
|
1027
1206
|
console.error("Error: --range must be start:end (e.g. 2025-01-01:2025-06-30)");
|
|
1028
1207
|
process.exit(1);
|
|
1029
1208
|
}
|
|
1030
|
-
const data = await stats.range(reg,
|
|
1209
|
+
const data = await stats.range(reg, pkg2, start, end, opts);
|
|
1031
1210
|
if (format === "json") {
|
|
1032
1211
|
console.log(JSON.stringify(data, null, 2));
|
|
1033
1212
|
} else if (format === "csv") {
|
|
1034
1213
|
console.log(calc.toCSV(data));
|
|
1035
1214
|
} else if (format === "chart") {
|
|
1036
|
-
console.log(JSON.stringify(calc.toChartData(data, `${
|
|
1215
|
+
console.log(JSON.stringify(calc.toChartData(data, `${pkg2} (${reg})`), null, 2));
|
|
1037
1216
|
} else {
|
|
1038
1217
|
const monthly = calc.groupTotals(calc.monthly(data));
|
|
1039
1218
|
const t = calc.trend(data);
|
|
1040
1219
|
console.log(`
|
|
1041
|
-
${
|
|
1220
|
+
${pkg2} (${reg}) \u2014 ${start} to ${end}
|
|
1042
1221
|
`);
|
|
1043
1222
|
for (const [month, total] of Object.entries(monthly)) {
|
|
1044
1223
|
console.log(` ${month} ${formatNumber(total)}`);
|
|
@@ -1047,9 +1226,9 @@ ${pkg} (${reg}) \u2014 ${start} to ${end}
|
|
|
1047
1226
|
Total: ${formatNumber(calc.total(data))} Avg/day: ${formatNumber(Math.round(calc.avg(data)))} Trend: ${t.direction} (${t.changePercent > 0 ? "+" : ""}${t.changePercent}%)`);
|
|
1048
1227
|
}
|
|
1049
1228
|
} else if (registry) {
|
|
1050
|
-
const result = await stats(registry,
|
|
1229
|
+
const result = await stats(registry, pkg2, opts);
|
|
1051
1230
|
if (!result) {
|
|
1052
|
-
console.error(`Package "${
|
|
1231
|
+
console.error(`Package "${pkg2}" not found on ${registry}`);
|
|
1053
1232
|
process.exit(1);
|
|
1054
1233
|
}
|
|
1055
1234
|
if (format === "json") {
|
|
@@ -1059,9 +1238,9 @@ ${pkg} (${reg}) \u2014 ${start} to ${end}
|
|
|
1059
1238
|
printStats(result);
|
|
1060
1239
|
}
|
|
1061
1240
|
} else {
|
|
1062
|
-
const results = await stats.all(
|
|
1241
|
+
const results = await stats.all(pkg2, opts);
|
|
1063
1242
|
if (results.length === 0) {
|
|
1064
|
-
console.error(`Package "${
|
|
1243
|
+
console.error(`Package "${pkg2}" not found on any registry`);
|
|
1065
1244
|
process.exit(1);
|
|
1066
1245
|
}
|
|
1067
1246
|
if (format === "json") {
|
|
@@ -1079,4 +1258,7 @@ ${pkg} (${reg}) \u2014 ${start} to ${end}
|
|
|
1079
1258
|
process.exit(1);
|
|
1080
1259
|
}
|
|
1081
1260
|
}
|
|
1082
|
-
main()
|
|
1261
|
+
main().catch((e) => {
|
|
1262
|
+
console.error(`Error: ${e.message}`);
|
|
1263
|
+
process.exit(1);
|
|
1264
|
+
});
|