plexus-peers 0.2.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,85 @@
1
+ # Plexus
2
+
3
+ CLI and a small web UI to inspect **direct dependencies** from a `package.json`: peer dependency ranges vs what is installed, optional **BundlePhobia**-style bundle size hints, and a **`--fix`** mode that queries npm for upgrade and cascade notes.
4
+
5
+ Requires **Node.js 18+** (uses the built-in `fetch` API).
6
+
7
+ The npm package is **`plexus-peers`** (the short name `plexus` is already used on the registry by another project).
8
+
9
+ ## Install
10
+
11
+ From npm:
12
+
13
+ ```bash
14
+ npx plexus-peers --help
15
+ ```
16
+
17
+ From a clone of this repo:
18
+
19
+ ```bash
20
+ npm install
21
+ node bin/plexus.js --help
22
+ ```
23
+
24
+ ## CLI
25
+
26
+ Run from a project that has `package.json` (and usually `node_modules` after `npm install`):
27
+
28
+ ```bash
29
+ npx plexus-peers # Terminal report for the current directory
30
+ npx plexus-peers --dir ./my-app # Another project root
31
+ npx plexus-peers -f ./path/to/package.json
32
+ ```
33
+
34
+ | Option | Description |
35
+ |--------|-------------|
36
+ | `--html` | Write an HTML report (`dep-graph.html` in the project root, or use `--out`) |
37
+ | `--out <path>` | HTML output path (with `--html`) |
38
+ | `--conflicts-only` | Only list packages that have peer issues |
39
+ | `--fix` | Call `npm` for latest metadata and print resolution hints (chatty; avoid on CI if logs matter) |
40
+ | `--bundlesize` | With `--html`, query [BundlePhobia](https://bundlephobia.com/) per direct dependency (slow, rate limits may apply) |
41
+ | `--pkg <name>` | Focus on one direct dependency and related peer rows |
42
+
43
+ Examples:
44
+
45
+ ```bash
46
+ npx plexus-peers --html
47
+ npx plexus-peers --html --fix --bundlesize --dir ./my-app
48
+ ```
49
+
50
+ ## Web UI (`serve`)
51
+
52
+ Starts a local server with a page to **upload** a `package.json`. The report is generated from the **npm registry** (semver resolution for each direct dependency). There is **no** local `node_modules`, so disk sizes are absent and only dependencies declared in the manifest contribute resolved versions for peers.
53
+
54
+ ```bash
55
+ npx plexus-peers serve
56
+ # http://127.0.0.1:3847 (override with --port)
57
+ ```
58
+
59
+ From the repo you can also run:
60
+
61
+ ```bash
62
+ npm start
63
+ ```
64
+
65
+ Optional checkboxes on the page match `--fix` and `--bundlesize`.
66
+
67
+ ## CLI vs upload
68
+
69
+ | | CLI (filesystem) | Web upload |
70
+ |--|------------------|------------|
71
+ | **Versions** | Read from `node_modules` | Resolved from registry against ranges in `package.json` |
72
+ | **Disk size** | `du` on `node_modules` entries | Not available |
73
+ | **Peers only transitive** | Can still be read from disk if present | Only if that package is also a direct dependency |
74
+
75
+ ## Development
76
+
77
+ ```bash
78
+ npm install
79
+ npm run plexus -- --help
80
+ npm start # serve UI on default port
81
+ ```
82
+
83
+ ## License
84
+
85
+ MIT
package/bin/plexus.js ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('path');
5
+ const { runFilesystemAnalysis } = require('../lib/engine');
6
+ const { startServer } = require('../lib/server');
7
+
8
+ function printHelp() {
9
+ console.log(`
10
+ Plexus — peer dependencies, sizes, resolution hints
11
+
12
+ Usage:
13
+ npx plexus-peers [options] Analyze package.json in current directory
14
+ npx plexus-peers serve [options] Start upload UI + API
15
+
16
+ Options:
17
+ --dir, -C <path> Project root (contains package.json and node_modules)
18
+ --file, -f <path> Path to package.json (root = its directory)
19
+ --html Write HTML report (dep-graph.html in project root)
20
+ --out <path> HTML output path (with --html)
21
+ --conflicts-only Terminal: only packages with peer issues
22
+ --fix Query npm for upgrade / cascade hints (noisy on CI)
23
+ --bundlesize With --html: fetch gzip sizes from BundlePhobia (slow)
24
+ --pkg <name> Focus on one direct dependency and its peers
25
+
26
+ Serve:
27
+ --port <n> Port (default: 3847)
28
+
29
+ Examples:
30
+ npx plexus-peers --html --dir ./my-app
31
+ npx plexus-peers serve --port 3847
32
+ `);
33
+ }
34
+
35
+ function parseArgv(argv) {
36
+ const out = {
37
+ command: 'analyze',
38
+ rootDir: null,
39
+ packageJsonPath: null,
40
+ port: 3847,
41
+ flags: {
42
+ conflictsOnly: false,
43
+ html: false,
44
+ fix: false,
45
+ bundleSize: false,
46
+ focusPkg: null,
47
+ outFile: null,
48
+ },
49
+ help: false,
50
+ };
51
+
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const a = argv[i];
54
+ if (a === 'serve' || a === 'web') {
55
+ out.command = 'serve';
56
+ continue;
57
+ }
58
+ if (a === '--help' || a === '-h') {
59
+ out.help = true;
60
+ continue;
61
+ }
62
+ if (a === '--dir' || a === '-C') {
63
+ out.rootDir = path.resolve(argv[++i] ?? '');
64
+ continue;
65
+ }
66
+ if (a === '--file' || a === '-f') {
67
+ out.packageJsonPath = path.resolve(argv[++i] ?? '');
68
+ continue;
69
+ }
70
+ if (a === '--port') {
71
+ out.port = Number(argv[++i]) || 3847;
72
+ continue;
73
+ }
74
+ if (a === '--out') {
75
+ out.flags.outFile = path.resolve(argv[++i] ?? '');
76
+ continue;
77
+ }
78
+ if (a === '--pkg') {
79
+ out.flags.focusPkg = argv[++i] ?? null;
80
+ continue;
81
+ }
82
+ if (a === '--conflicts-only') {
83
+ out.flags.conflictsOnly = true;
84
+ continue;
85
+ }
86
+ if (a === '--html') {
87
+ out.flags.html = true;
88
+ continue;
89
+ }
90
+ if (a === '--fix') {
91
+ out.flags.fix = true;
92
+ continue;
93
+ }
94
+ if (a === '--bundlesize') {
95
+ out.flags.bundleSize = true;
96
+ continue;
97
+ }
98
+ if (a.startsWith('-')) {
99
+ console.error(`Unknown option: ${a}\nRun npx plexus-peers --help`);
100
+ process.exitCode = 1;
101
+ return null;
102
+ }
103
+ }
104
+
105
+ if (out.packageJsonPath) {
106
+ out.rootDir = path.dirname(out.packageJsonPath);
107
+ }
108
+ if (!out.rootDir) {
109
+ out.rootDir = process.cwd();
110
+ }
111
+
112
+ return out;
113
+ }
114
+
115
+ async function main() {
116
+ const parsed = parseArgv(process.argv.slice(2));
117
+ if (!parsed) return;
118
+ if (parsed.help) {
119
+ printHelp();
120
+ return;
121
+ }
122
+
123
+ if (parsed.command === 'serve') {
124
+ startServer({ port: parsed.port });
125
+ return;
126
+ }
127
+
128
+ await runFilesystemAnalysis({ rootDir: parsed.rootDir, flags: parsed.flags });
129
+ }
130
+
131
+ main().catch(err => {
132
+ console.error(err);
133
+ process.exitCode = 1;
134
+ });
package/lib/ansi.js ADDED
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const c = {
4
+ reset: '\x1b[0m',
5
+ bold: '\x1b[1m',
6
+ red: '\x1b[31m',
7
+ yellow: '\x1b[33m',
8
+ green: '\x1b[32m',
9
+ cyan: '\x1b[36m',
10
+ gray: '\x1b[90m',
11
+ blue: '\x1b[34m',
12
+ magenta: '\x1b[35m',
13
+ };
14
+
15
+ function color(str, ...codes) {
16
+ return codes.map(code => c[code]).join('') + str + c.reset;
17
+ }
18
+
19
+ module.exports = { color };
package/lib/engine.js ADDED
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Plexus engine — dependency graph + peer conflict detection (filesystem or registry).
5
+ * Re-exports modular pieces; implementation lives in ./ansi, ./format, ./graph, etc.
6
+ */
7
+
8
+ const { color } = require('./ansi');
9
+ const { formatBytes } = require('./format');
10
+ const { createFsContext, buildGraph } = require('./graph');
11
+ const { printGraph, printSummary } = require('./terminal');
12
+ const { resolveConflicts } = require('./fix');
13
+ const { queryNpm, getBundleSize } = require('./npm');
14
+ const { renderHtml } = require('./render-html');
15
+ const { openHtmlReport, runFilesystemAnalysis, runRegistryAnalysis } = require('./run-analysis');
16
+
17
+ module.exports = {
18
+ color,
19
+ formatBytes,
20
+ buildGraph,
21
+ createFsContext,
22
+ printGraph,
23
+ printSummary,
24
+ resolveConflicts,
25
+ queryNpm,
26
+ renderHtml,
27
+ getBundleSize,
28
+ runFilesystemAnalysis,
29
+ runRegistryAnalysis,
30
+ openHtmlReport,
31
+ };
package/lib/fix.js ADDED
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ const { color } = require('./ansi');
4
+ const { satisfies } = require('./graph');
5
+ const { queryNpm } = require('./npm');
6
+
7
+ /**
8
+ * For each real conflict (version mismatch, not just missing optional):
9
+ * 1. Query npm for the latest version of the conflicting package.
10
+ * 2. Check if latest fixes the conflict (its new peer range accepts the installed version).
11
+ * 3. Check if latest introduces NEW conflicts (cascade detection).
12
+ */
13
+ function resolveConflicts(graph, directDeps, getInstalledVersion, options = {}) {
14
+ const silent = options.silent === true;
15
+ const log = (...args) => {
16
+ if (!silent) console.log(...args);
17
+ };
18
+ const write = (...args) => {
19
+ if (!silent) process.stdout.write(...args);
20
+ };
21
+
22
+ const directSet = new Set(Object.keys(directDeps));
23
+
24
+ const conflicts = [];
25
+ for (const [pkgName, info] of Object.entries(graph)) {
26
+ if (!directSet.has(pkgName)) continue;
27
+ for (const peer of info.peerDeps) {
28
+ if (!peer.ok && peer.installed) {
29
+ conflicts.push({
30
+ pkg: pkgName,
31
+ peer: peer.name,
32
+ range: peer.range,
33
+ installed: peer.installed,
34
+ });
35
+ }
36
+ }
37
+ }
38
+
39
+ if (conflicts.length === 0) return { resolutions: {}, cascades: [], suggestedDeps: null };
40
+
41
+ const pkgsToCheck = [...new Set(conflicts.map(c => c.pkg))];
42
+
43
+ log(color(`\nQuerying npm for ${pkgsToCheck.length} package(s)…`, 'gray'));
44
+
45
+ const resolutions = {};
46
+
47
+ for (const pkg of pkgsToCheck) {
48
+ write(` ${color(pkg, 'cyan')} … `);
49
+ const info = queryNpm(pkg);
50
+ if (!info) {
51
+ log(color('not found on npm', 'yellow'));
52
+ continue;
53
+ }
54
+
55
+ const latest = info['dist-tags']?.latest;
56
+ if (!latest) {
57
+ log(color('no dist-tags.latest', 'yellow'));
58
+ continue;
59
+ }
60
+
61
+ const newPeerDeps = info.peerDependencies ?? {};
62
+ const currentVersion = directDeps[pkg];
63
+
64
+ const fixes = conflicts
65
+ .filter(c => c.pkg === pkg)
66
+ .filter(c => {
67
+ const newRange = newPeerDeps[c.peer];
68
+ if (!newRange) return true;
69
+ return satisfies(c.installed, newRange);
70
+ })
71
+ .map(c => c.peer);
72
+
73
+ const stillConflicts = conflicts
74
+ .filter(c => c.pkg === pkg && !fixes.includes(c.peer))
75
+ .map(c => c.peer);
76
+
77
+ const cascades = Object.entries(newPeerDeps)
78
+ .map(([peerName, peerRange]) => {
79
+ const installedVer = getInstalledVersion(peerName);
80
+ if (!installedVer) return null;
81
+ const ok = satisfies(installedVer, peerRange);
82
+ return ok ? null : { peerName, peerRange, installedVer };
83
+ })
84
+ .filter(Boolean);
85
+
86
+ log(
87
+ latest === currentVersion.replace(/[\^~>=<]/g, '')
88
+ ? color(`already latest (v${latest})`, 'gray')
89
+ : color(`v${currentVersion} → v${latest}`, 'green'),
90
+ );
91
+
92
+ resolutions[pkg] = {
93
+ current: currentVersion,
94
+ latest,
95
+ fixes,
96
+ stillConflicts,
97
+ cascades,
98
+ newPeerDeps,
99
+ };
100
+ }
101
+
102
+ const upgrades = Object.fromEntries(
103
+ Object.entries(resolutions).map(([pkg, r]) => [pkg, r.latest]),
104
+ );
105
+
106
+ return { resolutions, upgrades };
107
+ }
108
+
109
+ module.exports = { resolveConflicts };
package/lib/format.js ADDED
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+
3
+ function formatBytes(bytes) {
4
+ if (bytes === null || bytes === undefined) return null;
5
+ if (bytes < 1024) return `${bytes} B`;
6
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
7
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
8
+ }
9
+
10
+ function sizeClass(bytes) {
11
+ if (!bytes) return '';
12
+ if (bytes > 5 * 1024 * 1024) return 'size-large';
13
+ if (bytes > 1 * 1024 * 1024) return 'size-medium';
14
+ return 'size-small';
15
+ }
16
+
17
+ module.exports = { formatBytes, sizeClass };
package/lib/graph.js ADDED
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+ const semver = require('semver');
7
+
8
+ function getDiskSize(pkgName, nodeModulesPath) {
9
+ const pkgPath = path.join(nodeModulesPath, pkgName);
10
+ try {
11
+ const out = execFileSync('du', ['-sk', pkgPath], {
12
+ encoding: 'utf8',
13
+ timeout: 8000,
14
+ maxBuffer: 256 * 1024,
15
+ });
16
+ const kb = parseInt(out.split(/\s/)[0], 10);
17
+ return isNaN(kb) ? null : kb * 1024;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ function createFsContext(rootDir) {
24
+ const nodeModulesPath = path.join(rootDir, 'node_modules');
25
+ function readPkg(pkgName) {
26
+ const pkgPath = path.join(nodeModulesPath, pkgName, 'package.json');
27
+ try {
28
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+ function getInstalledVersion(pkgName) {
34
+ return readPkg(pkgName)?.version ?? null;
35
+ }
36
+ return {
37
+ nodeModulesPath,
38
+ readPkg,
39
+ getDiskSize: pkgName => getDiskSize(pkgName, nodeModulesPath),
40
+ getInstalledVersion,
41
+ };
42
+ }
43
+
44
+ function satisfies(installedVersion, requiredRange) {
45
+ if (!installedVersion) return false;
46
+ try {
47
+ const coerced = semver.coerce(installedVersion);
48
+ if (!coerced) return false;
49
+ return semver.satisfies(coerced, requiredRange, { includePrerelease: true });
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ function buildGraph(directDeps, ctx) {
56
+ const { readPkg, getDiskSize, getInstalledVersion } = ctx;
57
+ const graph = {};
58
+
59
+ const toProcess = [...Object.keys(directDeps)];
60
+ const visited = new Set();
61
+
62
+ while (toProcess.length > 0) {
63
+ const pkgName = toProcess.shift();
64
+ if (visited.has(pkgName)) continue;
65
+ visited.add(pkgName);
66
+
67
+ const pkg = readPkg(pkgName);
68
+ if (!pkg) {
69
+ graph[pkgName] = { version: null, missing: true, peerDeps: [], requiredBy: [] };
70
+ continue;
71
+ }
72
+
73
+ const peerDeps = pkg.peerDependencies ?? {};
74
+ const peerEntries = Object.entries(peerDeps).map(([name, range]) => {
75
+ const installed = getInstalledVersion(name);
76
+ const ok = satisfies(installed, range);
77
+ return { name, range, installed, ok };
78
+ });
79
+
80
+ graph[pkgName] = {
81
+ version: pkg.version,
82
+ missing: false,
83
+ diskSize: getDiskSize(pkgName),
84
+ peerDeps: peerEntries,
85
+ requiredBy: [],
86
+ };
87
+
88
+ for (const { name } of peerEntries) {
89
+ if (!graph[name])
90
+ graph[name] = { version: null, missing: false, peerDeps: [], requiredBy: [] };
91
+ if (!graph[name].requiredBy.includes(pkgName)) {
92
+ graph[name].requiredBy.push(pkgName);
93
+ }
94
+ }
95
+ }
96
+
97
+ return graph;
98
+ }
99
+
100
+ module.exports = { createFsContext, buildGraph, satisfies };
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ function escapeHtml(s) {
4
+ if (s == null || s === '') return '';
5
+ return String(s)
6
+ .replace(/&/g, '&amp;')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;')
9
+ .replace(/"/g, '&quot;')
10
+ .replace(/'/g, '&#39;');
11
+ }
12
+
13
+ function npmPackageUrl(name) {
14
+ return `https://www.npmjs.com/package/${encodeURIComponent(String(name))}`;
15
+ }
16
+
17
+ /** Safe `id` / fragment slug for package names (not HTML-escaped — use only in id/href fragments). */
18
+ function pkgSlugForDom(name) {
19
+ return String(name).replace(/[@/]/g, '-').replace(/[^a-zA-Z0-9._-]/g, '-');
20
+ }
21
+
22
+ module.exports = { escapeHtml, npmPackageUrl, pkgSlugForDom };
package/lib/npm.js ADDED
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('child_process');
4
+ const semver = require('semver');
5
+
6
+ function queryNpm(pkg) {
7
+ try {
8
+ const out = execFileSync('npm', ['info', pkg, '--json'], {
9
+ encoding: 'utf8',
10
+ timeout: 12000,
11
+ maxBuffer: 10 * 1024 * 1024,
12
+ stdio: ['ignore', 'pipe', 'ignore'],
13
+ }).trim();
14
+ return out ? JSON.parse(out) : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ async function getBundleSize(pkgName, version) {
21
+ try {
22
+ const url = `https://bundlephobia.com/api/size?package=${encodeURIComponent(pkgName)}@${version}`;
23
+ const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
24
+ if (!res.ok) return null;
25
+ const data = await res.json();
26
+ return { size: data.size, gzip: data.gzip };
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ async function fetchResolvedPackageManifest(name, range) {
33
+ const url = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
34
+ const res = await fetch(url, { signal: AbortSignal.timeout(20000) });
35
+ if (!res.ok) return null;
36
+ const data = await res.json();
37
+ const versions = Object.keys(data.versions || {});
38
+ const best = semver.maxSatisfying(versions, range, { includePrerelease: true });
39
+ if (!best) return null;
40
+ return data.versions[best];
41
+ }
42
+
43
+ function registryFetchConcurrency() {
44
+ const n = Number(process.env.PLEXUS_REGISTRY_CONCURRENCY || 10);
45
+ return Math.max(1, Math.min(50, n > 0 ? n : 10));
46
+ }
47
+
48
+ async function createRegistryContext(directDeps) {
49
+ const pkgs = new Map();
50
+ const names = Object.keys(directDeps);
51
+ const limit = registryFetchConcurrency();
52
+ for (let i = 0; i < names.length; i += limit) {
53
+ const batch = names.slice(i, i + limit);
54
+ await Promise.all(
55
+ batch.map(async name => {
56
+ try {
57
+ const manifest = await fetchResolvedPackageManifest(name, directDeps[name]);
58
+ pkgs.set(name, manifest);
59
+ } catch {
60
+ pkgs.set(name, null);
61
+ }
62
+ }),
63
+ );
64
+ }
65
+
66
+ function readPkg(pkgName) {
67
+ return pkgs.get(pkgName) ?? null;
68
+ }
69
+ function getInstalledVersion(pkgName) {
70
+ return readPkg(pkgName)?.version ?? null;
71
+ }
72
+ return {
73
+ readPkg,
74
+ getDiskSize: () => null,
75
+ getInstalledVersion,
76
+ };
77
+ }
78
+
79
+ module.exports = {
80
+ queryNpm,
81
+ getBundleSize,
82
+ fetchResolvedPackageManifest,
83
+ createRegistryContext,
84
+ registryFetchConcurrency,
85
+ };