#!/usr/bin/env node
// ecosystem-audit.cjs — find packages where 2+ pgh apps' latest releases
// disagree on version. Reads ~/.config/pgh/published-index.json.
//
// This is the EXACT data pgh publish enumerates per-project as "dep
// conflicts"; centralising it lets you align the ecosystem once instead
// of resolving each app's publish friction separately.
//
// Usage:
// node ecosystem-audit.cjs [--index=PATH] [--filter=REGEX] [--apps=a,b,c]
// node ecosystem-audit.cjs --snapshot=../snapshots/keet-4.14.2.json
// ^ also flags pkgs that disagree with a reference snapshot
//
// Flags:
// --index=PATH path to published-index.json
// (default: ~/.config/pgh/published-index.json)
// --filter=RE only show packages matching this regex
// --apps=a,b,c only audit these apps (comma-sep; default: all)
// --snapshot=PATH cross-check ecosystem versions against a snapshot
// --verbose include matching packages too
const fs = require('fs')
const path = require('path')
const os = require('os')
function usage (msg) {
if (msg) process.stderr.write('error: ' + msg + '\n')
process.stderr.write('usage: ecosystem-audit.cjs [--index=PATH] [--filter=RE] [--apps=a,b,c] [--snapshot=PATH] [--verbose]\n')
process.exit(msg ? 1 : 0)
}
let indexPath = path.join(os.homedir(), '.config', 'pgh', 'published-index.json')
let filterRe = null
let onlyApps = null
let snapshotPath = null
let verbose = false
for (const a of process.argv.slice(2)) {
if (a === '--help' || a === '-h') usage()
else if (a === '--verbose') verbose = true
else if (a.startsWith('--index=')) indexPath = a.slice(8)
else if (a.startsWith('--filter=')) filterRe = new RegExp(a.slice(9))
else if (a.startsWith('--apps=')) onlyApps = new Set(a.slice(7).split(',').filter(Boolean))
else if (a.startsWith('--snapshot=')) snapshotPath = a.slice(11)
else usage('unknown flag: ' + a)
}
if (!fs.existsSync(indexPath)) usage('no published-index at ' + indexPath)
const data = JSON.parse(fs.readFileSync(indexPath, 'utf8'))
const apps = data.apps || {}
const snapshot = snapshotPath ? JSON.parse(fs.readFileSync(snapshotPath, 'utf8')) : null
const snapshotPkgs = snapshot ? (snapshot.packages || {}) : null
// Use the LATEST published version of each app as its current ecosystem footprint.
const latest = {}
for (const [name, app] of Object.entries(apps)) {
if (onlyApps && !onlyApps.has(name)) continue
const versions = app.versions || []
if (!versions.length) continue
latest[name] = versions[versions.length - 1]
}
// Build pkg → { version → [appNames...] }
const matrix = {}
for (const [appName, rel] of Object.entries(latest)) {
const deps = (rel.lockfile && rel.lockfile.deps) || []
for (const dep of deps) {
if (!dep.name || !dep.version) continue
if (filterRe && !filterRe.test(dep.name)) continue
if (!matrix[dep.name]) matrix[dep.name] = {}
if (!matrix[dep.name][dep.version]) matrix[dep.name][dep.version] = []
matrix[dep.name][dep.version].push(appName)
}
}
// Partition into conflicts (>1 version) + agreements (1 version, optionally
// vs snapshot)
const conflicts = []
const snapshotMismatches = []
const matches = []
for (const [pkg, vers] of Object.entries(matrix)) {
const verKeys = Object.keys(vers)
if (verKeys.length > 1) {
conflicts.push({ pkg, vers })
} else {
const v = verKeys[0]
if (snapshotPkgs && snapshotPkgs[pkg] && snapshotPkgs[pkg] !== v) {
snapshotMismatches.push({ pkg, ecoVersion: v, snapshotVersion: snapshotPkgs[pkg], apps: vers[v] })
} else {
matches.push({ pkg, v, apps: vers[v] })
}
}
}
conflicts.sort((a, b) => a.pkg.localeCompare(b.pkg))
snapshotMismatches.sort((a, b) => a.pkg.localeCompare(b.pkg))
console.log('# ecosystem-audit: ' + indexPath)
console.log(' apps: ' + Object.keys(latest).length + ' (' + Object.keys(latest).join(', ') + ')')
console.log(' unique packages: ' + Object.keys(matrix).length)
console.log(' conflicting packages: ' + conflicts.length)
if (snapshotPath) {
console.log(' vs snapshot: ' + path.relative(process.cwd(), snapshotPath))
console.log(' snapshot mismatches: ' + snapshotMismatches.length + ' (eco agrees internally but differs from snapshot)')
}
console.log()
if (conflicts.length === 0) {
console.log(' ✓ no internal conflicts')
} else {
console.log('## conflicts (pkg disagreed across apps)')
console.log()
for (const c of conflicts) {
console.log(c.pkg + (snapshotPkgs && snapshotPkgs[c.pkg] ? ' (snapshot: ' + snapshotPkgs[c.pkg] + ')' : ''))
const rows = Object.entries(c.vers).sort()
for (const [v, appList] of rows) {
const mark = snapshotPkgs && snapshotPkgs[c.pkg] === v ? ' ←snapshot' : ''
console.log(' ' + v.padEnd(12) + ' ' + appList.join(', ') + mark)
}
console.log()
}
}
if (snapshotPath && snapshotMismatches.length) {
console.log('## ecosystem agrees but drifts from snapshot')
console.log()
for (const m of snapshotMismatches) {
console.log(' ' + m.pkg.padEnd(40) + ' eco=' + m.ecoVersion.padEnd(12) + ' snapshot=' + m.snapshotVersion + ' (' + m.apps.join(', ') + ')')
}
console.log()
}
if (verbose) {
console.log('## matching (' + matches.length + ' packages aligned)')
for (const m of matches.sort((a, b) => a.pkg.localeCompare(b.pkg))) {
console.log(' ' + m.pkg.padEnd(40) + ' ' + m.v + ' (' + m.apps.join(', ') + ')')
}
}
process.exit(conflicts.length > 0 ? 1 : 0)