@yob/depcollector 0.1.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/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # depcollector
2
+
3
+ A CLI tool that snapshots the state of your npm dependencies. It reads your
4
+ `package.json` and `package-lock.json`, queries the npm registry, and outputs
5
+ a JSON report containing each dependency's locked version, its release date,
6
+ and the latest available version with its release date. It also captures the
7
+ current git SHA and commit timestamp.
8
+
9
+ ## Why?
10
+
11
+ Keeping dependencies up to date is important, but it's hard to track *how*
12
+ out of date they are across projects over time. depcollector produces a
13
+ structured snapshot you can store (e.g. in S3) and trend over time to answer
14
+ questions like:
15
+
16
+ - How old are my locked dependency versions?
17
+ - How far behind latest is each dependency?
18
+ - Are things getting better or worse over time?
19
+
20
+ ## Requirements
21
+
22
+ - Node.js >= 20
23
+ - A `package.json` and `package-lock.json` in the target directory
24
+ - A git repository (for SHA and commit timestamp)
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ npm install -g depcollector
30
+ ```
31
+
32
+ Or run directly with npx:
33
+
34
+ ```bash
35
+ npx depcollector
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ Run in any directory containing a `package.json` and `package-lock.json`:
41
+
42
+ ```bash
43
+ depcollector
44
+ ```
45
+
46
+ This prints a JSON report to stdout:
47
+
48
+ ```json
49
+ {
50
+ "ecosystem": "npm",
51
+ "collectedAt": "2026-02-08T11:24:42.590Z",
52
+ "gitSha": "c75e008...",
53
+ "gitTimestamp": "2026-02-08T22:24:33+11:00",
54
+ "dependencies": [
55
+ {
56
+ "name": "typescript",
57
+ "direct": true,
58
+ "currentVersion": "5.9.3",
59
+ "currentVersionDate": "2025-09-30T21:19:38.784Z",
60
+ "latestVersion": "5.9.3",
61
+ "latestVersionDate": "2025-09-30T21:19:38.784Z"
62
+ }
63
+ ]
64
+ }
65
+ ```
66
+
67
+ ### `--at-commit`
68
+
69
+ By default, "latest version" means the latest version available right now. If
70
+ you want to know what the latest version was *at the time of the commit*
71
+ instead, use the `--at-commit` flag:
72
+
73
+ ```bash
74
+ depcollector --at-commit
75
+ ```
76
+
77
+ This caps the "latest version" to the most recent release that existed at the
78
+ git commit timestamp. This is useful for historical analysis -- for example,
79
+ running depcollector against older commits to understand how out of date
80
+ dependencies were at that point in time, without the answer being skewed by
81
+ versions released after the commit.
82
+
83
+ ### `--transitive`
84
+
85
+ By default, only direct dependencies (those listed in `dependencies` and
86
+ `devDependencies` in `package.json`) are included. Use `--transitive` to
87
+ include all transitive dependencies from the lockfile as well:
88
+
89
+ ```bash
90
+ depcollector --transitive
91
+ ```
92
+
93
+ Each dependency in the output includes a `"direct"` field indicating whether
94
+ it is a direct dependency (`true`) or a transitive one (`false`). If the same
95
+ package appears at multiple versions in the dependency tree, each distinct
96
+ version is reported separately.
97
+
98
+ ### `--project-name <name>`
99
+
100
+ Include a project name in the output. Useful for distinguishing reports when
101
+ collecting dependency data across multiple projects:
102
+
103
+ ```bash
104
+ depcollector --project-name myapp
105
+ ```
106
+
107
+ This adds a `"projectName"` key to the top level of the JSON output.
108
+
109
+ ### `--manifest-path <path>`
110
+
111
+ Include the in-repo path to the manifest in the output. This is intended for
112
+ monorepos where dependency data might be collected for multiple packages:
113
+
114
+ ```bash
115
+ depcollector --manifest-path packages/api/package.json
116
+ ```
117
+
118
+ This adds a `"manifestPath"` key to the top level of the JSON output. The
119
+ value is not validated -- it can be any string.
120
+
121
+ ### Saving output
122
+
123
+ The output is plain JSON on stdout, so you can pipe it wherever you like:
124
+
125
+ ```bash
126
+ # Save to a file
127
+ depcollector > deps-snapshot.json
128
+
129
+ # Upload to S3
130
+ depcollector | aws s3 cp - s3://my-bucket/deps/$(date +%Y-%m-%d).json
131
+
132
+ # Pretty-print with jq
133
+ depcollector | jq .
134
+ ```
135
+
136
+ ## Output format
137
+
138
+ | Field | Description |
139
+ |---|---|
140
+ | `ecosystem` | Always `"npm"` |
141
+ | `projectName` | Project name (present when `--project-name` is used) |
142
+ | `manifestPath` | In-repo manifest path (present when `--manifest-path` is used) |
143
+ | `collectedAt` | ISO 8601 timestamp of when the report was generated |
144
+ | `gitSha` | The current HEAD commit SHA |
145
+ | `gitTimestamp` | The commit timestamp of HEAD |
146
+ | `dependencies[]` | Array of dependency info objects |
147
+ | `dependencies[].name` | Package name |
148
+ | `dependencies[].direct` | `true` for direct dependencies, `false` for transitive |
149
+ | `dependencies[].currentVersion` | Version locked in package-lock.json |
150
+ | `dependencies[].currentVersionDate` | Release date of the locked version |
151
+ | `dependencies[].latestVersion` | Latest available version (or latest as of commit with `--at-commit`) |
152
+ | `dependencies[].latestVersionDate` | Release date of the latest version |
153
+
154
+ ## License
155
+
156
+ MIT
157
+
158
+ ## Author
159
+
160
+ [James Healy](https://yob.id.au)
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/index.js";
package/dist/git.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare function getGitInfo(): {
2
+ sha: string;
3
+ timestamp: string;
4
+ };
package/dist/git.js ADDED
@@ -0,0 +1,8 @@
1
+ import { execSync } from 'node:child_process';
2
+ export function getGitInfo() {
3
+ const sha = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
4
+ const timestamp = execSync('git log -1 --format=%cI', {
5
+ encoding: 'utf-8',
6
+ }).trim();
7
+ return { sha, timestamp };
8
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,100 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { program } from 'commander';
4
+ import { getPackageInfo } from './registry.js';
5
+ import { getGitInfo } from './git.js';
6
+ const CONCURRENCY = 5;
7
+ async function loadJson(filePath) {
8
+ const content = await readFile(filePath, 'utf-8');
9
+ return JSON.parse(content);
10
+ }
11
+ function getLockedVersions(packageJson, lockfile, transitive) {
12
+ if (!lockfile.lockfileVersion || lockfile.lockfileVersion < 2) {
13
+ throw new Error(`package-lock.json lockfileVersion ${lockfile.lockfileVersion ?? 1} is not supported. ` +
14
+ 'Please regenerate your lockfile with npm 7+ (lockfileVersion 2 or 3).');
15
+ }
16
+ if (!lockfile.packages) {
17
+ throw new Error("package-lock.json has no 'packages' field.");
18
+ }
19
+ if (transitive) {
20
+ return getAllLockedVersions(lockfile.packages);
21
+ }
22
+ const allDeps = {
23
+ ...packageJson.dependencies,
24
+ ...packageJson.devDependencies,
25
+ };
26
+ const result = [];
27
+ for (const name of Object.keys(allDeps)) {
28
+ const entry = lockfile.packages[`node_modules/${name}`];
29
+ if (entry?.version) {
30
+ result.push({ name, version: entry.version });
31
+ }
32
+ }
33
+ return result;
34
+ }
35
+ function getAllLockedVersions(packages) {
36
+ const seen = new Set();
37
+ const result = [];
38
+ for (const [path, entry] of Object.entries(packages)) {
39
+ if (!path.startsWith('node_modules/') || !entry.version)
40
+ continue;
41
+ // Extract package name from the last node_modules/ segment
42
+ // e.g. "node_modules/a/node_modules/@scope/b" -> "@scope/b"
43
+ const name = path.substring(path.lastIndexOf('node_modules/') + 13);
44
+ // Deduplicate by name+version to avoid redundant registry calls
45
+ const key = `${name}@${entry.version}`;
46
+ if (!seen.has(key)) {
47
+ seen.add(key);
48
+ result.push({ name, version: entry.version });
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+ async function processInBatches(locked, directNames, concurrency, cutoff) {
54
+ const results = [];
55
+ for (let i = 0; i < locked.length; i += concurrency) {
56
+ const batch = locked.slice(i, i + concurrency);
57
+ const batchResults = await Promise.all(batch.map((dep) => getPackageInfo(dep.name, dep.version, directNames.has(dep.name), cutoff)));
58
+ results.push(...batchResults);
59
+ }
60
+ return results;
61
+ }
62
+ program
63
+ .name('depcollector')
64
+ .description('Collect dependency version and age information from package.json and package-lock.json')
65
+ .option('--at-commit', 'Cap latest version to what was available at the time of the commit')
66
+ .option('--transitive', 'Include transitive dependencies')
67
+ .option('--project-name <name>', 'Include a project name in the output')
68
+ .option('--manifest-path <path>', 'Include the in-repo path to the manifest in the output')
69
+ .parse();
70
+ async function main() {
71
+ const opts = program.opts();
72
+ const cwd = process.cwd();
73
+ const [packageJson, lockfile] = await Promise.all([
74
+ loadJson(join(cwd, 'package.json')),
75
+ loadJson(join(cwd, 'package-lock.json')),
76
+ ]);
77
+ const locked = getLockedVersions(packageJson, lockfile, opts.transitive ?? false);
78
+ locked.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
79
+ const directNames = new Set(Object.keys({
80
+ ...packageJson.dependencies,
81
+ ...packageJson.devDependencies,
82
+ }));
83
+ const gitInfo = getGitInfo();
84
+ const cutoff = opts.atCommit ? new Date(gitInfo.timestamp) : undefined;
85
+ const dependencies = await processInBatches(locked, directNames, CONCURRENCY, cutoff);
86
+ const result = {
87
+ ecosystem: 'npm',
88
+ ...(opts.projectName && { projectName: opts.projectName }),
89
+ ...(opts.manifestPath && { manifestPath: opts.manifestPath }),
90
+ collectedAt: new Date().toISOString(),
91
+ gitSha: gitInfo.sha,
92
+ gitTimestamp: gitInfo.timestamp,
93
+ dependencies,
94
+ };
95
+ console.log(JSON.stringify(result, null, 2));
96
+ }
97
+ main().catch((err) => {
98
+ console.error(err);
99
+ process.exit(1);
100
+ });
@@ -0,0 +1,2 @@
1
+ import type { DependencyInfo } from './types.js';
2
+ export declare function getPackageInfo(name: string, currentVersion: string, direct: boolean, cutoff?: Date): Promise<DependencyInfo>;
@@ -0,0 +1,81 @@
1
+ function compareVersions(a, b) {
2
+ const pa = a.split('.').map(Number);
3
+ const pb = b.split('.').map(Number);
4
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
5
+ const na = pa[i] ?? 0;
6
+ const nb = pb[i] ?? 0;
7
+ if (na !== nb)
8
+ return na - nb;
9
+ }
10
+ return 0;
11
+ }
12
+ function findLatestBefore(time, versions, cutoff) {
13
+ let best;
14
+ for (const [version, dateStr] of Object.entries(time)) {
15
+ if (version === 'created' || version === 'modified')
16
+ continue;
17
+ if (version.includes('-'))
18
+ continue;
19
+ if (versions[version]?.deprecated)
20
+ continue;
21
+ const date = new Date(dateStr);
22
+ if (date <= cutoff && (!best || compareVersions(version, best) > 0)) {
23
+ best = version;
24
+ }
25
+ }
26
+ if (!best)
27
+ return undefined;
28
+ return { version: best, date: time[best] };
29
+ }
30
+ const registryCache = new Map();
31
+ async function fetchRegistryData(name) {
32
+ const cached = registryCache.get(name);
33
+ if (cached)
34
+ return cached;
35
+ const url = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
36
+ const res = await fetch(url, {
37
+ headers: { Accept: 'application/json' },
38
+ });
39
+ if (!res.ok) {
40
+ throw new Error(`Failed to fetch registry info for ${name}: ${res.status} ${res.statusText}`);
41
+ }
42
+ const data = (await res.json());
43
+ registryCache.set(name, data);
44
+ return data;
45
+ }
46
+ export async function getPackageInfo(name, currentVersion, direct, cutoff) {
47
+ let data;
48
+ try {
49
+ data = await fetchRegistryData(name);
50
+ }
51
+ catch (err) {
52
+ console.error(`Warning: ${err instanceof Error ? err.message : String(err)}`);
53
+ return {
54
+ name,
55
+ direct,
56
+ currentVersion,
57
+ currentVersionDate: '',
58
+ latestVersion: '',
59
+ latestVersionDate: '',
60
+ };
61
+ }
62
+ let latestVersion;
63
+ let latestVersionDate;
64
+ if (cutoff) {
65
+ const found = findLatestBefore(data.time, data.versions, cutoff);
66
+ latestVersion = found?.version ?? data['dist-tags'].latest;
67
+ latestVersionDate = found?.date ?? data.time[latestVersion] ?? 'unknown';
68
+ }
69
+ else {
70
+ latestVersion = data['dist-tags'].latest;
71
+ latestVersionDate = data.time[latestVersion] ?? 'unknown';
72
+ }
73
+ return {
74
+ name,
75
+ direct,
76
+ currentVersion,
77
+ currentVersionDate: data.time[currentVersion] ?? 'unknown',
78
+ latestVersion,
79
+ latestVersionDate,
80
+ };
81
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { program } from "commander";
4
+ const MS_PER_YEAR = 365.25 * 24 * 60 * 60 * 1000;
5
+ function calculateLibyears(result) {
6
+ let total = 0;
7
+ for (const dep of result.dependencies) {
8
+ if (dep.currentVersionDate === "unknown" || dep.latestVersionDate === "unknown")
9
+ continue;
10
+ const current = new Date(dep.currentVersionDate).getTime();
11
+ const latest = new Date(dep.latestVersionDate).getTime();
12
+ const diff = latest - current;
13
+ if (diff > 0) {
14
+ total += diff / MS_PER_YEAR;
15
+ }
16
+ }
17
+ return total;
18
+ }
19
+ program
20
+ .name("depcollector-summarize")
21
+ .description("Summarize multiple depcollector JSON reports into a CSV")
22
+ .argument("<directory>", "Directory containing depcollector JSON files")
23
+ .parse();
24
+ async function main() {
25
+ const dir = program.args[0];
26
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".json")).sort();
27
+ const rows = [];
28
+ for (const file of files) {
29
+ const content = await readFile(join(dir, file), "utf-8");
30
+ const result = JSON.parse(content);
31
+ const libyears = calculateLibyears(result);
32
+ rows.push({
33
+ gitSha: result.gitSha,
34
+ gitTimestamp: result.gitTimestamp,
35
+ libyears: libyears.toFixed(2),
36
+ });
37
+ }
38
+ rows.sort((a, b) => a.gitTimestamp.localeCompare(b.gitTimestamp));
39
+ console.log("commit_sha,commit_timestamp,libyears");
40
+ for (const row of rows) {
41
+ console.log(`${row.gitSha},${row.gitTimestamp},${row.libyears}`);
42
+ }
43
+ }
44
+ main().catch((err) => {
45
+ console.error(err);
46
+ process.exit(1);
47
+ });
@@ -0,0 +1,21 @@
1
+ export interface LockedDependency {
2
+ name: string;
3
+ version: string;
4
+ }
5
+ export interface DependencyInfo {
6
+ name: string;
7
+ direct: boolean;
8
+ currentVersion: string;
9
+ currentVersionDate: string;
10
+ latestVersion: string;
11
+ latestVersionDate: string;
12
+ }
13
+ export interface CollectionResult {
14
+ ecosystem: 'npm';
15
+ projectName?: string;
16
+ manifestPath?: string;
17
+ collectedAt: string;
18
+ gitSha: string;
19
+ gitTimestamp: string;
20
+ dependencies: DependencyInfo[];
21
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@yob/depcollector",
3
+ "version": "0.1.0",
4
+ "description": "Collect dependency version and age information from package.json and package-lock.json",
5
+ "type": "module",
6
+ "bin": {
7
+ "depcollector": "./bin/depcollector.mjs"
8
+ },
9
+ "author": "James Healy (https://yob.id.au)",
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "formatting": "prettier --check .",
13
+ "formatting:fix": "prettier --write .",
14
+ "prepublishOnly": "npm run build",
15
+ "start": "tsx src/index.ts"
16
+ },
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "bin"
23
+ ],
24
+ "keywords": [
25
+ "dependencies",
26
+ "versions",
27
+ "audit",
28
+ "npm"
29
+ ],
30
+ "license": "MIT",
31
+ "devDependencies": {
32
+ "@types/node": "^22",
33
+ "prettier": "^3.8.1",
34
+ "tsx": "^4",
35
+ "typescript": "^5"
36
+ },
37
+ "dependencies": {
38
+ "commander": "^14.0.3"
39
+ }
40
+ }