cryptoserve 0.2.1 → 0.3.2
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/bin/cryptoserve.mjs +61 -1
- package/lib/census/aggregator.mjs +226 -0
- package/lib/census/collectors/cocoapods-downloads.mjs +72 -0
- package/lib/census/collectors/crates-downloads.mjs +71 -0
- package/lib/census/collectors/github-advisories.mjs +132 -0
- package/lib/census/collectors/go-downloads.mjs +132 -0
- package/lib/census/collectors/hex-downloads.mjs +69 -0
- package/lib/census/collectors/maven-downloads.mjs +92 -0
- package/lib/census/collectors/npm-downloads.mjs +132 -0
- package/lib/census/collectors/nuget-downloads.mjs +72 -0
- package/lib/census/collectors/nvd-cves.mjs +71 -0
- package/lib/census/collectors/packagist-downloads.mjs +65 -0
- package/lib/census/collectors/pub-downloads.mjs +65 -0
- package/lib/census/collectors/pypi-downloads.mjs +67 -0
- package/lib/census/collectors/rubygems-downloads.mjs +67 -0
- package/lib/census/index.mjs +173 -0
- package/lib/census/package-catalog.mjs +577 -0
- package/lib/census/report-html.mjs +540 -0
- package/lib/census/report-terminal.mjs +126 -0
- package/package.json +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect download counts from the pub.dev API.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint: GET https://pub.dev/api/packages/{name}/score
|
|
5
|
+
* - Returns downloadCount30Days (or estimate from likes/popularity)
|
|
6
|
+
* - No authentication required
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const PUB_API = 'https://pub.dev/api/packages';
|
|
10
|
+
const REQUEST_DELAY_MS = 300;
|
|
11
|
+
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fetch pub.dev download counts for a list of packages.
|
|
18
|
+
*
|
|
19
|
+
* @param {import('../package-catalog.mjs').CatalogEntry[]} packages
|
|
20
|
+
* @param {Object} [options]
|
|
21
|
+
* @param {Function} [options.fetchFn]
|
|
22
|
+
* @param {boolean} [options.verbose]
|
|
23
|
+
* @returns {Promise<{packages: Array<{name: string, downloads: number, tier: string}>, period: string, collectedAt: string}>}
|
|
24
|
+
*/
|
|
25
|
+
export async function collectPubDownloads(packages, options = {}) {
|
|
26
|
+
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
27
|
+
const verbose = options.verbose || false;
|
|
28
|
+
|
|
29
|
+
const results = [];
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < packages.length; i++) {
|
|
32
|
+
const pkg = packages[i];
|
|
33
|
+
|
|
34
|
+
if (verbose) {
|
|
35
|
+
process.stderr.write(` pub ${i + 1}/${packages.length}: ${pkg.name}\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetchFn(`${PUB_API}/${pkg.name}/score`);
|
|
40
|
+
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
if (verbose) process.stderr.write(` pub ${pkg.name}: ${res.status}\n`);
|
|
43
|
+
results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
|
|
44
|
+
} else {
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
// pub.dev score endpoint has downloadCount30Days
|
|
47
|
+
const downloads = data?.downloadCount30Days || 0;
|
|
48
|
+
results.push({ name: pkg.name, downloads: downloads, tier: pkg.tier });
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (verbose) process.stderr.write(` pub ${pkg.name} error: ${err.message}\n`);
|
|
52
|
+
results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (i < packages.length - 1) {
|
|
56
|
+
await sleep(REQUEST_DELAY_MS);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
packages: results.sort((a, b) => b.downloads - a.downloads),
|
|
62
|
+
period: 'last_month',
|
|
63
|
+
collectedAt: new Date().toISOString(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect download counts from PyPI Stats API.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint: GET https://pypistats.org/api/packages/{pkg}/recent
|
|
5
|
+
* - Individual requests only (no batch endpoint)
|
|
6
|
+
* - No authentication required
|
|
7
|
+
* - 500ms delay between requests to be polite
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PYPI_API = 'https://pypistats.org/api/packages';
|
|
11
|
+
const REQUEST_DELAY_MS = 500;
|
|
12
|
+
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fetch PyPI download counts for a list of packages.
|
|
19
|
+
*
|
|
20
|
+
* @param {import('../package-catalog.mjs').CatalogEntry[]} packages
|
|
21
|
+
* @param {Object} [options]
|
|
22
|
+
* @param {Function} [options.fetchFn] - Fetch implementation (defaults to globalThis.fetch)
|
|
23
|
+
* @param {boolean} [options.verbose] - Log progress
|
|
24
|
+
* @returns {Promise<{packages: Array<{name: string, downloads: number, tier: string}>, period: string, collectedAt: string}>}
|
|
25
|
+
*/
|
|
26
|
+
export async function collectPypiDownloads(packages, options = {}) {
|
|
27
|
+
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
28
|
+
const verbose = options.verbose || false;
|
|
29
|
+
|
|
30
|
+
const results = [];
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < packages.length; i++) {
|
|
33
|
+
const pkg = packages[i];
|
|
34
|
+
const url = `${PYPI_API}/${pkg.name}/recent`;
|
|
35
|
+
|
|
36
|
+
if (verbose) {
|
|
37
|
+
process.stderr.write(` pypi ${i + 1}/${packages.length}: ${pkg.name}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetchFn(url);
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
if (verbose) process.stderr.write(` pypi ${pkg.name}: ${res.status}\n`);
|
|
44
|
+
results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
|
|
45
|
+
} else {
|
|
46
|
+
const data = await res.json();
|
|
47
|
+
// Response: { data: { last_month: N, last_week: N, last_day: N }, ... }
|
|
48
|
+
const downloads = data?.data?.last_month || 0;
|
|
49
|
+
results.push({ name: pkg.name, downloads, tier: pkg.tier });
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (verbose) process.stderr.write(` pypi ${pkg.name} error: ${err.message}\n`);
|
|
53
|
+
results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Delay between requests
|
|
57
|
+
if (i < packages.length - 1) {
|
|
58
|
+
await sleep(REQUEST_DELAY_MS);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
packages: results.sort((a, b) => b.downloads - a.downloads),
|
|
64
|
+
period: 'last_month',
|
|
65
|
+
collectedAt: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect download counts from the RubyGems API.
|
|
3
|
+
*
|
|
4
|
+
* Endpoint: GET https://rubygems.org/api/v1/gems/{name}.json
|
|
5
|
+
* - Returns total downloads (no monthly breakdown)
|
|
6
|
+
* - Estimate monthly = total / 120 (approx 10 years of data)
|
|
7
|
+
* - No authentication required
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const RUBYGEMS_API = 'https://rubygems.org/api/v1/gems';
|
|
11
|
+
const REQUEST_DELAY_MS = 300;
|
|
12
|
+
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fetch RubyGems download counts for a list of packages.
|
|
19
|
+
*
|
|
20
|
+
* @param {import('../package-catalog.mjs').CatalogEntry[]} packages
|
|
21
|
+
* @param {Object} [options]
|
|
22
|
+
* @param {Function} [options.fetchFn]
|
|
23
|
+
* @param {boolean} [options.verbose]
|
|
24
|
+
* @returns {Promise<{packages: Array<{name: string, downloads: number, tier: string}>, period: string, collectedAt: string}>}
|
|
25
|
+
*/
|
|
26
|
+
export async function collectRubygemsDownloads(packages, options = {}) {
|
|
27
|
+
const fetchFn = options.fetchFn || globalThis.fetch;
|
|
28
|
+
const verbose = options.verbose || false;
|
|
29
|
+
|
|
30
|
+
const results = [];
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < packages.length; i++) {
|
|
33
|
+
const pkg = packages[i];
|
|
34
|
+
|
|
35
|
+
if (verbose) {
|
|
36
|
+
process.stderr.write(` rubygems ${i + 1}/${packages.length}: ${pkg.name}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetchFn(`${RUBYGEMS_API}/${pkg.name}.json`);
|
|
41
|
+
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
if (verbose) process.stderr.write(` rubygems ${pkg.name}: ${res.status}\n`);
|
|
44
|
+
results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
|
|
45
|
+
} else {
|
|
46
|
+
const data = await res.json();
|
|
47
|
+
// RubyGems only provides total downloads; estimate monthly
|
|
48
|
+
const totalDownloads = data?.downloads || 0;
|
|
49
|
+
const monthlyEstimate = Math.round(totalDownloads / 120);
|
|
50
|
+
results.push({ name: pkg.name, downloads: monthlyEstimate, tier: pkg.tier });
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (verbose) process.stderr.write(` rubygems ${pkg.name} error: ${err.message}\n`);
|
|
54
|
+
results.push({ name: pkg.name, downloads: 0, tier: pkg.tier });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (i < packages.length - 1) {
|
|
58
|
+
await sleep(REQUEST_DELAY_MS);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
packages: results.sort((a, b) => b.downloads - a.downloads),
|
|
64
|
+
period: 'estimated_monthly',
|
|
65
|
+
collectedAt: new Date().toISOString(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Census orchestrator: run collectors, aggregate, cache results.
|
|
3
|
+
*
|
|
4
|
+
* Supports 11 ecosystems: npm, PyPI, Go, Maven, crates.io, Packagist, NuGet,
|
|
5
|
+
* RubyGems, Hex (Elixir), pub.dev (Dart), and CocoaPods (Swift/ObjC).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
NPM_PACKAGES, PYPI_PACKAGES, GO_PACKAGES,
|
|
14
|
+
MAVEN_PACKAGES, CRATES_PACKAGES, PACKAGIST_PACKAGES, NUGET_PACKAGES,
|
|
15
|
+
RUBYGEMS_PACKAGES, HEX_PACKAGES, PUB_PACKAGES, COCOAPODS_PACKAGES,
|
|
16
|
+
} from './package-catalog.mjs';
|
|
17
|
+
import { collectNpmDownloads } from './collectors/npm-downloads.mjs';
|
|
18
|
+
import { collectPypiDownloads } from './collectors/pypi-downloads.mjs';
|
|
19
|
+
import { collectGoDownloads } from './collectors/go-downloads.mjs';
|
|
20
|
+
import { collectMavenDownloads } from './collectors/maven-downloads.mjs';
|
|
21
|
+
import { collectCratesDownloads } from './collectors/crates-downloads.mjs';
|
|
22
|
+
import { collectPackagistDownloads } from './collectors/packagist-downloads.mjs';
|
|
23
|
+
import { collectNugetDownloads } from './collectors/nuget-downloads.mjs';
|
|
24
|
+
import { collectRubygemsDownloads } from './collectors/rubygems-downloads.mjs';
|
|
25
|
+
import { collectHexDownloads } from './collectors/hex-downloads.mjs';
|
|
26
|
+
import { collectPubDownloads } from './collectors/pub-downloads.mjs';
|
|
27
|
+
import { collectCocoapodsDownloads } from './collectors/cocoapods-downloads.mjs';
|
|
28
|
+
import { collectNvdCves } from './collectors/nvd-cves.mjs';
|
|
29
|
+
import { collectGithubAdvisories } from './collectors/github-advisories.mjs';
|
|
30
|
+
import { aggregate } from './aggregator.mjs';
|
|
31
|
+
|
|
32
|
+
const CACHE_DIR = join(homedir(), '.cryptoserve');
|
|
33
|
+
const CACHE_FILE = join(CACHE_DIR, 'census-cache.json');
|
|
34
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load cached data if valid.
|
|
38
|
+
* @returns {Object|null}
|
|
39
|
+
*/
|
|
40
|
+
function loadCache() {
|
|
41
|
+
try {
|
|
42
|
+
if (!existsSync(CACHE_FILE)) return null;
|
|
43
|
+
const raw = readFileSync(CACHE_FILE, 'utf-8');
|
|
44
|
+
const cached = JSON.parse(raw);
|
|
45
|
+
const age = Date.now() - new Date(cached.collectedAt).getTime();
|
|
46
|
+
if (age < CACHE_TTL_MS) return cached;
|
|
47
|
+
return null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Save data to cache.
|
|
55
|
+
*/
|
|
56
|
+
function saveCache(data) {
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
59
|
+
writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
|
|
60
|
+
} catch {
|
|
61
|
+
// Cache write failure is non-fatal
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Run the full census: collect data from all sources and aggregate.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} [options]
|
|
69
|
+
* @param {boolean} [options.verbose] - Log progress to stderr
|
|
70
|
+
* @param {boolean} [options.noCache] - Skip cache
|
|
71
|
+
* @param {string[]} [options.sources] - Which sources to query (default: all)
|
|
72
|
+
* @param {Function} [options.fetchFn] - Injected fetch for testing
|
|
73
|
+
* @returns {Promise<Object>} Aggregated census data
|
|
74
|
+
*/
|
|
75
|
+
export async function runCensus(options = {}) {
|
|
76
|
+
const { verbose = false, noCache = false, sources, fetchFn } = options;
|
|
77
|
+
|
|
78
|
+
// Check cache first
|
|
79
|
+
if (!noCache) {
|
|
80
|
+
const cached = loadCache();
|
|
81
|
+
if (cached) {
|
|
82
|
+
if (verbose) process.stderr.write('Using cached census data (< 1 hour old)\n');
|
|
83
|
+
return cached;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const enabledSources = sources || [
|
|
88
|
+
'npm', 'pypi', 'go', 'maven', 'crates', 'packagist', 'nuget',
|
|
89
|
+
'rubygems', 'hex', 'pub', 'cocoapods',
|
|
90
|
+
'nvd', 'github',
|
|
91
|
+
];
|
|
92
|
+
const collectorOpts = { verbose, fetchFn };
|
|
93
|
+
const empty = { packages: [], period: 'last_month', collectedAt: new Date().toISOString() };
|
|
94
|
+
|
|
95
|
+
if (verbose) process.stderr.write('Collecting census data across 11 ecosystems...\n');
|
|
96
|
+
|
|
97
|
+
// Phase 1: Package downloads (all 11 ecosystems in parallel)
|
|
98
|
+
const downloadPromises = [
|
|
99
|
+
enabledSources.includes('npm')
|
|
100
|
+
? (verbose && process.stderr.write('\nFetching npm download counts...\n'), collectNpmDownloads(NPM_PACKAGES, collectorOpts))
|
|
101
|
+
: Promise.resolve(empty),
|
|
102
|
+
enabledSources.includes('pypi')
|
|
103
|
+
? (verbose && process.stderr.write('\nFetching PyPI download counts...\n'), collectPypiDownloads(PYPI_PACKAGES, collectorOpts))
|
|
104
|
+
: Promise.resolve(empty),
|
|
105
|
+
enabledSources.includes('go')
|
|
106
|
+
? (verbose && process.stderr.write('\nFetching Go module stats...\n'), collectGoDownloads(GO_PACKAGES, collectorOpts))
|
|
107
|
+
: Promise.resolve(empty),
|
|
108
|
+
enabledSources.includes('maven')
|
|
109
|
+
? (verbose && process.stderr.write('\nFetching Maven Central stats...\n'), collectMavenDownloads(MAVEN_PACKAGES, collectorOpts))
|
|
110
|
+
: Promise.resolve(empty),
|
|
111
|
+
enabledSources.includes('crates')
|
|
112
|
+
? (verbose && process.stderr.write('\nFetching crates.io download counts...\n'), collectCratesDownloads(CRATES_PACKAGES, collectorOpts))
|
|
113
|
+
: Promise.resolve(empty),
|
|
114
|
+
enabledSources.includes('packagist')
|
|
115
|
+
? (verbose && process.stderr.write('\nFetching Packagist download counts...\n'), collectPackagistDownloads(PACKAGIST_PACKAGES, collectorOpts))
|
|
116
|
+
: Promise.resolve(empty),
|
|
117
|
+
enabledSources.includes('nuget')
|
|
118
|
+
? (verbose && process.stderr.write('\nFetching NuGet download counts...\n'), collectNugetDownloads(NUGET_PACKAGES, collectorOpts))
|
|
119
|
+
: Promise.resolve(empty),
|
|
120
|
+
enabledSources.includes('rubygems')
|
|
121
|
+
? (verbose && process.stderr.write('\nFetching RubyGems download counts...\n'), collectRubygemsDownloads(RUBYGEMS_PACKAGES, collectorOpts))
|
|
122
|
+
: Promise.resolve(empty),
|
|
123
|
+
enabledSources.includes('hex')
|
|
124
|
+
? (verbose && process.stderr.write('\nFetching Hex.pm download counts...\n'), collectHexDownloads(HEX_PACKAGES, collectorOpts))
|
|
125
|
+
: Promise.resolve(empty),
|
|
126
|
+
enabledSources.includes('pub')
|
|
127
|
+
? (verbose && process.stderr.write('\nFetching pub.dev download counts...\n'), collectPubDownloads(PUB_PACKAGES, collectorOpts))
|
|
128
|
+
: Promise.resolve(empty),
|
|
129
|
+
enabledSources.includes('cocoapods')
|
|
130
|
+
? (verbose && process.stderr.write('\nFetching CocoaPods pod counts...\n'), collectCocoapodsDownloads(COCOAPODS_PACKAGES, collectorOpts))
|
|
131
|
+
: Promise.resolve(empty),
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const [npmData, pypiData, goData, mavenData, cratesData, packagistData, nugetData,
|
|
135
|
+
rubygemsData, hexData, pubData, cocoapodsData] =
|
|
136
|
+
await Promise.all(downloadPromises);
|
|
137
|
+
|
|
138
|
+
// Phase 2: Vulnerability data (NVD + GitHub in parallel)
|
|
139
|
+
const vulnPromises = [
|
|
140
|
+
enabledSources.includes('nvd')
|
|
141
|
+
? (verbose && process.stderr.write('\nFetching NVD CVE data...\n'), collectNvdCves(collectorOpts))
|
|
142
|
+
: Promise.resolve({ cves: [], collectedAt: new Date().toISOString() }),
|
|
143
|
+
enabledSources.includes('github')
|
|
144
|
+
? (verbose && process.stderr.write('\nFetching GitHub advisories...\n'), collectGithubAdvisories(collectorOpts))
|
|
145
|
+
: Promise.resolve({ advisories: [], collectedAt: new Date().toISOString() }),
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
const [nvdData, githubData] = await Promise.all(vulnPromises);
|
|
149
|
+
|
|
150
|
+
// Aggregate
|
|
151
|
+
const result = aggregate({
|
|
152
|
+
npm: npmData,
|
|
153
|
+
pypi: pypiData,
|
|
154
|
+
go: goData,
|
|
155
|
+
maven: mavenData,
|
|
156
|
+
crates: cratesData,
|
|
157
|
+
packagist: packagistData,
|
|
158
|
+
nuget: nugetData,
|
|
159
|
+
rubygems: rubygemsData,
|
|
160
|
+
hex: hexData,
|
|
161
|
+
pub: pubData,
|
|
162
|
+
cocoapods: cocoapodsData,
|
|
163
|
+
nvd: nvdData,
|
|
164
|
+
github: githubData,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Cache the result
|
|
168
|
+
if (!noCache) {
|
|
169
|
+
saveCache(result);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
}
|