pnpm-viz 1.0.1

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,86 @@
1
+ # pnpm-viz
2
+
3
+ **Visual companion for pnpm**
4
+
5
+ `pnpm-viz` parses your `pnpm-lock.yaml` and `pnpm-workspace.yaml` to generate interactive dependency graphs, version conflict heatmaps, and workspace flow diagrams.
6
+
7
+ ## Features
8
+
9
+ - **Interactive CLI**: View workspace structure and duplicate stats directly in your terminal.
10
+ - **Heatmap Analysis**: Identify duplicate packages and potential version conflicts.
11
+ - **Web Dashboard**: Explore your dependency graph interactively in the browser.
12
+ - **Export**: Export your dependency graph to JSON for further analysis.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install -g pnpm-viz
18
+ # OR run directly
19
+ npx pnpm-viz
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ### CLI Overview
25
+
26
+ Run in your project root:
27
+
28
+ ```bash
29
+ pnpm-viz .
30
+ ```
31
+
32
+ ### Heatmap Mode
33
+
34
+ View duplicate packages in the terminal:
35
+
36
+ ```bash
37
+ pnpm-viz . --heatmap
38
+ ```
39
+
40
+ ### Web Dashboard
41
+
42
+ Launch the interactive web visualization:
43
+
44
+ ```bash
45
+ pnpm-viz serve
46
+ ```
47
+
48
+ ### Export Data
49
+
50
+ Export the parsed graph to JSON:
51
+
52
+ ```bash
53
+ pnpm-viz export . -o graph.json
54
+ ```
55
+
56
+ ## Development
57
+
58
+ 1. Clone the repo.
59
+ 2. Install dependencies: `npm install`
60
+ 3. Build the project: `npm run build`
61
+ 4. Run locally: `node dist/cli/index.js .`
62
+
63
+
64
+ ## Publishing
65
+
66
+ To publish a new version to npm:
67
+
68
+ 1. **Login to npm** (if not already logged in):
69
+ ```bash
70
+ npm login
71
+ ```
72
+
73
+ 2. **Bump the version**:
74
+ ```bash
75
+ npm version patch # or minor, major
76
+ ```
77
+
78
+ 3. **Publish**:
79
+ ```bash
80
+ npm publish
81
+ ```
82
+ (This will automatically run `npm run build` before publishing)
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Text, Box, Newline } from 'ink';
4
+ import BigText from 'ink-big-text';
5
+ import { LockfileParser } from '../core/parser.js';
6
+ import { GraphAnalyzer } from '../core/analysis.js';
7
+ import path from 'path';
8
+ const App = ({ path: projectPath, heatmap }) => {
9
+ const [graph, setGraph] = useState(null);
10
+ const [loading, setLoading] = useState(true);
11
+ const [error, setError] = useState(null);
12
+ useEffect(() => {
13
+ const loadGraph = async () => {
14
+ try {
15
+ const lockfilePath = path.join(path.resolve(projectPath), 'pnpm-lock.yaml');
16
+ const parser = new LockfileParser(lockfilePath);
17
+ const g = await parser.parse();
18
+ setGraph(g);
19
+ }
20
+ catch (err) {
21
+ setError(err.message);
22
+ }
23
+ finally {
24
+ setLoading(false);
25
+ }
26
+ };
27
+ loadGraph();
28
+ }, [projectPath]);
29
+ if (loading) {
30
+ return _jsx(Text, { children: "Loading pnpm-lock.yaml..." });
31
+ }
32
+ if (error) {
33
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
34
+ }
35
+ if (!graph)
36
+ return null;
37
+ const analyzer = new GraphAnalyzer(graph);
38
+ const duplicates = analyzer.getDuplicates();
39
+ const stats = analyzer.getStats();
40
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "cyan", children: [_jsx(BigText, { text: "pnpm-viz", font: "chrome", colors: ['green', 'cyan'] }), _jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "Analyzed Project:" }), " ", path.resolve(projectPath)] }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "\u2714 Packages:" }), " ", stats.totalPackages] }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "\u2714 Dependencies:" }), " ", stats.totalDependencies] }), _jsxs(Text, { children: [_jsxs(Text, { color: stats.duplicateCount > 0 ? "yellow" : "green", bold: true, children: [stats.duplicateCount > 0 ? "⚠" : "✔", " Duplicates:"] }), " ", stats.duplicateCount] })] }), heatmap ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, underline: true, color: "yellow", backgroundColor: "black", children: " DUPLICATE PACKAGES HEATMAP " }), _jsx(Newline, {}), duplicates.length === 0 ? (_jsx(Text, { color: "green", children: "No duplicates found. Great job!" })) : (duplicates.slice(0, 10).map((dup, i) => (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", width: "80%", children: [_jsxs(Text, { bold: true, children: [i + 1, ". ", dup.name] }), _jsxs(Box, { children: [_jsxs(Text, { color: "red", children: [" ", dup.count, " versions "] }), _jsxs(Text, { dimColor: true, children: [" (", dup.versions.join(', '), ")"] })] })] }, dup.name)))), duplicates.length > 10 && _jsxs(Text, { dimColor: true, children: ["...and ", duplicates.length - 10, " more."] })] })) : (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, underline: true, color: "cyan", children: "WORKSPACE IMPORTERS" }), Object.values(graph.nodes).filter(n => n.path && !n.path.includes('node_modules') && n.id !== 'root').map(node => (_jsxs(Text, { children: [" \u2022 ", node.name] }, node.id))), _jsx(Newline, {}), _jsx(Text, { dimColor: true, children: "Run with --heatmap to see duplicates." }), _jsx(Text, { dimColor: true, children: "Run 'pnpm-viz serve' to view graph." })] }))] }));
41
+ };
42
+ export default App;
@@ -0,0 +1,16 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { LockfileParser } from '../core/parser.js';
4
+ export async function exportGraph(projectPath, outputFile) {
5
+ try {
6
+ const lockfilePath = path.join(path.resolve(projectPath), 'pnpm-lock.yaml');
7
+ const parser = new LockfileParser(lockfilePath);
8
+ const graph = await parser.parse();
9
+ fs.writeFileSync(outputFile, JSON.stringify(graph, null, 2));
10
+ console.log(`Graph exported to ${outputFile}`);
11
+ }
12
+ catch (err) {
13
+ console.error('Export failed:', err.message);
14
+ process.exit(1);
15
+ }
16
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from 'ink';
4
+ import { Command } from 'commander';
5
+ import App from './app.js';
6
+ const program = new Command();
7
+ program
8
+ .name('pnpm-viz')
9
+ .description('Visualize your pnpm dependencies')
10
+ .version('1.0.0')
11
+ .argument('[path]', 'Path to the project', '.')
12
+ .option('--heatmap', 'Show heatmap mode')
13
+ .action((path, options) => {
14
+ render(_jsx(App, { path: path, heatmap: options.heatmap }));
15
+ });
16
+ program
17
+ .command('serve')
18
+ .description('Launch the web dashboard')
19
+ .argument('[path]', 'Path to the project', '.')
20
+ .option('-p, --port <number>', 'Port to run on', '3000')
21
+ .action((path, options) => {
22
+ import('./server.js').then(module => {
23
+ module.startServer(path, parseInt(options.port));
24
+ });
25
+ });
26
+ program
27
+ .command('export')
28
+ .description('Export graph as JSON')
29
+ .argument('[path]', 'Path to the project', '.')
30
+ .option('-o, --output <file>', 'Output file path', 'graph.json')
31
+ .action((path, options) => {
32
+ import('./export.js').then(module => {
33
+ module.exportGraph(path, options.output);
34
+ });
35
+ });
36
+ program.parse();
@@ -0,0 +1,89 @@
1
+ import http from 'http';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import open from 'open';
6
+ import { LockfileParser } from '../core/parser.js';
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ // dist/cli/server.js -> dist/web
10
+ const WEB_ROOT = path.resolve(__dirname, '../../dist/web');
11
+ export async function startServer(projectPath, port = 3000) {
12
+ const server = http.createServer(async (req, res) => {
13
+ // API Endpoint
14
+ if (req.url === '/api/graph') {
15
+ try {
16
+ const lockfilePath = path.join(path.resolve(projectPath), 'pnpm-lock.yaml');
17
+ const parser = new LockfileParser(lockfilePath);
18
+ const graph = await parser.parse();
19
+ res.writeHead(200, { 'Content-Type': 'application/json' });
20
+ res.end(JSON.stringify(graph));
21
+ }
22
+ catch (err) {
23
+ res.writeHead(500, { 'Content-Type': 'application/json' });
24
+ res.end(JSON.stringify({ error: err.message }));
25
+ }
26
+ return;
27
+ }
28
+ // Static File Serving
29
+ let filePath = path.join(WEB_ROOT, req.url === '/' ? 'index.html' : req.url || 'index.html');
30
+ // Security check to prevent directory traversal
31
+ if (!filePath.startsWith(WEB_ROOT)) {
32
+ res.writeHead(403);
33
+ res.end('Forbidden');
34
+ return;
35
+ }
36
+ const extname = path.extname(filePath);
37
+ let contentType = 'text/html';
38
+ switch (extname) {
39
+ case '.js':
40
+ contentType = 'text/javascript';
41
+ break;
42
+ case '.css':
43
+ contentType = 'text/css';
44
+ break;
45
+ case '.json':
46
+ contentType = 'application/json';
47
+ break;
48
+ case '.png':
49
+ contentType = 'image/png';
50
+ break;
51
+ case '.jpg':
52
+ contentType = 'image/jpg';
53
+ break;
54
+ case '.svg':
55
+ contentType = 'image/svg+xml';
56
+ break;
57
+ }
58
+ fs.readFile(filePath, (error, content) => {
59
+ if (error) {
60
+ if (error.code === 'ENOENT') {
61
+ // SPA Fallback necessary? React Router? Not using router yet.
62
+ // basic fallback
63
+ fs.readFile(path.join(WEB_ROOT, 'index.html'), (err, content) => {
64
+ if (err) {
65
+ res.writeHead(404);
66
+ res.end('Not Found');
67
+ }
68
+ else {
69
+ res.writeHead(200, { 'Content-Type': 'text/html' });
70
+ res.end(content, 'utf-8');
71
+ }
72
+ });
73
+ }
74
+ else {
75
+ res.writeHead(500);
76
+ res.end('Server Error: ' + error.code);
77
+ }
78
+ }
79
+ else {
80
+ res.writeHead(200, { 'Content-Type': contentType });
81
+ res.end(content, 'utf-8');
82
+ }
83
+ });
84
+ });
85
+ server.listen(port, () => {
86
+ console.log(`Server running at http://localhost:${port}/`);
87
+ open(`http://localhost:${port}/`);
88
+ });
89
+ }
@@ -0,0 +1,37 @@
1
+ export class GraphAnalyzer {
2
+ constructor(graph) {
3
+ this.graph = graph;
4
+ }
5
+ getDuplicates() {
6
+ const packageMap = new Map();
7
+ Object.values(this.graph.nodes).forEach(node => {
8
+ if (node.id === 'root' || node.path === '.')
9
+ return;
10
+ const name = node.name;
11
+ const version = node.version;
12
+ if (!packageMap.has(name)) {
13
+ packageMap.set(name, new Set());
14
+ }
15
+ packageMap.get(name)?.add(version);
16
+ });
17
+ const duplicates = [];
18
+ packageMap.forEach((versions, name) => {
19
+ if (versions.size > 1) {
20
+ duplicates.push({
21
+ name,
22
+ versions: Array.from(versions),
23
+ count: versions.size
24
+ });
25
+ }
26
+ });
27
+ // Sort by count descending
28
+ return duplicates.sort((a, b) => b.count - a.count);
29
+ }
30
+ getStats() {
31
+ return {
32
+ totalPackages: Object.keys(this.graph.nodes).length,
33
+ totalDependencies: this.graph.edges.length,
34
+ duplicateCount: this.getDuplicates().length
35
+ };
36
+ }
37
+ }
@@ -0,0 +1,157 @@
1
+ import fs from 'fs';
2
+ import yaml from 'js-yaml';
3
+ import path from 'path';
4
+ export class LockfileParser {
5
+ constructor(lockfilePath) {
6
+ this.lockfilePath = lockfilePath;
7
+ }
8
+ async parse() {
9
+ const content = fs.readFileSync(this.lockfilePath, 'utf8');
10
+ const lockfile = yaml.load(content);
11
+ const graph = {
12
+ nodes: {},
13
+ edges: [],
14
+ root: 'root',
15
+ };
16
+ // Handle root dependencies (monorepo root or single package)
17
+ // Note: This is a simplified parser. Real pnpm lockfiles are complex.
18
+ // We strictly follow pnpm v6+ lockfile structure (lockfileVersion 5.4, 6.0, 9.0 etc)
19
+ // Add logic to traverse 'importers' (workspaces) or root 'dependencies'
20
+ // Check for workspace file
21
+ const workspacePath = path.join(path.dirname(this.lockfilePath), 'pnpm-workspace.yaml');
22
+ let workspacePackages = [];
23
+ if (fs.existsSync(workspacePath)) {
24
+ try {
25
+ const workspaceContent = fs.readFileSync(workspacePath, 'utf8');
26
+ const workspace = yaml.load(workspaceContent);
27
+ if (workspace && Array.isArray(workspace.packages)) {
28
+ workspacePackages = workspace.packages;
29
+ }
30
+ }
31
+ catch (e) {
32
+ // ignore invalid workspace file
33
+ }
34
+ }
35
+ if (lockfile.importers) {
36
+ // It's a workspace
37
+ for (const [importerPath, importer] of Object.entries(lockfile.importers)) {
38
+ const nodeId = importerPath === '.' ? 'root' : importerPath;
39
+ // Try to find package.json for better name
40
+ let packageName = path.basename(importerPath === '.' ? process.cwd() : importerPath);
41
+ const packageJsonPath = path.join(path.dirname(this.lockfilePath), importerPath, 'package.json');
42
+ if (fs.existsSync(packageJsonPath)) {
43
+ try {
44
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
45
+ if (pkg.name)
46
+ packageName = pkg.name;
47
+ }
48
+ catch (e) { /* ignore */ }
49
+ }
50
+ const node = {
51
+ id: nodeId,
52
+ name: packageName,
53
+ version: '0.0.0',
54
+ path: importerPath,
55
+ dependencies: [],
56
+ };
57
+ graph.nodes[nodeId] = node;
58
+ if (importer.dependencies) {
59
+ this.processDeps(importer.dependencies, nodeId, graph, 'prod');
60
+ }
61
+ if (importer.devDependencies) {
62
+ this.processDeps(importer.devDependencies, nodeId, graph, 'dev');
63
+ }
64
+ }
65
+ }
66
+ else {
67
+ // Single package
68
+ let packageName = 'root';
69
+ const packageJsonPath = path.join(path.dirname(this.lockfilePath), 'package.json');
70
+ if (fs.existsSync(packageJsonPath)) {
71
+ try {
72
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
73
+ if (pkg.name)
74
+ packageName = pkg.name;
75
+ }
76
+ catch (e) { /* ignore */ }
77
+ }
78
+ const node = {
79
+ id: 'root',
80
+ name: packageName,
81
+ version: '0.0.0',
82
+ path: '.',
83
+ dependencies: [],
84
+ };
85
+ graph.nodes['root'] = node;
86
+ if (lockfile.dependencies) {
87
+ this.processDeps(lockfile.dependencies, 'root', graph, 'prod');
88
+ }
89
+ if (lockfile.packages) {
90
+ // in some lockfile versions, root deps are just in packages but referenced from '.' entry in importers
91
+ // if strictly checking 'dependencies' on root object fails, might be lockfile v6 style where root is in importers['.']
92
+ }
93
+ }
94
+ // Parse 'packages' section (the store)
95
+ if (lockfile.packages) {
96
+ for (const [pkgPath, pkg] of Object.entries(lockfile.packages)) {
97
+ const nodeId = pkgPath;
98
+ const node = {
99
+ id: nodeId,
100
+ name: this.extractName(pkgPath),
101
+ version: this.extractVersion(pkgPath),
102
+ path: pkgPath,
103
+ dependencies: [],
104
+ isDev: pkg.dev
105
+ };
106
+ graph.nodes[nodeId] = node;
107
+ if (pkg.dependencies) {
108
+ this.processDeps(pkg.dependencies, nodeId, graph, 'prod');
109
+ }
110
+ if (pkg.peerDependencies) {
111
+ this.processDeps(pkg.peerDependencies, nodeId, graph, 'peer');
112
+ }
113
+ }
114
+ }
115
+ return graph;
116
+ }
117
+ processDeps(deps, parentId, graph, type) {
118
+ for (const [name, ref] of Object.entries(deps)) {
119
+ // logic to resolve ref to a package path in 'packages'
120
+ // pnpm refs can be: '1.2.3', '/foo/1.2.3', 'link:../foo'
121
+ let targetId = ref;
122
+ if (ref.startsWith('link:')) {
123
+ // Local workspace link
124
+ // For now, simple resolution
125
+ targetId = ref.replace('link:', '');
126
+ // In monorepos, this might need normalization to match importer keys
127
+ }
128
+ else if (ref.includes('(')) {
129
+ // peer dep resolution, skip logic for now or clean
130
+ targetId = ref.split('(')[0];
131
+ }
132
+ // Trying to map simplified ref to package ID if possible
133
+ // In pnpm lockfile v6, keys in 'packages' are often just the resolution string (e.g. /@types/node@18.0.0)
134
+ // We need to match this.
135
+ // For now, create an edge. Whether the node exists or not will be checked by visualization
136
+ graph.edges.push({ source: parentId, target: targetId, type });
137
+ // Add dep to node (redundant but useful)
138
+ if (graph.nodes[parentId]) {
139
+ graph.nodes[parentId].dependencies.push(targetId);
140
+ }
141
+ }
142
+ }
143
+ extractName(pkgPath) {
144
+ // Very naive extraction, pnpm paths vary wildly
145
+ // /@babel/core/7.1.0 -> @babel/core
146
+ // /foo@1.0.0 -> foo
147
+ const parts = pkgPath.split('/');
148
+ if (parts[1]?.startsWith('@')) {
149
+ return `${parts[1]}/${parts[2].split('@')[0]}`;
150
+ }
151
+ return parts[1]?.split('@')[0] || pkgPath;
152
+ }
153
+ extractVersion(pkgPath) {
154
+ const parts = pkgPath.split('@');
155
+ return parts[parts.length - 1] || '0.0.0';
156
+ }
157
+ }
@@ -0,0 +1 @@
1
+ export {};