#!/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)