@mcptoolshop/registry-stats 0.3.0

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/dist/cli.js ADDED
@@ -0,0 +1,880 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { writeFileSync, existsSync as existsSync2 } from "fs";
5
+ import { resolve as resolve2 } from "path";
6
+
7
+ // src/types.ts
8
+ var RegistryError = class extends Error {
9
+ constructor(registry, statusCode, message, retryAfter) {
10
+ super(`[${registry}] ${message}`);
11
+ this.registry = registry;
12
+ this.statusCode = statusCode;
13
+ this.retryAfter = retryAfter;
14
+ this.name = "RegistryError";
15
+ }
16
+ };
17
+
18
+ // src/fetch.ts
19
+ var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
20
+ var MAX_RETRIES = 3;
21
+ var BASE_DELAY = 1e3;
22
+ async function fetchWithRetry(url, registry, init) {
23
+ let lastError;
24
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
25
+ const res = await fetch(url, init);
26
+ if (res.status === 404) return null;
27
+ if (res.ok) return res.json();
28
+ const retryAfter = res.headers.get("retry-after");
29
+ const retryAfterMs = retryAfter ? parseInt(retryAfter, 10) * 1e3 : void 0;
30
+ lastError = new RegistryError(
31
+ registry,
32
+ res.status,
33
+ `${res.statusText}: ${url}`,
34
+ retryAfter ? parseInt(retryAfter, 10) : void 0
35
+ );
36
+ if (!RETRYABLE.has(res.status) || attempt === MAX_RETRIES) break;
37
+ const delay = retryAfterMs ?? BASE_DELAY * Math.pow(2, attempt);
38
+ await new Promise((r) => setTimeout(r, delay));
39
+ }
40
+ throw lastError;
41
+ }
42
+
43
+ // src/providers/npm.ts
44
+ var API = "https://api.npmjs.org/downloads";
45
+ var npm = {
46
+ name: "npm",
47
+ async getStats(pkg) {
48
+ const [day, week, month] = await Promise.all([
49
+ fetchWithRetry(`${API}/point/last-day/${pkg}`, "npm"),
50
+ fetchWithRetry(`${API}/point/last-week/${pkg}`, "npm"),
51
+ fetchWithRetry(`${API}/point/last-month/${pkg}`, "npm")
52
+ ]);
53
+ if (!day && !week && !month) return null;
54
+ return {
55
+ registry: "npm",
56
+ package: pkg,
57
+ downloads: {
58
+ lastDay: day?.downloads,
59
+ lastWeek: week?.downloads,
60
+ lastMonth: month?.downloads
61
+ },
62
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
63
+ };
64
+ },
65
+ async getRange(pkg, start, end) {
66
+ const startDate = new Date(start);
67
+ const endDate = new Date(end);
68
+ const maxDays = 549;
69
+ const chunks = [];
70
+ let cursor = startDate;
71
+ while (cursor < endDate) {
72
+ const chunkEnd = new Date(cursor);
73
+ chunkEnd.setDate(chunkEnd.getDate() + maxDays - 1);
74
+ const actualEnd = chunkEnd > endDate ? endDate : chunkEnd;
75
+ const s = fmt(cursor);
76
+ const e = fmt(actualEnd);
77
+ const data = await fetchWithRetry(`${API}/range/${s}:${e}/${pkg}`, "npm");
78
+ if (data) {
79
+ for (const d of data.downloads) {
80
+ chunks.push({ date: d.day, downloads: d.downloads });
81
+ }
82
+ }
83
+ cursor = new Date(actualEnd);
84
+ cursor.setDate(cursor.getDate() + 1);
85
+ }
86
+ return chunks;
87
+ }
88
+ };
89
+ function fmt(d) {
90
+ return d.toISOString().slice(0, 10);
91
+ }
92
+
93
+ // src/providers/pypi.ts
94
+ var API2 = "https://pypistats.org/api";
95
+ var pypi = {
96
+ name: "pypi",
97
+ rateLimit: { maxRequests: 30, windowSeconds: 60, authRaisesLimit: false },
98
+ async getStats(pkg) {
99
+ const [recent, overall] = await Promise.all([
100
+ fetchWithRetry(`${API2}/packages/${pkg}/recent`, "pypi"),
101
+ fetchWithRetry(`${API2}/packages/${pkg}/overall?mirrors=false`, "pypi")
102
+ ]);
103
+ if (!recent && !overall) return null;
104
+ const total = overall?.data?.filter((d) => d.category === "without_mirrors")?.reduce((sum, d) => sum + d.downloads, 0);
105
+ return {
106
+ registry: "pypi",
107
+ package: pkg,
108
+ downloads: {
109
+ total: total || void 0,
110
+ lastDay: recent?.data.last_day,
111
+ lastWeek: recent?.data.last_week,
112
+ lastMonth: recent?.data.last_month
113
+ },
114
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
115
+ };
116
+ },
117
+ async getRange(pkg, start, end) {
118
+ const data = await fetchWithRetry(
119
+ `${API2}/packages/${pkg}/overall?mirrors=false`,
120
+ "pypi"
121
+ );
122
+ if (!data) return [];
123
+ const startDate = new Date(start);
124
+ const endDate = new Date(end);
125
+ return data.data.filter((d) => {
126
+ if (!d.date || d.category !== "without_mirrors") return false;
127
+ const date = new Date(d.date);
128
+ return date >= startDate && date <= endDate;
129
+ }).map((d) => ({ date: d.date, downloads: d.downloads })).sort((a, b) => a.date.localeCompare(b.date));
130
+ }
131
+ };
132
+
133
+ // src/providers/nuget.ts
134
+ var SEARCH_API = "https://azuresearch-usnc.nuget.org/query";
135
+ var nuget = {
136
+ name: "nuget",
137
+ async getStats(pkg) {
138
+ const url = `${SEARCH_API}?q=packageid:${encodeURIComponent(pkg)}&take=1`;
139
+ const json2 = await fetchWithRetry(url, "nuget");
140
+ if (!json2) return null;
141
+ const match = json2.data.find(
142
+ (d) => d.id.toLowerCase() === pkg.toLowerCase()
143
+ );
144
+ if (!match) return null;
145
+ return {
146
+ registry: "nuget",
147
+ package: match.id,
148
+ downloads: {
149
+ total: match.totalDownloads
150
+ },
151
+ extra: {
152
+ version: match.version
153
+ },
154
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
155
+ };
156
+ }
157
+ };
158
+
159
+ // src/providers/vscode.ts
160
+ var API3 = "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery";
161
+ function getStat(stats2, name) {
162
+ return stats2.find((s) => s.statisticName === name)?.value;
163
+ }
164
+ var vscode = {
165
+ name: "vscode",
166
+ async getStats(pkg) {
167
+ let json2;
168
+ try {
169
+ json2 = await fetchWithRetry(API3, "vscode", {
170
+ method: "POST",
171
+ headers: {
172
+ "Content-Type": "application/json",
173
+ Accept: "application/json;api-version=3.0-preview.1"
174
+ },
175
+ body: JSON.stringify({
176
+ filters: [
177
+ {
178
+ criteria: [{ filterType: 7, value: pkg }]
179
+ }
180
+ ],
181
+ flags: 256
182
+ // IncludeStatistics
183
+ })
184
+ });
185
+ } catch (e) {
186
+ if (e.statusCode === 400) return null;
187
+ throw e;
188
+ }
189
+ if (!json2) return null;
190
+ const ext = json2.results?.[0]?.extensions?.[0];
191
+ if (!ext) return null;
192
+ const stats2 = ext.statistics || [];
193
+ return {
194
+ registry: "vscode",
195
+ package: `${ext.publisher.publisherName}.${ext.extensionName}`,
196
+ downloads: {
197
+ total: getStat(stats2, "install")
198
+ },
199
+ extra: {
200
+ displayName: ext.displayName,
201
+ rating: getStat(stats2, "averagerating"),
202
+ ratingCount: getStat(stats2, "ratingcount"),
203
+ trendingDaily: getStat(stats2, "trendingdaily"),
204
+ trendingWeekly: getStat(stats2, "trendingweekly"),
205
+ trendingMonthly: getStat(stats2, "trendingmonthly")
206
+ },
207
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
208
+ };
209
+ }
210
+ };
211
+
212
+ // src/providers/docker.ts
213
+ var API4 = "https://hub.docker.com/v2/repositories";
214
+ var docker = {
215
+ name: "docker",
216
+ rateLimit: { maxRequests: 10, windowSeconds: 3600, authRaisesLimit: true },
217
+ async getStats(pkg, options) {
218
+ const headers = {};
219
+ if (options?.dockerToken) {
220
+ headers["Authorization"] = `Bearer ${options.dockerToken}`;
221
+ }
222
+ const json2 = await fetchWithRetry(`${API4}/${pkg}`, "docker", { headers });
223
+ if (!json2 || !json2.name || !json2.namespace) return null;
224
+ return {
225
+ registry: "docker",
226
+ package: `${json2.namespace}/${json2.name}`,
227
+ downloads: {
228
+ total: json2.pull_count
229
+ },
230
+ extra: {
231
+ stars: json2.star_count,
232
+ lastUpdated: json2.last_updated
233
+ },
234
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
235
+ };
236
+ }
237
+ };
238
+
239
+ // src/calc.ts
240
+ var calc = {
241
+ total(records) {
242
+ return records.reduce((sum, r) => sum + r.downloads, 0);
243
+ },
244
+ avg(records) {
245
+ if (records.length === 0) return 0;
246
+ return calc.total(records) / records.length;
247
+ },
248
+ group(records, fn) {
249
+ const groups = {};
250
+ for (const r of records) {
251
+ const key = fn(r);
252
+ (groups[key] ??= []).push(r);
253
+ }
254
+ return groups;
255
+ },
256
+ monthly(records) {
257
+ return calc.group(records, (r) => r.date.slice(0, 7));
258
+ },
259
+ yearly(records) {
260
+ return calc.group(records, (r) => r.date.slice(0, 4));
261
+ },
262
+ groupTotals(grouped) {
263
+ const result = {};
264
+ for (const [key, records] of Object.entries(grouped)) {
265
+ result[key] = calc.total(records);
266
+ }
267
+ return result;
268
+ },
269
+ groupAvgs(grouped) {
270
+ const result = {};
271
+ for (const [key, records] of Object.entries(grouped)) {
272
+ result[key] = calc.avg(records);
273
+ }
274
+ return result;
275
+ },
276
+ trend(records, windowDays = 7) {
277
+ if (records.length < windowDays * 2) {
278
+ return { slope: 0, direction: "flat", changePercent: 0 };
279
+ }
280
+ const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
281
+ const recent = sorted.slice(-windowDays);
282
+ const previous = sorted.slice(-windowDays * 2, -windowDays);
283
+ const recentAvg = calc.avg(recent);
284
+ const previousAvg = calc.avg(previous);
285
+ const slope = recentAvg - previousAvg;
286
+ const changePercent = previousAvg === 0 ? 0 : (recentAvg - previousAvg) / previousAvg * 100;
287
+ const threshold = previousAvg * 0.05;
288
+ const direction = slope > threshold ? "up" : slope < -threshold ? "down" : "flat";
289
+ return { slope: Math.round(slope * 100) / 100, direction, changePercent: Math.round(changePercent * 100) / 100 };
290
+ },
291
+ movingAvg(records, windowDays = 7) {
292
+ if (records.length < windowDays) return [];
293
+ const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
294
+ const result = [];
295
+ for (let i = windowDays - 1; i < sorted.length; i++) {
296
+ let sum = 0;
297
+ for (let j = i - windowDays + 1; j <= i; j++) {
298
+ sum += sorted[j].downloads;
299
+ }
300
+ result.push({
301
+ date: sorted[i].date,
302
+ downloads: Math.round(sum / windowDays * 100) / 100
303
+ });
304
+ }
305
+ return result;
306
+ },
307
+ popularity(records) {
308
+ if (records.length === 0) return 0;
309
+ const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
310
+ const recentDays = Math.min(30, sorted.length);
311
+ const recent = sorted.slice(-recentDays);
312
+ const avgDaily = calc.avg(recent);
313
+ const score = Math.max(0, Math.min(100, Math.log10(Math.max(1, avgDaily)) / 6 * 100));
314
+ return Math.round(score * 10) / 10;
315
+ },
316
+ toCSV(records) {
317
+ const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
318
+ const lines = ["date,downloads"];
319
+ for (const r of sorted) {
320
+ lines.push(`${r.date},${r.downloads}`);
321
+ }
322
+ return lines.join("\n");
323
+ },
324
+ toChartData(records, label = "downloads") {
325
+ const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date));
326
+ return {
327
+ labels: sorted.map((r) => r.date),
328
+ datasets: [{ label, data: sorted.map((r) => r.downloads) }]
329
+ };
330
+ }
331
+ };
332
+
333
+ // src/config.ts
334
+ import { readFileSync, existsSync } from "fs";
335
+ import { resolve, dirname } from "path";
336
+ var CONFIG_NAME = "registry-stats.config.json";
337
+ function loadConfig(startDir) {
338
+ let dir = startDir ?? process.cwd();
339
+ while (true) {
340
+ const configPath = resolve(dir, CONFIG_NAME);
341
+ if (existsSync(configPath)) {
342
+ const raw = readFileSync(configPath, "utf-8");
343
+ return JSON.parse(raw);
344
+ }
345
+ const parent = dirname(dir);
346
+ if (parent === dir) break;
347
+ dir = parent;
348
+ }
349
+ return null;
350
+ }
351
+ var STARTER = `{
352
+ "registries": ["npm", "pypi", "nuget", "vscode", "docker"],
353
+ "packages": {
354
+ "my-package": {
355
+ "npm": "my-package",
356
+ "pypi": "my-package"
357
+ }
358
+ },
359
+ "cache": true,
360
+ "cacheTtlMs": 300000,
361
+ "concurrency": 5
362
+ }
363
+ `;
364
+ function starterConfig() {
365
+ return STARTER;
366
+ }
367
+
368
+ // src/server.ts
369
+ import { createServer as httpCreateServer } from "http";
370
+ function json(res, data, status = 200) {
371
+ res.writeHead(status, { "Content-Type": "application/json" });
372
+ res.end(JSON.stringify(data));
373
+ }
374
+ function error(res, message, status = 400) {
375
+ json(res, { error: message }, status);
376
+ }
377
+ function parseUrl(url) {
378
+ const [pathname, search] = url.split("?");
379
+ const path = pathname.replace(/^\/api\//, "/").split("/").filter(Boolean);
380
+ const query = {};
381
+ if (search) {
382
+ for (const pair of search.split("&")) {
383
+ const [k, v] = pair.split("=");
384
+ if (k) query[decodeURIComponent(k)] = decodeURIComponent(v ?? "");
385
+ }
386
+ }
387
+ return { path, query };
388
+ }
389
+ function createHandler(opts) {
390
+ const options = { ...opts };
391
+ if (!options.cache) {
392
+ options.cache = createCache();
393
+ }
394
+ return async (req, res) => {
395
+ res.setHeader("Access-Control-Allow-Origin", "*");
396
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
397
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
398
+ if (req.method === "OPTIONS") {
399
+ res.writeHead(204);
400
+ res.end();
401
+ return;
402
+ }
403
+ if (req.method !== "GET") {
404
+ error(res, "Method not allowed", 405);
405
+ return;
406
+ }
407
+ const { path, query } = parseUrl(req.url ?? "/");
408
+ try {
409
+ if (path[0] === "stats") {
410
+ if (path.length === 2) {
411
+ const pkg = decodeURIComponent(path[1]);
412
+ const results = await stats.all(pkg, options);
413
+ json(res, results);
414
+ return;
415
+ }
416
+ if (path.length >= 3) {
417
+ const registry = path[1];
418
+ const pkg = path.slice(2).join("/");
419
+ const result = await stats(registry, pkg, options);
420
+ if (!result) {
421
+ error(res, `Package "${pkg}" not found on ${registry}`, 404);
422
+ return;
423
+ }
424
+ json(res, result);
425
+ return;
426
+ }
427
+ }
428
+ if (path[0] === "compare" && path.length >= 2) {
429
+ const pkg = decodeURIComponent(path[1]);
430
+ const registries = query.registries ? query.registries.split(",") : void 0;
431
+ const result = await stats.compare(pkg, registries, options);
432
+ json(res, result);
433
+ return;
434
+ }
435
+ if (path[0] === "range" && path.length >= 3) {
436
+ const registry = path[1];
437
+ const pkg = path.slice(2).join("/");
438
+ const { start, end, format } = query;
439
+ if (!start || !end) {
440
+ error(res, "Missing start and end query parameters");
441
+ return;
442
+ }
443
+ const data = await stats.range(registry, pkg, start, end, options);
444
+ if (format === "csv") {
445
+ res.writeHead(200, {
446
+ "Content-Type": "text/csv",
447
+ "Content-Disposition": `attachment; filename="${pkg}-${start}-${end}.csv"`
448
+ });
449
+ res.end(calc.toCSV(data));
450
+ return;
451
+ }
452
+ if (format === "chart") {
453
+ json(res, calc.toChartData(data, `${pkg} (${registry})`));
454
+ return;
455
+ }
456
+ json(res, data);
457
+ return;
458
+ }
459
+ if (path.length === 0) {
460
+ json(res, {
461
+ name: "@mcptoolshop/registry-stats",
462
+ endpoints: [
463
+ "GET /stats/:package",
464
+ "GET /stats/:registry/:package",
465
+ "GET /compare/:package?registries=npm,pypi",
466
+ "GET /range/:registry/:package?start=YYYY-MM-DD&end=YYYY-MM-DD&format=json|csv|chart"
467
+ ]
468
+ });
469
+ return;
470
+ }
471
+ error(res, "Not found", 404);
472
+ } catch (e) {
473
+ error(res, e.message, 500);
474
+ }
475
+ };
476
+ }
477
+ function serve(opts) {
478
+ const port = opts?.port ?? 3e3;
479
+ const handler = createHandler({
480
+ cache: opts?.cache !== false ? createCache() : void 0
481
+ });
482
+ const server = httpCreateServer(handler);
483
+ server.listen(port, () => {
484
+ console.log(`registry-stats server listening on http://localhost:${port}`);
485
+ console.log(`
486
+ Endpoints:`);
487
+ console.log(` GET /stats/:package`);
488
+ console.log(` GET /stats/:registry/:package`);
489
+ console.log(` GET /compare/:package?registries=npm,pypi`);
490
+ console.log(` GET /range/:registry/:package?start=...&end=...&format=json|csv|chart`);
491
+ });
492
+ return server;
493
+ }
494
+
495
+ // src/index.ts
496
+ function createCache() {
497
+ const store = /* @__PURE__ */ new Map();
498
+ return {
499
+ get(key) {
500
+ const entry = store.get(key);
501
+ if (!entry) return void 0;
502
+ if (Date.now() > entry.expiresAt) {
503
+ store.delete(key);
504
+ return void 0;
505
+ }
506
+ return entry.value;
507
+ },
508
+ set(key, value, ttlMs) {
509
+ store.set(key, { value, expiresAt: Date.now() + ttlMs });
510
+ }
511
+ };
512
+ }
513
+ function pLimit(concurrency) {
514
+ let active = 0;
515
+ const queue = [];
516
+ function next() {
517
+ if (queue.length > 0 && active < concurrency) {
518
+ active++;
519
+ queue.shift()();
520
+ }
521
+ }
522
+ return (fn) => new Promise((resolve3, reject) => {
523
+ queue.push(() => {
524
+ fn().then(resolve3, reject).finally(() => {
525
+ active--;
526
+ next();
527
+ });
528
+ });
529
+ next();
530
+ });
531
+ }
532
+ var providers = {
533
+ npm,
534
+ pypi,
535
+ nuget,
536
+ vscode,
537
+ docker
538
+ };
539
+ var DEFAULT_TTL = 3e5;
540
+ async function stats(registry, pkg, options) {
541
+ const provider = providers[registry];
542
+ if (!provider) {
543
+ throw new RegistryError(registry, 0, `Unknown registry "${registry}". Use registerProvider() to add custom registries.`);
544
+ }
545
+ const cache = options?.cache;
546
+ if (cache) {
547
+ const key = `stats:${registry}:${pkg}`;
548
+ const cached = cache.get(key);
549
+ if (cached) return cached;
550
+ const result = await provider.getStats(pkg, options);
551
+ if (result) cache.set(key, result, options?.cacheTtlMs ?? DEFAULT_TTL);
552
+ return result;
553
+ }
554
+ return provider.getStats(pkg, options);
555
+ }
556
+ stats.all = async function all(pkg, options) {
557
+ const results = await Promise.allSettled(
558
+ Object.values(providers).map((p) => p.getStats(pkg, options))
559
+ );
560
+ return results.filter(
561
+ (r) => r.status === "fulfilled" && r.value !== null
562
+ ).map((r) => r.value);
563
+ };
564
+ stats.bulk = async function bulk(registry, packages, options) {
565
+ const provider = providers[registry];
566
+ if (!provider) {
567
+ throw new RegistryError(registry, 0, `Unknown registry "${registry}".`);
568
+ }
569
+ const concurrency = options?.concurrency ?? 5;
570
+ const limit = pLimit(concurrency);
571
+ return Promise.all(packages.map((pkg) => limit(() => stats(registry, pkg, options))));
572
+ };
573
+ stats.range = async function range(registry, pkg, start, end, options) {
574
+ const provider = providers[registry];
575
+ if (!provider) {
576
+ throw new RegistryError(registry, 0, `Unknown registry "${registry}".`);
577
+ }
578
+ if (!provider.getRange) {
579
+ throw new RegistryError(
580
+ registry,
581
+ 0,
582
+ `${registry} does not support time-series data. Only npm and pypi support getRange().`
583
+ );
584
+ }
585
+ const cache = options?.cache;
586
+ if (cache) {
587
+ const key = `range:${registry}:${pkg}:${start}:${end}`;
588
+ const cached = cache.get(key);
589
+ if (cached) return cached;
590
+ const result = await provider.getRange(pkg, start, end);
591
+ cache.set(key, result, options?.cacheTtlMs ?? DEFAULT_TTL);
592
+ return result;
593
+ }
594
+ return provider.getRange(pkg, start, end);
595
+ };
596
+ stats.compare = async function compare(pkg, registries, options) {
597
+ const regs = registries ?? Object.keys(providers);
598
+ const results = await Promise.allSettled(
599
+ regs.map(async (reg) => {
600
+ const result = await stats(reg, pkg, options);
601
+ return result ? { reg, result } : null;
602
+ })
603
+ );
604
+ const registryMap = {};
605
+ for (const r of results) {
606
+ if (r.status === "fulfilled" && r.value) {
607
+ registryMap[r.value.reg] = r.value.result;
608
+ }
609
+ }
610
+ return {
611
+ package: pkg,
612
+ registries: registryMap,
613
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
614
+ };
615
+ };
616
+
617
+ // src/cli.ts
618
+ function usage() {
619
+ console.log(`
620
+ Usage: registry-stats [package] [options]
621
+ registry-stats serve [--port 3000]
622
+
623
+ If no package is given, reads from registry-stats.config.json.
624
+
625
+ Options:
626
+ --registry, -r Registry to query (npm, pypi, nuget, vscode, docker)
627
+ Omit to query all registries
628
+ --range Date range for time series (e.g. 2025-01-01:2025-06-30)
629
+ Only npm and pypi support this
630
+ --compare Compare package across registries side-by-side
631
+ --format Output format: table (default), json, csv, chart
632
+ --init Create a starter registry-stats.config.json
633
+ --help, -h Show this help
634
+
635
+ Subcommands:
636
+ serve Start a REST API server
637
+ --port Port to listen on (default: 3000)
638
+
639
+ Examples:
640
+ registry-stats express
641
+ registry-stats express -r npm
642
+ registry-stats express --compare
643
+ registry-stats express -r npm --range 2025-01-01:2025-06-30 --format csv
644
+ registry-stats serve --port 8080
645
+ registry-stats --init
646
+ registry-stats # fetches all packages from config
647
+ `);
648
+ }
649
+ function formatNumber(n) {
650
+ if (n === void 0) return "-";
651
+ return n.toLocaleString("en-US");
652
+ }
653
+ function printStats(s) {
654
+ const d = s.downloads;
655
+ const parts = [
656
+ ` ${s.registry.padEnd(7)} | ${s.package}`
657
+ ];
658
+ const metrics = [];
659
+ if (d.total !== void 0) metrics.push(`total: ${formatNumber(d.total)}`);
660
+ if (d.lastMonth !== void 0) metrics.push(`month: ${formatNumber(d.lastMonth)}`);
661
+ if (d.lastWeek !== void 0) metrics.push(`week: ${formatNumber(d.lastWeek)}`);
662
+ if (d.lastDay !== void 0) metrics.push(`day: ${formatNumber(d.lastDay)}`);
663
+ if (metrics.length > 0) {
664
+ parts.push(` ${metrics.join(" ")}`);
665
+ }
666
+ if (s.extra) {
667
+ const extras = [];
668
+ if (s.extra.stars !== void 0) extras.push(`stars: ${formatNumber(s.extra.stars)}`);
669
+ if (s.extra.rating !== void 0) extras.push(`rating: ${s.extra.rating.toFixed(1)}`);
670
+ if (s.extra.version !== void 0) extras.push(`v${s.extra.version}`);
671
+ if (extras.length > 0) {
672
+ parts.push(` ${extras.join(" ")}`);
673
+ }
674
+ }
675
+ console.log(parts.join("\n"));
676
+ }
677
+ function printComparison(result) {
678
+ const regs = Object.entries(result.registries);
679
+ if (regs.length === 0) {
680
+ console.error(`Package "${result.package}" not found on any registry`);
681
+ process.exit(1);
682
+ }
683
+ console.log(`
684
+ ${result.package} \u2014 comparison
685
+ `);
686
+ const cols = regs.map(([r]) => r.padEnd(12));
687
+ console.log(` ${"Metric".padEnd(14)}${cols.join("")}`);
688
+ console.log(` ${"\u2500".repeat(14 + cols.length * 12)}`);
689
+ const metrics = ["total", "lastMonth", "lastWeek", "lastDay"];
690
+ const labels = {
691
+ total: "Total",
692
+ lastMonth: "Month",
693
+ lastWeek: "Week",
694
+ lastDay: "Day"
695
+ };
696
+ for (const m of metrics) {
697
+ const values = regs.map(([, s]) => {
698
+ const v = s.downloads[m];
699
+ return (v !== void 0 ? formatNumber(v) : "-").padEnd(12);
700
+ });
701
+ console.log(` ${labels[m].padEnd(14)}${values.join("")}`);
702
+ }
703
+ console.log();
704
+ }
705
+ function buildOptions(config) {
706
+ const opts = {};
707
+ if (!config) return opts;
708
+ if (config.cache !== false) {
709
+ opts.cache = createCache();
710
+ opts.cacheTtlMs = config.cacheTtlMs;
711
+ }
712
+ if (config.concurrency) opts.concurrency = config.concurrency;
713
+ if (config.dockerToken) opts.dockerToken = config.dockerToken;
714
+ return opts;
715
+ }
716
+ async function runConfigPackages(config, format) {
717
+ const packages = config.packages;
718
+ if (!packages || Object.keys(packages).length === 0) {
719
+ console.error("No packages defined in config. Add packages to registry-stats.config.json.");
720
+ process.exit(1);
721
+ }
722
+ const opts = buildOptions(config);
723
+ const allResults = {};
724
+ for (const [displayName, registryMap] of Object.entries(packages)) {
725
+ const results = [];
726
+ const fetches = Object.entries(registryMap).map(async ([registry, pkgId]) => {
727
+ try {
728
+ const result = await stats(registry, pkgId, opts);
729
+ if (result) results.push(result);
730
+ } catch {
731
+ }
732
+ });
733
+ await Promise.all(fetches);
734
+ if (results.length > 0) allResults[displayName] = results;
735
+ }
736
+ if (format === "json") {
737
+ console.log(JSON.stringify(allResults, null, 2));
738
+ return;
739
+ }
740
+ if (Object.keys(allResults).length === 0) {
741
+ console.error("No results found for any configured packages.");
742
+ process.exit(1);
743
+ }
744
+ for (const [displayName, results] of Object.entries(allResults)) {
745
+ console.log(`
746
+ ${displayName}`);
747
+ console.log(` ${"\u2500".repeat(displayName.length)}`);
748
+ for (const r of results) {
749
+ printStats(r);
750
+ }
751
+ }
752
+ console.log();
753
+ }
754
+ async function main() {
755
+ const args = process.argv.slice(2);
756
+ if (args.includes("--help") || args.includes("-h")) {
757
+ usage();
758
+ process.exit(0);
759
+ }
760
+ if (args.includes("--init")) {
761
+ const configPath = resolve2(process.cwd(), "registry-stats.config.json");
762
+ if (existsSync2(configPath)) {
763
+ console.error("registry-stats.config.json already exists.");
764
+ process.exit(1);
765
+ }
766
+ writeFileSync(configPath, starterConfig(), "utf-8");
767
+ console.log("Created registry-stats.config.json");
768
+ process.exit(0);
769
+ }
770
+ if (args[0] === "serve") {
771
+ let port = 3e3;
772
+ for (let i = 1; i < args.length; i++) {
773
+ if (args[i] === "--port" && args[i + 1]) {
774
+ port = parseInt(args[++i], 10);
775
+ }
776
+ }
777
+ serve({ port });
778
+ return;
779
+ }
780
+ let pkg;
781
+ let registry;
782
+ let range2;
783
+ let format = "table";
784
+ let compare2 = false;
785
+ for (let i = 0; i < args.length; i++) {
786
+ if ((args[i] === "--registry" || args[i] === "-r") && args[i + 1]) {
787
+ registry = args[++i];
788
+ } else if (args[i] === "--range" && args[i + 1]) {
789
+ range2 = args[++i];
790
+ } else if (args[i] === "--format" && args[i + 1]) {
791
+ format = args[++i];
792
+ } else if (args[i] === "--json") {
793
+ format = "json";
794
+ } else if (args[i] === "--compare") {
795
+ compare2 = true;
796
+ } else if (!args[i].startsWith("-") && !pkg) {
797
+ pkg = args[i];
798
+ }
799
+ }
800
+ const config = loadConfig();
801
+ if (!pkg) {
802
+ if (!config) {
803
+ usage();
804
+ process.exit(0);
805
+ }
806
+ await runConfigPackages(config, format);
807
+ return;
808
+ }
809
+ const opts = buildOptions(config);
810
+ try {
811
+ if (compare2) {
812
+ const registries = registry ? [registry] : void 0;
813
+ const result = await stats.compare(pkg, registries, opts);
814
+ if (format === "json") {
815
+ console.log(JSON.stringify(result, null, 2));
816
+ } else {
817
+ printComparison(result);
818
+ }
819
+ return;
820
+ }
821
+ if (range2) {
822
+ const reg = registry ?? "npm";
823
+ const [start, end] = range2.split(":");
824
+ if (!start || !end) {
825
+ console.error("Error: --range must be start:end (e.g. 2025-01-01:2025-06-30)");
826
+ process.exit(1);
827
+ }
828
+ const data = await stats.range(reg, pkg, start, end, opts);
829
+ if (format === "json") {
830
+ console.log(JSON.stringify(data, null, 2));
831
+ } else if (format === "csv") {
832
+ console.log(calc.toCSV(data));
833
+ } else if (format === "chart") {
834
+ console.log(JSON.stringify(calc.toChartData(data, `${pkg} (${reg})`), null, 2));
835
+ } else {
836
+ const monthly = calc.groupTotals(calc.monthly(data));
837
+ const t = calc.trend(data);
838
+ console.log(`
839
+ ${pkg} (${reg}) \u2014 ${start} to ${end}
840
+ `);
841
+ for (const [month, total] of Object.entries(monthly)) {
842
+ console.log(` ${month} ${formatNumber(total)}`);
843
+ }
844
+ console.log(`
845
+ Total: ${formatNumber(calc.total(data))} Avg/day: ${formatNumber(Math.round(calc.avg(data)))} Trend: ${t.direction} (${t.changePercent > 0 ? "+" : ""}${t.changePercent}%)`);
846
+ }
847
+ } else if (registry) {
848
+ const result = await stats(registry, pkg, opts);
849
+ if (!result) {
850
+ console.error(`Package "${pkg}" not found on ${registry}`);
851
+ process.exit(1);
852
+ }
853
+ if (format === "json") {
854
+ console.log(JSON.stringify(result, null, 2));
855
+ } else {
856
+ console.log();
857
+ printStats(result);
858
+ }
859
+ } else {
860
+ const results = await stats.all(pkg, opts);
861
+ if (results.length === 0) {
862
+ console.error(`Package "${pkg}" not found on any registry`);
863
+ process.exit(1);
864
+ }
865
+ if (format === "json") {
866
+ console.log(JSON.stringify(results, null, 2));
867
+ } else {
868
+ console.log();
869
+ for (const r of results) {
870
+ printStats(r);
871
+ console.log();
872
+ }
873
+ }
874
+ }
875
+ } catch (e) {
876
+ console.error(`Error: ${e.message}`);
877
+ process.exit(1);
878
+ }
879
+ }
880
+ main();