#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import {
CURRENT_HIVERELAY_VERSION,
DEFAULT_DEPENDENCY_MODE,
EXPECTED_STALE_CONSUMERS,
HIVERELAY_DEPS,
checkConsumerState,
getExpectedCurrentConsumers,
normalizeConsumerScope,
normalizeDependencyMode,
scanConsumerSourceChecks,
scanCurrentConsumerLockChecks,
scanHiverelayConsumers,
scanSnapshotVersionChecks,
verifyNpmLatestDistTags
} from './audit-ecosystem-consumers.mjs'
const here = path.dirname(fileURLToPath(import.meta.url))
const hiverelayRoot = path.resolve(here, '..')
const workspaceRootDefault = path.resolve(hiverelayRoot, '..', '..')
const DEP_SECTIONS = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies'
]
const usage = `
Usage:
node scripts/sync-ecosystem-consumers.mjs [--workspace-root <path>] [--expected-version <semver>] [--dependency-mode <local|npm-latest>] [--consumer-scope <all|release>] [--check] [--dry-run] [--prepare-latest-defaults]
Updates the known direct ecosystem app consumers so their default
p2p-hiverelay* package defaults point at the npm latest dist-tag by default.
npm-latest mode first verifies the full published HiveRelay package line equals
the expected Hiverelay version, then requires npm to refresh package-lock metadata.
Use --dependency-mode local for development workspace links. Use --check in CI
to fail if a consumer would be changed. Use --prepare-latest-defaults only before
npm latest is promoted; it writes manifests/source markers to latest, skips
lockfile refresh, and leaves strict release/audit checks for the post-publish gate.
Combine --prepare-latest-defaults with --check to verify the staged latest
defaults without writing app files or refreshing lockfiles.
`
if (isMain()) main()
export function syncEcosystemConsumers (opts = {}) {
const workspaceRoot = path.resolve(opts.workspaceRoot || workspaceRootDefault)
const expectedVersion = opts.expectedVersion || CURRENT_HIVERELAY_VERSION
const dependencyMode = normalizeDependencyMode(opts.dependencyMode || DEFAULT_DEPENDENCY_MODE)
const consumerScope = normalizeConsumerScope(opts.consumerScope || 'all')
const expectedCurrent = opts.expectedCurrent || getExpectedCurrentConsumers({ dependencyMode, consumerScope })
const expectedClassifiedCurrent = opts.expectedClassifiedCurrent || getExpectedCurrentConsumers({ dependencyMode, consumerScope: 'all' })
const dryRun = Boolean(opts.dryRun || opts.check)
const prepareLatestDefaults = Boolean(opts.prepareLatestDefaults)
const changes = []
const errors = []
const warnings = []
if (prepareLatestDefaults && dependencyMode !== 'npm-latest') {
errors.push('--prepare-latest-defaults requires --dependency-mode npm-latest')
}
if (errors.length > 0) {
return {
ok: false,
check: Boolean(opts.check),
dryRun,
prepareLatestDefaults,
dependencyMode,
consumerScope,
workspaceRoot,
expectedVersion,
changes,
errors,
warnings,
summary: null
}
}
if (dependencyMode === 'npm-latest') {
const latestCheck = verifyNpmLatestDistTags({
expectedCurrent,
expectedVersion,
npmLatestVersions: opts.npmLatestVersions
})
warnings.push(...latestCheck.warnings)
if (latestCheck.errors.length > 0 && !prepareLatestDefaults) {
errors.push(...latestCheck.errors)
changes.push(...previewNpmLatestConsumerChanges({
workspaceRoot,
expectedCurrent,
expectedVersion
}))
if (changes.length > 0) {
warnings.push('npm latest verification failed; pending manifest/source changes are shown for release planning only and no app files were written')
}
return {
ok: false,
check: Boolean(opts.check),
dryRun,
dependencyMode,
consumerScope,
workspaceRoot,
expectedVersion,
changes,
errors,
warnings,
summary: null
}
}
if (latestCheck.errors.length > 0) {
for (const error of latestCheck.errors) warnings.push(`prepare-latest-defaults skipped registry proof: ${error}`)
warnings.push('prepare-latest-defaults wrote npm latest app defaults without lockfile refresh; rerun strict ecosystem sync/audit after npm latest is promoted')
}
}
for (const consumer of expectedCurrent) {
try {
changes.push(...syncConsumerManifest({
workspaceRoot,
consumer,
dryRun
}))
if (dependencyMode === 'local') {
changes.push(...syncConsumerLockfile({
workspaceRoot,
consumer,
expectedVersion,
dryRun
}))
}
changes.push(...syncConsumerSourceMarkers({
workspaceRoot,
consumer,
expectedVersion,
dryRun
}))
} catch (err) {
errors.push(`${consumer.path}: ${err.message}`)
}
}
if (dependencyMode === 'npm-latest' && !dryRun && !prepareLatestDefaults) {
try {
changes.push(...refreshNpmLatestLockfiles({
workspaceRoot,
expectedCurrent,
skipNpmInstall: opts.skipNpmInstall
}))
} catch (err) {
errors.push(err.message)
}
}
const rows = scanHiverelayConsumers({ workspaceRoot })
const sourceChecks = scanConsumerSourceChecks({
workspaceRoot,
expectedVersion,
expectedCurrent,
expectedStale: EXPECTED_STALE_CONSUMERS
})
const lockChecks = scanCurrentConsumerLockChecks({
workspaceRoot,
expectedVersion,
expectedCurrent
})
const snapshotChecks = opts.snapshotChecks === false
? []
: scanSnapshotVersionChecks({
workspaceRoot,
expectedVersion,
snapshotRoots: opts.snapshotRoots
})
const summary = checkConsumerState(rows, {
expectedVersion,
expectedCurrent,
expectedClassifiedCurrent,
expectedStale: EXPECTED_STALE_CONSUMERS,
sourceChecks,
lockChecks: prepareLatestDefaults ? [] : lockChecks,
snapshotChecks
})
if (opts.check && changes.length > 0) {
errors.push(`${changes.length} ecosystem consumer file(s) need default-version sync`)
}
errors.push(...summary.errors)
warnings.push(...summary.warnings)
return {
ok: errors.length === 0,
check: Boolean(opts.check),
dryRun,
prepareLatestDefaults,
dependencyMode,
consumerScope,
workspaceRoot,
expectedVersion,
changes,
errors,
warnings,
summary
}
}
function previewNpmLatestConsumerChanges ({ workspaceRoot, expectedCurrent, expectedVersion }) {
const changes = []
for (const consumer of expectedCurrent) {
const packageFile = path.join(workspaceRoot, consumer.path)
if (!fs.existsSync(packageFile)) continue
try {
changes.push(...syncConsumerManifest({
workspaceRoot,
consumer,
dryRun: true
}))
changes.push(...syncConsumerSourceMarkers({
workspaceRoot,
consumer,
expectedVersion,
dryRun: true,
skipMissing: true
}))
} catch {
// The blocking error is the stale/unverified npm latest tag. Keep this
// preview best-effort so partial fixtures and sparse worktrees can still
// report the registry gate cleanly.
}
}
return changes
}
function syncConsumerManifest ({ workspaceRoot, consumer, dryRun }) {
const file = path.join(workspaceRoot, consumer.path)
const pkg = readJson(file)
const changes = []
const expectedDeps = new Set(Object.keys(consumer.deps || {}))
for (const [dep, value] of Object.entries(consumer.deps).sort(([a], [b]) => a.localeCompare(b))) {
const preferredSection = findDepSection(pkg, dep) || 'dependencies'
if (setDependency(pkg, dep, value, preferredSection)) {
changes.push(`${slash(path.relative(workspaceRoot, file))}: ${dep} -> ${value}`)
}
}
if (expectedDeps.size === 0) {
for (const section of DEP_SECTIONS) {
const deps = pkg[section]
if (!deps || typeof deps !== 'object' || Array.isArray(deps)) continue
for (const dep of HIVERELAY_DEPS) {
if (!Object.hasOwn(deps, dep)) continue
delete deps[dep]
changes.push(`${slash(path.relative(workspaceRoot, file))}: ${dep} removed`)
}
if (Object.keys(deps).length === 0) delete pkg[section]
}
}
if (changes.length > 0 && !dryRun) writeJson(file, pkg)
return changes
}
function syncConsumerLockfile ({ workspaceRoot, consumer, expectedVersion, dryRun }) {
if (Object.keys(consumer.deps || {}).length === 0) return []
const packageFile = path.join(workspaceRoot, consumer.path)
const packageDir = path.dirname(packageFile)
const lockFile = findNearestLockfile(packageDir, workspaceRoot)
if (!lockFile) throw new Error('missing nearest package-lock.json')
const pkg = readJson(packageFile)
const lock = readJson(lockFile)
const lockRoot = path.dirname(lockFile)
const relLockFile = slash(path.relative(workspaceRoot, lockFile))
const packageKey = slash(path.relative(lockRoot, packageDir))
const packageEntryKey = packageKey === '' ? '' : packageKey
const packageEntry = lock.packages?.[packageEntryKey]
if (!packageEntry) throw new Error(`${relLockFile} is missing lock entry ${JSON.stringify(packageEntryKey)}`)
const changes = []
for (const [dep, value] of Object.entries(consumer.deps).sort(([a], [b]) => a.localeCompare(b))) {
const preferredSection = findDepSection(pkg, dep) || findDepSection(packageEntry, dep) || 'dependencies'
if (setDependency(packageEntry, dep, value, preferredSection)) {
changes.push(`${relLockFile}: ${packageEntryKey || 'root'} ${dep} -> ${value}`)
}
if (!value.startsWith('file:')) continue
const target = path.resolve(packageDir, value.slice('file:'.length))
const targetKey = slash(path.relative(lockRoot, target))
const targetEntry = lock.packages?.[targetKey]
if (!targetEntry) throw new Error(`${relLockFile} is missing linked package metadata for ${dep} at ${targetKey}`)
if (targetEntry.name !== dep) {
targetEntry.name = dep
changes.push(`${relLockFile}: ${targetKey} name -> ${dep}`)
}
if (targetEntry.version !== expectedVersion) {
targetEntry.version = expectedVersion
changes.push(`${relLockFile}: ${targetKey} version -> ${expectedVersion}`)
}
if (targetEntry.dependencies?.['p2p-hiverelay']) {
const expectedRange = `^${expectedVersion}`
if (targetEntry.dependencies['p2p-hiverelay'] !== expectedRange) {
targetEntry.dependencies['p2p-hiverelay'] = expectedRange
changes.push(`${relLockFile}: ${targetKey} p2p-hiverelay -> ${expectedRange}`)
}
}
}
if (changes.length > 0 && !dryRun) writeJson(lockFile, lock)
return changes
}
function syncConsumerSourceMarkers ({ workspaceRoot, consumer, expectedVersion, dryRun, skipMissing = false }) {
const changes = []
for (const spec of consumer.sourceChecks || []) {
const nextTerm = renderSourceTerm(spec, expectedVersion)
if (!nextTerm) continue
const file = path.join(workspaceRoot, spec.file)
if (skipMissing && !fs.existsSync(file)) continue
const text = fs.readFileSync(file, 'utf8')
const next = replaceSourceMarker(text, spec, expectedVersion, nextTerm)
if (next === text) continue
const rel = slash(path.relative(workspaceRoot, file))
changes.push(`${rel}: ${spec.label} -> ${expectedVersion}`)
if (!dryRun) fs.writeFileSync(file, next)
}
return changes
}
function renderSourceTerm (spec, expectedVersion) {
if (typeof spec.termTemplate === 'string') return spec.termTemplate.replaceAll('{version}', expectedVersion)
if (typeof spec.term === 'string') return spec.term
return null
}
function replaceSourceMarker (text, spec, expectedVersion, nextTerm) {
let next = text
const templates = uniqueStrings([
spec.termTemplate,
...(spec.replaceTermTemplates || [])
])
for (const template of templates) {
next = next.replace(termTemplateRegex(template), nextTerm)
}
const terms = uniqueStrings([
spec.term,
...(spec.replaceTerms || [])
])
for (const term of terms) {
const rendered = term.replaceAll('{version}', expectedVersion)
next = next.split(rendered).join(nextTerm)
}
return next
}
function refreshNpmLatestLockfiles ({ workspaceRoot, expectedCurrent, skipNpmInstall }) {
const changes = []
const roots = collectConsumerLockRoots({ workspaceRoot, expectedCurrent })
for (const root of roots) {
const rel = slash(path.relative(workspaceRoot, root))
if (skipNpmInstall) {
changes.push(`${rel || '.'}/package-lock.json: npm package-lock refresh skipped by test harness`)
continue
}
try {
execFileSync('npm', ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--fund=false'], {
cwd: root,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120000
})
changes.push(`${rel || '.'}/package-lock.json: refreshed from npm latest`)
} catch (err) {
throw new Error(`${rel || '.'}: npm package-lock refresh failed: ${stderrMessage(err) || err.message}`)
}
}
return changes
}
function collectConsumerLockRoots ({ workspaceRoot, expectedCurrent }) {
const roots = new Set()
for (const consumer of expectedCurrent) {
if (Object.keys(consumer.deps || {}).length === 0) continue
const packageFile = path.join(workspaceRoot, consumer.path)
const packageDir = path.dirname(packageFile)
const lockFile = findNearestLockfile(packageDir, workspaceRoot)
if (!lockFile) throw new Error(`${consumer.path}: missing nearest package-lock.json`)
roots.add(path.dirname(lockFile))
}
return Array.from(roots).sort()
}
function stderrMessage (err) {
return Buffer.isBuffer(err?.stderr) ? err.stderr.toString().trim() : String(err?.stderr || '').trim()
}
function termTemplateRegex (template) {
const semver = String.raw`v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?`
const parts = template.split('{version}').map(escapeRegExp)
return new RegExp(parts.join(semver), 'g')
}
function setDependency (target, dep, value, preferredSection) {
const section = findDepSection(target, dep) || preferredSection
let changed = false
if (!target[section] || typeof target[section] !== 'object' || Array.isArray(target[section])) {
target[section] = {}
changed = true
}
if (target[section][dep] !== value) {
target[section][dep] = value
changed = true
}
for (const other of DEP_SECTIONS) {
if (other === section) continue
if (!target[other] || typeof target[other] !== 'object' || Array.isArray(target[other])) continue
if (Object.hasOwn(target[other], dep)) {
delete target[other][dep]
changed = true
}
}
return changed
}
function findDepSection (target, dep) {
for (const section of DEP_SECTIONS) {
const deps = target?.[section]
if (deps && typeof deps === 'object' && !Array.isArray(deps) && typeof deps[dep] === 'string') {
return section
}
}
return null
}
function findNearestLockfile (startDir, stopDir) {
let dir = path.resolve(startDir)
const stop = path.resolve(stopDir)
while (dir.startsWith(stop)) {
const candidate = path.join(dir, 'package-lock.json')
if (fs.existsSync(candidate)) return candidate
if (dir === stop) break
const parent = path.dirname(dir)
if (parent === dir) break
dir = parent
}
return null
}
function readJson (file) {
return JSON.parse(fs.readFileSync(file, 'utf8'))
}
function writeJson (file, body) {
fs.writeFileSync(file, JSON.stringify(body, null, 2) + '\n')
}
function slash (value) {
return value.split(path.sep).join('/')
}
function escapeRegExp (value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function uniqueStrings (values) {
return Array.from(new Set(values.filter(value => typeof value === 'string' && value.length > 0)))
}
function parseArgs (argv) {
const out = {}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--help' || arg === '-h') {
console.log(usage.trim())
process.exit(0)
}
if (arg === '--check') {
out.check = true
continue
}
if (arg === '--dry-run') {
out.dryRun = true
continue
}
if (arg === '--prepare-latest-defaults') {
out.prepareLatestDefaults = true
out.skipNpmInstall = true
continue
}
if (arg === '--workspace-root') {
out.workspaceRoot = readValue(argv, ++i, arg)
continue
}
if (arg === '--expected-version') {
out.expectedVersion = readValue(argv, ++i, arg)
continue
}
if (arg === '--dependency-mode') {
out.dependencyMode = readValue(argv, ++i, arg)
continue
}
if (arg === '--consumer-scope') {
out.consumerScope = readValue(argv, ++i, arg)
continue
}
if (arg === '--npm-latest') {
out.dependencyMode = 'npm-latest'
continue
}
throw new Error(`Unknown argument: ${arg}`)
}
return out
}
function readValue (argv, index, flag) {
const value = argv[index]
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${flag}`)
return value
}
function main () {
let args
try {
args = parseArgs(process.argv.slice(2))
} catch (err) {
console.error(err.message)
process.exit(1)
}
const result = syncEcosystemConsumers(args)
const mode = result.check ? 'check' : result.dryRun ? 'dry run' : result.prepareLatestDefaults ? 'prepare latest defaults' : 'sync'
console.log(`HiveRelay ecosystem consumer ${mode} (target ${result.expectedVersion}, mode ${result.dependencyMode}, scope ${result.consumerScope})`)
if (result.changes.length === 0) {
console.log('- no consumer package or lockfile changes needed')
} else {
for (const change of result.changes) console.log(`- ${change}`)
}
for (const warning of result.warnings) console.warn(`WARN ${warning}`)
for (const error of result.errors) console.error(`ERROR ${error}`)
if (!result.ok) process.exit(1)
}
function isMain () {
return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
}