OverviewFilesCommitsIssuesDocsReleasesPeers

pear-dep-audit / dep-audit.cjs

163 lines · javascript ref:
#!/usr/bin/env node
// dep-audit.cjs — diff a project's installed deps against a Keet AppImage
// snapshot (produced by snapshot-keet-versions.sh).
//
// Usage:
//   node dep-audit.cjs <project-dir> [--snapshot=path] [--fix] [--all]
//
// Flags:
//   --snapshot=PATH   path to a snapshot JSON
//                     (default: <pear-dep-audit>/snapshots/keet-<latest>.json)
//   --fix             also print a ready-to-paste "overrides" block
//   --all             include only-installed-here packages (default: only drift)
//
// Examples:
//   node dep-audit.cjs ../../godot/hyper-tps
//   node dep-audit.cjs ../packages/keet-daemon --fix
//   node dep-audit.cjs <project> --snapshot=snapshots/keet-4.14.2.json

const fs = require('fs')
const path = require('path')

function usage (msg) {
  if (msg) process.stderr.write('error: ' + msg + '\n')
  process.stderr.write('usage: dep-audit.cjs <project-dir> [--snapshot=PATH] [--fix] [--all]\n')
  process.exit(msg ? 1 : 0)
}

const args = process.argv.slice(2)
let projArg = null
let snapshotArg = null
let fix = false
let showAll = false
for (const a of args) {
  if (a === '--fix') fix = true
  else if (a === '--all') showAll = true
  else if (a === '--help' || a === '-h') usage()
  else if (a.startsWith('--snapshot=')) snapshotArg = a.slice('--snapshot='.length)
  else if (a.startsWith('--')) usage('unknown flag: ' + a)
  else if (!projArg) projArg = a
  else usage('extra positional: ' + a)
}
if (!projArg) usage('missing <project-dir>')

const proj = path.resolve(projArg)
const nm = path.join(proj, 'node_modules')
if (!fs.existsSync(nm)) usage('no node_modules in ' + proj + ' (run `npm install` first)')

// Resolve snapshot path: explicit flag, else <script-dir>/snapshots/keet-*.json (latest by mtime)
let snapshotPath = snapshotArg
if (!snapshotPath) {
  const snapDir = path.resolve(__dirname, 'snapshots')
  if (!fs.existsSync(snapDir)) usage('no default snapshots dir at ' + snapDir + '; pass --snapshot=PATH')
  const candidates = fs.readdirSync(snapDir)
    .filter(n => n.startsWith('keet-') && n.endsWith('.json') && n !== 'keet-unknown.json')
    .map(n => ({ name: n, mtime: fs.statSync(path.join(snapDir, n)).mtimeMs }))
    .sort((a, b) => b.mtime - a.mtime)
  if (!candidates.length) usage('no snapshot files found in ' + snapDir)
  snapshotPath = path.join(snapDir, candidates[0].name)
}
const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'))
const expected = snapshot.packages || {}

// Walk node_modules — yields the directory of every installed package.
function * walkNm (dir) {
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
    if (e.name.startsWith('.')) continue
    const p = path.join(dir, e.name)
    if (e.name.startsWith('@') && e.isDirectory()) {
      for (const sub of fs.readdirSync(p, { withFileTypes: true })) {
        if (sub.isDirectory()) yield path.join(p, sub.name)
      }
    } else if (e.isDirectory()) {
      yield p
    }
  }
}

function cmpVersion (a, b) {
  const pa = String(a).split(/[.-]/).map(s => /^\d+$/.test(s) ? Number(s) : s)
  const pb = String(b).split(/[.-]/).map(s => /^\d+$/.test(s) ? Number(s) : s)
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
    const va = pa[i] === undefined ? 0 : pa[i]
    const vb = pb[i] === undefined ? 0 : pb[i]
    if (va < vb) return -1
    if (va > vb) return 1
  }
  return 0
}

const drift = []
const onlyHere = []
const matched = []

for (const pkgDir of walkNm(nm)) {
  const pjPath = path.join(pkgDir, 'package.json')
  if (!fs.existsSync(pjPath)) continue
  let pj
  try { pj = JSON.parse(fs.readFileSync(pjPath, 'utf8')) } catch { continue }
  const name = pj.name
  const installed = pj.version
  if (!name || !installed) continue
  const wanted = expected[name]
  if (wanted === undefined) {
    onlyHere.push({ name, installed })
  } else if (wanted !== installed) {
    const cmp = cmpVersion(installed, wanted)
    drift.push({ name, installed, wanted, direction: cmp === 0 ? 'equal' : cmp > 0 ? 'ahead' : 'behind' })
  } else {
    matched.push(name)
  }
}

drift.sort((a, b) => a.name.localeCompare(b.name))
onlyHere.sort((a, b) => a.name.localeCompare(b.name))

const counts = { ahead: 0, behind: 0 }
for (const d of drift) counts[d.direction]++

const colName = Math.max(12, ...drift.map(d => d.name.length))
const colInst = Math.max(10, ...drift.map(d => d.installed.length))
const colWant = Math.max(10, ...drift.map(d => d.wanted.length))

console.log('# dep-audit: ' + proj)
console.log('  snapshot:    ' + path.relative(process.cwd(), snapshotPath))
console.log('  reference:   ' + (snapshot.appName || '?') + '@' + (snapshot.appVersion || '?'))
console.log('  matched:     ' + matched.length)
console.log('  drift:       ' + drift.length + '  (ahead=' + counts.ahead + ' behind=' + counts.behind + ')')
console.log('  only here:   ' + onlyHere.length)
console.log()

if (drift.length === 0) {
  console.log('  ✓ no drift against snapshot')
} else {
  console.log('  ' + 'package'.padEnd(colName) + '  ' + 'installed'.padEnd(colInst) + '  ' + 'snapshot'.padEnd(colWant) + '  direction')
  console.log('  ' + '-'.repeat(colName + colInst + colWant + 14))
  for (const d of drift) {
    console.log('  ' + d.name.padEnd(colName) + '  ' + d.installed.padEnd(colInst) + '  ' + d.wanted.padEnd(colWant) + '  ' + d.direction)
  }
}

if (showAll && onlyHere.length) {
  console.log()
  console.log('## only installed here (not in snapshot)')
  for (const o of onlyHere) console.log('  ' + o.name + '  ' + o.installed)
}

if (fix && drift.length) {
  console.log()
  console.log('## suggested overrides block (paste into package.json):')
  console.log('"overrides": {')
  for (let i = 0; i < drift.length; i++) {
    const d = drift[i]
    const comma = i === drift.length - 1 ? '' : ','
    console.log('  "' + d.name + '": "' + d.wanted + '"' + comma)
  }
  console.log('}')
  console.log()
  console.log('# note: caret-style direct deps in "dependencies" must also be tightened')
  console.log('#       to exact versions if any conflict with an override (npm EOVERRIDE).')
}

process.exit(drift.length > 0 ? 1 : 0)