From ab912ce7ee23751eba728b5e97f4dffa3d5d6afd Mon Sep 17 00:00:00 2001 From: MULHAM Date: Sun, 21 Jun 2026 02:13:54 +0700 Subject: [PATCH 1/4] feat: add node-versions module to scan globals per Node version --- src/node-versions.js | 160 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/node-versions.js diff --git a/src/node-versions.js b/src/node-versions.js new file mode 100644 index 0000000..ade205c --- /dev/null +++ b/src/node-versions.js @@ -0,0 +1,160 @@ +import { readdirSync, readFileSync } from 'fs' +import { join } from 'path' +import { homedir } from 'os' +import chalk from 'chalk' +import Table from 'cli-table3' + +import { renderBanner } from './display/table.js' + +function safeReaddir(dir) { + try { + return readdirSync(dir, { withFileTypes: true }) + } catch { + return [] + } +} + +function safeReadJson(path) { + try { + return JSON.parse(readFileSync(path, 'utf8')) + } catch { + return null + } +} + +// Top-level entries in node_modules that are not installed packages. +const SKIP_NAMES = new Set(['.bin', '.cache', 'npm', 'corepack']) + +// Read installed packages from one `node_modules` directory. Pure + injectable +// so the scope/skip rules can be tested without touching the filesystem. +export function readGlobalPackages( + modulesDir, + { readdir = safeReaddir, readJson = safeReadJson } = {} +) { + const packages = [] + + for (const entry of readdir(modulesDir)) { + if (!entry.isDirectory()) continue + const name = entry.name + if (name.startsWith('.') || SKIP_NAMES.has(name)) continue + + if (name.startsWith('@')) { + const scopeDir = join(modulesDir, name) + for (const inner of readdir(scopeDir)) { + if (!inner.isDirectory() || inner.name.startsWith('.')) continue + const pkg = readJson(join(scopeDir, inner.name, 'package.json')) + packages.push({ name: `${name}/${inner.name}`, version: pkg?.version || 'unknown' }) + } + continue + } + + const pkg = readJson(join(modulesDir, name, 'package.json')) + packages.push({ name, version: pkg?.version || 'unknown' }) + } + + return packages.sort((a, b) => a.name.localeCompare(b.name)) +} + +// Every Node version manager keeps installs under a versions dir; map each +// version dir to the node_modules that holds its global packages. +function nodeVersionSources() { + const home = homedir() + const env = process.env + const sources = [] + const libMods = (dir) => join(dir, 'lib', 'node_modules') + + const add = (manager, versionsDir, toModules) => { + for (const entry of safeReaddir(versionsDir)) { + if (!entry.isDirectory()) continue + sources.push({ + manager, + nodeVersion: entry.name, + modulesDir: toModules(join(versionsDir, entry.name)), + }) + } + } + + add('nvm', join(env.NVM_DIR || join(home, '.nvm'), 'versions', 'node'), libMods) + if (env.APPDATA) add('nvm-windows', join(env.APPDATA, 'nvm'), (dir) => join(dir, 'node_modules')) + + const fnmBase = + env.FNM_DIR || + (process.platform === 'darwin' + ? join(home, 'Library', 'Application Support', 'fnm') + : join(home, '.local', 'share', 'fnm')) + add('fnm', join(fnmBase, 'node-versions'), (dir) => libMods(join(dir, 'installation'))) + + add('volta', join(env.VOLTA_HOME || join(home, '.volta'), 'tools', 'image', 'node'), libMods) + add('n', join(env.N_PREFIX || '/usr/local', 'n', 'versions', 'node'), libMods) + add('asdf', join(env.ASDF_DATA_DIR || join(home, '.asdf'), 'installs', 'nodejs'), libMods) + add('mise', join(home, '.local', 'share', 'mise', 'installs', 'node'), libMods) + add('nodenv', join(env.NODENV_ROOT || join(home, '.nodenv'), 'versions'), libMods) + + return sources +} + +export function collectNodeVersions() { + return nodeVersionSources() + .map(({ manager, nodeVersion, modulesDir }) => ({ + manager, + nodeVersion, + path: modulesDir, + packages: readGlobalPackages(modulesDir), + })) + .filter((entry) => entry.packages.length > 0) + .sort( + (a, b) => a.manager.localeCompare(b.manager) || a.nodeVersion.localeCompare(b.nodeVersion) + ) +} + +function renderNodeVersions(versions) { + renderBanner() + + const totalPackages = versions.reduce((sum, v) => sum + v.packages.length, 0) + console.log( + ' ' + + chalk.dim( + `Total: ${chalk.bold.white(totalPackages)} global package(s) across ${versions.length} Node version(s)` + ) + ) + console.log() + + const table = new Table({ + head: [chalk.bold('Manager'), chalk.bold('Node'), chalk.bold('Package'), chalk.bold('Version')], + colWidths: [12, 16, 38, 16], + style: { head: [], border: [] }, + }) + + for (const version of versions) { + for (const pkg of version.packages) { + table.push([ + version.manager, + chalk.cyan(version.nodeVersion), + pkg.name, + chalk.green(pkg.version), + ]) + } + } + + console.log(table.toString()) + console.log() +} + +export async function runNodeVersions(options) { + const resolvedOptions = typeof options?.opts === 'function' ? options.opts() : options + const doJson = Boolean(resolvedOptions?.json || options?.parent?.opts?.().json) + + const versions = collectNodeVersions() + + if (doJson) { + console.log(JSON.stringify({ generatedAt: new Date().toISOString(), versions }, null, 2)) + return + } + + if (versions.length === 0) { + console.log(chalk.yellow('No managed Node.js versions with global packages found.')) + return + } + + renderNodeVersions(versions) +} From 28284394b84c7b4ba367d678e3cc942ea92780d4 Mon Sep 17 00:00:00 2001 From: MULHAM Date: Sun, 21 Jun 2026 02:14:21 +0700 Subject: [PATCH 2/4] feat: wire node-versions command into CLI --- bin/pkgmap.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bin/pkgmap.js b/bin/pkgmap.js index 68e0e93..f3f21f6 100755 --- a/bin/pkgmap.js +++ b/bin/pkgmap.js @@ -4,6 +4,7 @@ import { run } from '../src/index.js' import { runAudit } from '../src/audit.js' import { runPorts } from '../src/ports.js' import { runUpgrade } from '../src/upgrade.js' +import { runNodeVersions } from '../src/node-versions.js' import { APP_VERSION } from '../src/version.js' program @@ -41,4 +42,11 @@ program .option('--dry-run', 'print the upgrade command(s) without executing them') .action((...args) => runUpgrade(args.at(-1))) +program + .command('node-versions') + .aliases(['nodes', 'nv']) + .description('list global npm packages grouped by installed Node.js versions') + .option('-j, --json', 'print results as JSON to stdout') + .action((...args) => runNodeVersions(args.at(-1))) + program.parse() From ae2e7d827f46c67085d064d72f826644371445b0 Mon Sep 17 00:00:00 2001 From: MULHAM Date: Sun, 21 Jun 2026 02:14:56 +0700 Subject: [PATCH 3/4] test: cover readGlobalPackages scope and skip rules --- test/index.test.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/index.test.js b/test/index.test.js index a5f8391..3e1d7cd 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -7,6 +7,7 @@ import { annotatePorts, filterSuspiciousPorts, terminatePorts } from '../src/por import { parseGoBinaryMetadata } from '../src/scanners/go.js' import { parseVcpkgList } from '../src/scanners/vcpkg.js' import { buildUpgradeCommand, getUpgradePlan } from '../src/upgrade.js' +import { readGlobalPackages } from '../src/node-versions.js' test('normalizeWarning flattens mixed warning arguments', () => { const error = new Error('kaput') @@ -199,3 +200,28 @@ test('buildUpgradeCommand expands dynamic cargo package installs', () => { 'cargo install cargo-audit && cargo install bacon' ) }) + +test('readGlobalPackages expands scopes and skips non-package entries', () => { + const tree = { + '/mods': ['typescript', 'eslint', '@scope', '.bin', 'npm'], + '/mods/@scope': ['cli', '.cache'], + } + const dirent = (name) => ({ name, isDirectory: () => true }) + const readdir = (dir) => (tree[dir] || []).map(dirent) + + const versions = { + '/mods/typescript/package.json': { version: '5.4.2' }, + '/mods/@scope/cli/package.json': { version: '1.0.0' }, + } + const readJson = (path) => versions[path] || null + + assert.deepEqual(readGlobalPackages('/mods', { readdir, readJson }), [ + { name: '@scope/cli', version: '1.0.0' }, + { name: 'eslint', version: 'unknown' }, + { name: 'typescript', version: '5.4.2' }, + ]) +}) + +test('readGlobalPackages returns empty for missing or empty directories', () => { + assert.deepEqual(readGlobalPackages('/nope', { readdir: () => [], readJson: () => null }), []) +}) From 23f21ea87995bee41042f38775227985f8798a5e Mon Sep 17 00:00:00 2001 From: MULHAM Date: Sun, 21 Jun 2026 02:15:34 +0700 Subject: [PATCH 4/4] docs: document node-versions command --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 09081fd..013a2e2 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,12 @@ pkgmap upgrade --manager npm # Preview the upgrade commands without running them pkgmap upgrade --dry-run + +# List global npm packages grouped by installed Node.js version +pkgmap node-versions + +# JSON output (aliases: nodes, nv) +pkgmap nv --json ``` --- @@ -282,6 +288,17 @@ pkgmap upgrade --dry-run | `pkgmap upgrade --manager npm` | Upgrade packages only for one manager | | `pkgmap upgrade --dry-run` | Print the commands that would run without executing them | +### Node versions subcommand + +| Command | Description | +|---------|-------------| +| `pkgmap node-versions` | List global npm packages grouped by installed Node.js version | +| `pkgmap nodes` / `pkgmap nv` | Aliases for `node-versions` | +| `pkgmap node-versions --json` | Print results as JSON | + +> Detects Node.js installs from `nvm`, `nvm-windows`, `fnm`, `volta`, `n`, `asdf`, `mise`, and `nodenv`. +> Use it to track down "command not found" after switching Node versions. + > Notes: > - `pkgmap upgrade` skips managers that are not accommodated yet. > - Currently skipped / not yet accommodated: `gradle`, `maven`, `nuget`, `helm`, `go`, and `volta`.