Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

---
Expand Down Expand Up @@ -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`.
Expand Down
8 changes: 8 additions & 0 deletions bin/pkgmap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
160 changes: 160 additions & 0 deletions src/node-versions.js
Original file line number Diff line number Diff line change
@@ -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)
}
26 changes: 26 additions & 0 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 }), [])
})