OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-sidecar / scripts / build-bootstrap-bare.js

608 lines · javascript ref:
#!/usr/bin/env node
/**
 * build-bootstrap-bare — package pgh into a per-arch bare-runtime tarball.
 *
 * Produces `pgh-bootstrap-bare-<os>-<arch>.tar.gz` from the pgh package,
 * including:
 *   - pgh's own code + bin scripts (entry renamed to bin/<name>.bare.js)
 *   - bare-* runtime libs (NOT pruned — these are load-bearing under bare)
 *   - the bare binary for the target arch (from bare-runtime-<os>-<arch>)
 *   - a shell launcher at bin/<name> that runs:  bundled-bare <entry>
 *
 * The launcher pattern means the install.sh script needs no changes —
 * it still symlinks ~/.local/bin/<name> -> <bootstrap>/bin/<name>, and
 * <name> is just a shell wrapper now.
 *
 * Counterpart to build-bootstrap.js (which targets node). The two scripts
 * are separate so neither risks regressing the other.
 *
 * Usage:
 *   node scripts/build-bootstrap-bare.js                # infers os+arch
 *   node scripts/build-bootstrap-bare.js --os linux --arch x64
 *   node scripts/build-bootstrap-bare.js --out /tmp/out # override output
 */

import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import os from 'os'
import { fileURLToPath } from 'url'
import { pruneNestedSmart } from './lib/prune-nested.js'

const HERE = path.dirname(fileURLToPath(import.meta.url))
// --root lets `pgh publish` build ANY app's bootstrap tarball, not just pgh's
// own. Default = the pgh package this script ships in (HERE/..). getFlag is a
// hoisted function declaration, so it is callable here.
const ROOT = path.resolve(getFlag('root', path.resolve(HERE, '..')))

// ── Args ──────────────────────────────────────────────────────

function getFlag (name, def) {
  const i = process.argv.indexOf('--' + name)
  if (i === -1 || !process.argv[i + 1]) return def
  return process.argv[i + 1]
}

function archLabel (platform, arch) {
  const osMap = { linux: 'linux', darwin: 'darwin', win32: 'win32' }
  const archMap = { x64: 'x64', arm64: 'arm64' }
  return (osMap[platform] || platform) + '-' + (archMap[arch] || arch)
}

const KNOWN_FLAGS = new Set(['root', 'os', 'arch', 'out', 'stage-out', 'extra-arch'])
for (let i = 2; i < process.argv.length; i++) {
  const a = process.argv[i]
  if (!a.startsWith('--')) continue
  const name = a.slice(2).split('=')[0]
  if (!KNOWN_FLAGS.has(name)) {
    // Loud on purpose. These builders are COPIED per bootstrap app, so a stale
    // copy silently ignoring a new flag yields a confidently wrong artifact —
    // exactly how a --extra-arch build produced a single-arch tree that still
    // reported success.
    console.error('error: unknown flag ' + a + ' — this build-bootstrap-bare.js is probably stale.')
    console.error('  Known flags: --' + [...KNOWN_FLAGS].join(' --'))
    process.exit(1)
  }
}

const OS = getFlag('os', os.platform())
const ARCH = getFlag('arch', os.arch())
const LABEL = archLabel(OS, ARCH)
const OUT_DIR = path.resolve(getFlag('out', path.join(ROOT, 'dist')))
const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
const VERSION = PKG.version
const BIN_NAME = (PKG.pgh && PKG.pgh.bin) || PKG.name
const ENTRY = (PKG.pgh && PKG.pgh.entrypoint) || (PKG.bin && PKG.bin[BIN_NAME]) || 'index.js'

// Tarball naming includes the package name so multiple apps' bootstrap
// tarballs can coexist in one dist/ or bootstrap-served dir.
const PKG_SLUG = PKG.name.replace(/[^a-z0-9-]/gi, '-')
const TARBALL_NAME = `${PKG_SLUG}-bootstrap-bare-${LABEL}.tar.gz`
const STAGE_NAME = `${PKG_SLUG}-bootstrap-bare`

console.log('Building pgh bare-runtime bootstrap tarball')
console.log('  package:    ' + PKG.name + '@' + VERSION)
console.log('  target:     ' + LABEL)
console.log('  bin name:   ' + BIN_NAME)
console.log('  entry:      ' + ENTRY)
console.log('  out:        ' + path.join(OUT_DIR, TARBALL_NAME))

// ── Verify bare binary exists for target arch ──

// Additional arches staged under by-arch/<label>/runtime/bare. The install
// side already prefers by-arch/<host>/runtime/bare (lib/runtime-factory.js) and
// skips foreign subtrees when materialising (lib/store.js), so a multi-arch
// tree costs a remote host nothing to install — but WITHOUT it an x64 publisher
// can only ever produce x64-runnable installs, because both the runtime binary
// and the native prebuilds are pruned to the build host. That is why an aarch64
// device could fetch pgh-sidecar and still not run it.
const EXTRA_ARCHES = (getFlag('extra-arch', '') || '')
  .split(',').map((a) => a.trim()).filter(Boolean)
const EXTRA_LABELS = EXTRA_ARCHES.map((a) => archLabel(OS, a)).filter((l) => l !== LABEL)
const ALL_LABELS = [LABEL, ...EXTRA_LABELS]

const BARE_ARCH_PKG = `bare-runtime-${LABEL}`
const BARE_BIN_SRC = path.join(ROOT, 'node_modules', BARE_ARCH_PKG, 'bin', 'bare')
if (!fs.existsSync(BARE_BIN_SRC)) {
  console.error(`error: ${BARE_BIN_SRC} not found.`)
  console.error(`  Install the per-arch dep first:`)
  console.error(`    npm install --save ${BARE_ARCH_PKG}`)
  console.error(`  Or (preferred) install bare-runtime which pulls`)
  console.error(`  optionalDependencies for the host arch:`)
  console.error(`    npm install --save bare-runtime`)
  process.exit(1)
}
const BARE_BIN_SIZE = fs.statSync(BARE_BIN_SRC).size
console.log('  bare bin:   ' + BARE_BIN_SRC + ' (' + fmtBytes(BARE_BIN_SIZE) + ')')

// Known-bad transitive deps to strip from the staged copy.
// Some upstream packages list test-only helpers in their PROD deps,
// pulling in older versions of singletons that cause skew. We strip
// these from the bundle at stage time. Each entry should have an
// upstream issue filed so the list can shrink.
//   - @hyperswarm/testnet: listed as prod dep of hyper-overlay,
//     pulls hyperdht 6.31 → udx-native 1.19.2 (we want 1.20.3).
const FORCE_STRIP = new Set([
  '@hyperswarm/testnet'
])

// ── Dep-closure sanity check (deferred — runs against STAGE post-prune) ──

const INVARIANT_SINGLETONS = ['udx-native', 'hypercore', 'hyperswarm', 'corestore']

// ── Stage ──

fs.mkdirSync(OUT_DIR, { recursive: true })
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-bootstrap-bare-'))
const stageRoot = path.join(stage, STAGE_NAME)
fs.mkdirSync(stageRoot)

// 'scripts' MUST ship in the bundle: a bootstrap app is re-published via the
// INSTALLED pgh, whose publish runs scripts/build-bootstrap-bare.js. Omitting
// it left the installed tree with no build tool, so `pgh publish` fell back to
// publishing raw source for bootstrap apps. Shipping scripts/ makes the
// installed `pgh publish` work normally (no source checkout needed).
const INCLUDE = ['bin', 'lib', 'scripts', 'index.js']

// Dev cruft to never include (at any depth). node_modules is NOT here
// (see COPY_SKIP_TOP below) because file:dep sources need their nested
// node_modules to bundle their transitive deps.
const COPY_SKIP = new Set([
  '.git', '.pear-git', '.github', '.gitattributes',
  '.gitignore', '.npmrc', '.npmignore', 'test', 'tests', '__tests__',
  'docs', 'doc', 'examples', 'example', 'bench', 'benchmarks',
  'coverage', '.nyc_output', '.eslintrc', '.prettierrc',
  '.prettierignore', '.vscode', '.idea', 'CHANGELOG.md', 'HISTORY.md'
])

// COPY_SKIP_ALWAYS applies at every depth (dev cruft, vcs metadata).
// COPY_SKIP_TOP applies only at the top of the staging walk — it
// includes 'node_modules' so we don't double-process it (we
// special-case copy + prune node_modules afterwards) but DOES NOT
// suppress nested node_modules inside file:dep sources. Without this
// split, file:dep packages that declare their own transitive deps
// (like pgh's autobase under pgh-bare-experiment/node_modules) get
// silently stripped → MODULE_NOT_FOUND at boot on the install target.
const COPY_SKIP_TOP = new Set([...COPY_SKIP, 'node_modules'])

function copyRecursive (src, dest, topLevel = false) {
  const stat = fs.lstatSync(src)
  if (stat.isDirectory()) {
    fs.mkdirSync(dest, { recursive: true })
    const skip = topLevel ? COPY_SKIP_TOP : COPY_SKIP
    for (const name of fs.readdirSync(src)) {
      if (skip.has(name)) continue
      copyRecursive(path.join(src, name), path.join(dest, name))
    }
  } else if (stat.isSymbolicLink()) {
    const real = fs.realpathSync(src)
    // After dereferencing, we're INSIDE a dep — node_modules there
    // are transitive deps we MUST keep. So always recurse with
    // topLevel=false (where COPY_SKIP doesn't include node_modules).
    copyRecursive(real, dest, false)
  } else {
    fs.mkdirSync(path.dirname(dest), { recursive: true })
    fs.copyFileSync(src, dest)
    try { fs.chmodSync(dest, stat.mode & 0o777) } catch {}
  }
}

for (const rel of INCLUDE) {
  const src = path.join(ROOT, rel)
  if (!fs.existsSync(src)) continue
  copyRecursive(src, path.join(stageRoot, rel), true)
  console.log('  + ' + rel)
}

// node_modules — copy then prune. We do NOT prune bare-* (they ARE the
// runtime libs under bare). We DO prune nested node_modules and
// non-target prebuilds.
if (!fs.existsSync(path.join(ROOT, 'node_modules'))) {
  console.error('  node_modules missing — run `npm install` first')
  process.exit(1)
}
// We pass topLevel=false here because INSIDE node_modules we want to
// preserve each package's own nested node_modules (file:dep transitives).
copyRecursive(path.join(ROOT, 'node_modules'), path.join(stageRoot, 'node_modules'), false)
const raw = du(path.join(stageRoot, 'node_modules'))
console.log('  + node_modules (raw: ' + fmtBytes(raw) + ')')

// Prune dev-only deps from the staged copy so the bundle is "production".
// We don't `npm prune --production` on the source — keeping the dev tree
// intact for tests + the test:bare runner. We just strip from the stage.
pruneDevDeps(path.join(stageRoot, 'node_modules'))
const afterDev = du(path.join(stageRoot, 'node_modules'))
console.log('    after dev-dep prune:             ' + fmtBytes(afterDev))

// Prune known-bad transitive deps (see FORCE_STRIP set + comment above).
pruneForceStrip(path.join(stageRoot, 'node_modules'))
const afterForce = du(path.join(stageRoot, 'node_modules'))
console.log('    after force-strip prune:         ' + fmtBytes(afterForce))

// Smart nested-node_modules prune.
// Naive prune (delete every node_modules/<X>/node_modules/) strips
// file:dep transitives that npm intentionally nested because the host
// doesn't declare them at the top — broke pgh-sidecar's autobase.
// Naive keep (don't prune at all) leaves duplicates that triple the
// bundle size AND break the single-version invariant for udx-native etc.
// Smart prune deletes a nested package ONLY when same-name + same-version
// exists at the top level (true npm-hoist duplicate). Keeps nested when
// it's the only copy OR a version-conflict resolution.
const nestedStats = pruneNestedSmart(path.join(stageRoot, 'node_modules'))
console.log('    smart-nested: removed ' + nestedStats.dupsRemoved + ' hoist-dup(s), kept ' + nestedStats.nestedKept + ' transitive(s)')
const afterNested = du(path.join(stageRoot, 'node_modules'))
console.log('    after smart-nested-dup prune:    ' + fmtBytes(afterNested))

prunePrebuilds(path.join(stageRoot, 'node_modules'), ALL_LABELS)
const afterPrebuilds = du(path.join(stageRoot, 'node_modules'))
console.log('    after prebuilds prune:           ' + fmtBytes(afterPrebuilds))

// Prune non-target bare-runtime per-arch packages. We only need the
// target's binary; the others (bare-runtime-darwin-arm64, etc.) are
// pulled by npm as optional deps but useless on this bundle.
pruneOtherBareArches(path.join(stageRoot, 'node_modules'), ALL_LABELS)
const afterArches = du(path.join(stageRoot, 'node_modules'))
console.log('    after bare-runtime arch prune:   ' + fmtBytes(afterArches))

// Singleton check runs against the staged tree (post-prune).
checkStagedSingletons(path.join(stageRoot, 'node_modules'))

// ── Bundle the bare binary outside node_modules ──
// Keep it at <bootstrap>/runtime/bare for predictability — independent
// of the node_modules tree, so the launcher path doesn't break if
// node_modules is later reshuffled.
const runtimeDir = path.join(stageRoot, 'runtime')
fs.mkdirSync(runtimeDir, { recursive: true })
const bareBinDest = path.join(runtimeDir, 'bare')
fs.copyFileSync(BARE_BIN_SRC, bareBinDest)
fs.chmodSync(bareBinDest, 0o755)
console.log('  + runtime/bare (' + fmtBytes(fs.statSync(bareBinDest).size) + ')')

// Extra arches live at by-arch/<label>/runtime/bare. runtime/bare stays the
// BUILD host's copy so existing launchers, which hardcode that path, keep
// working against an older tree.
for (const label of EXTRA_LABELS) {
  const src = path.join(ROOT, 'node_modules', 'bare-runtime-' + label, 'bin', 'bare')
  if (!fs.existsSync(src)) {
    // Hard failure: a silently-missing arch produces a by-arch/ tree that
    // LOOKS multi-arch and still cannot run on that host.
    console.error('error: --extra-arch ' + label + ' requested but ' + src + ' not found.')
    console.error('  Vendor it first:  npm pack bare-runtime-' + label + '@<pinned>')
    console.error('  and extract into node_modules/bare-runtime-' + label + '/')
    process.exit(1)
  }
  const dir = path.join(stageRoot, 'by-arch', label, 'runtime')
  fs.mkdirSync(dir, { recursive: true })
  const dest = path.join(dir, 'bare')
  fs.copyFileSync(src, dest)
  fs.chmodSync(dest, 0o755)
  console.log('  + by-arch/' + label + '/runtime/bare (' + fmtBytes(fs.statSync(dest).size) + ')')
}

// ── Shell launcher at bin/<BIN_NAME> + entry preserved as .bare.js ──
//
// install.sh symlinks ~/.local/bin/<BIN_NAME> -> <bootstrap>/bin/<BIN_NAME>,
// so that's where the launcher must end up.
//
// The ENTRY (from pgh.entrypoint or bin map) is the actual JS file —
// may be `bin/pgh`, `server.js`, etc. We move it to `<bare-name>.bare.js`
// (same directory as the entry) and have the launcher exec the bundled
// bare on that path.
//
// Note: BIN_NAME and ENTRY base name may differ — e.g. ENTRY=bin/pgh
// with BIN_NAME=pgh-experimental. The launcher's name comes from
// BIN_NAME; the source entry path comes from ENTRY. Don't conflate them.
const entryRel = ENTRY.startsWith('bin/') ? path.basename(ENTRY) : ENTRY
const entryDir = path.dirname(ENTRY) // 'bin' for bin/pgh, '.' for server.js
const entrySrc = path.join(stageRoot, ENTRY)
const entryDest = path.join(stageRoot, entryDir, entryRel + '.bare.js')
const launcherPath = path.join(stageRoot, 'bin', BIN_NAME)

if (!fs.existsSync(entrySrc)) {
  console.error('  expected entry not staged: ' + entrySrc)
  process.exit(1)
}

// Move the original entry to <entry>.bare.js in the same dir.
fs.renameSync(entrySrc, entryDest)

// Ensure bin/ exists (some packages have entry at root e.g. server.js).
fs.mkdirSync(path.dirname(launcherPath), { recursive: true })

// Launcher resolves its own location → bootstrap root → bundled bare +
// the renamed entry's absolute path within the bundle.
const entryFromRoot = path.relative(stageRoot, entryDest) // e.g. 'bin/pgh.bare.js' or 'server.js.bare.js'
const launcherScript = `#!/bin/sh
# pgh-bootstrap-bare launcher — runs the bundled bare on the entry script.
# Resolves its own location via readlink so the symlink in ~/.local/bin/
# (or wherever install.sh placed it) still finds the bundled runtime.
HERE="$(cd "$(dirname "$(readlink -f "$0" || echo "$0")")" >/dev/null 2>&1 && pwd)"
ROOT="$(cd "$HERE/.." >/dev/null 2>&1 && pwd)"
# Prefer this host's arch when the tree carries several; fall back to the
# build host's copy for single-arch trees.
HOSTLABEL="$(uname -s | tr 'A-Z' 'a-z')-$(uname -m | sed 's/x86_64/x64/; s/aarch64/arm64/')"
BARE="$ROOT/by-arch/$HOSTLABEL/runtime/bare"
[ -x "$BARE" ] || BARE="$ROOT/runtime/bare"
exec "$BARE" "$ROOT/${entryFromRoot}" "$@"
`
fs.writeFileSync(launcherPath, launcherScript)
fs.chmodSync(launcherPath, 0o755)
console.log('  + bin/' + BIN_NAME + ' (shell launcher → runtime/bare → ' + entryFromRoot + ')')

// Trimmed package.json — mark the runtime explicitly so install.sh
// or any future tooling can dispatch on it.
const bootPkg = {
  // Keep the REAL package name (not STAGE_NAME). STAGE_NAME is only the tar
  // top-dir; the package identity must stay `<name>` so `pgh publish` registers
  // the self-contained tree under the app's real name and `pgh app update
  // <name>` resolves it. (The bootstrap installer adopts with an explicit
  // --name, so it never depended on this field.)
  name: PKG.name,
  version: PKG.version,
  type: PKG.type,
  main: PKG.main,
  bin: PKG.bin,
  exports: PKG.exports,
  imports: PKG.imports,
  dependencies: PKG.dependencies,
  // Repoint pgh.entrypoint at the RENAMED bare entry. The source declares
  // e.g. `bin/pgh`, but we moved it to `<entry>.bare.js` above, so the
  // verbatim source entrypoint no longer exists in the bundle. `pgh app
  // adopt`/`app start` run `bare <pgh.entrypoint>`, so it must name the file
  // that's actually here (the launcher is only for direct ~/.local/bin use).
  // runtime MUST be 'bare' for the bootstrap bundle: the entry is the bundled
  // bare build (#node:* → bare module map) and `pgh app adopt/start` dispatch
  // on pgh.runtime (supervisor spawns RUNTIME entrypoint). The source declares
  // 'node' for regular installs; here we override so adopt/start launch with
  // the bundled bare instead of system node (which crashes on the #node: map).
  pgh: { ...PKG.pgh, entrypoint: entryFromRoot, runtime: 'bare' },
  pghBootstrap: {
    os: OS,
    arch: ARCH,
    label: LABEL,
    runtime: 'bare',
    bareEntry: entryFromRoot,
    // NOTE: deliberately NO builtAt timestamp — it would bake wall-clock time
    // into the staged package.json, making every build of the same version
    // produce a different root tree hash (spurious BUILD_SKEW on re-publish).
    // Keep the staged tree byte-reproducible for a given source version.
    sourceVersion: PKG.version
  }
}
fs.writeFileSync(path.join(stageRoot, 'package.json'), JSON.stringify(bootPkg, null, 2))
console.log('  + package.json (trimmed)')

// ── Tar it ──

const outFile = path.join(OUT_DIR, TARBALL_NAME)
try {
  execSync(`tar -czf "${outFile}" -C "${stage}" ${STAGE_NAME}`, { stdio: 'inherit' })
} catch (err) {
  console.error('tar failed: ' + err.message)
  process.exit(1)
}

const size = fs.statSync(outFile).size
console.log('\nDone: ' + outFile + ' (' + fmtBytes(size) + ')')

// --stage-out <dir>: leave the staged self-contained dir at <dir> so `pgh
// publish` can register it AS the registry tree. One stage → tarball + tree,
// so the curl-install artifact and the `pgh app install` artifact can never
// diverge (the root cause of the two-shapes-of-one-version bug).
const STAGE_OUT = getFlag('stage-out', null)
if (STAGE_OUT) {
  const dest = path.resolve(STAGE_OUT.replace(/^~(?=$|\/)/, os.homedir()))
  fs.rmSync(dest, { recursive: true, force: true })
  fs.mkdirSync(path.dirname(dest), { recursive: true })
  try { fs.renameSync(stageRoot, dest) } catch { fs.cpSync(stageRoot, dest, { recursive: true }) }
  fs.rmSync(stage, { recursive: true, force: true })
  console.log('  stage kept at ' + dest)
} else {
  fs.rmSync(stage, { recursive: true, force: true })
}

try {
  const sum = execSync(`sha256sum "${outFile}" 2>/dev/null || shasum -a 256 "${outFile}"`, { encoding: 'utf8' }).trim()
  fs.writeFileSync(outFile + '.sha256', sum + '\n')
  console.log('      ' + outFile + '.sha256')
} catch {}

// ── Optional deploy into a served bootstrap dir ──
// --deploy-dir copies the finished tarball (+ .sha256) into a served bootstrap
// dir, rolling one .bak.prev (mirrors scripts/deploy-bootstrap.sh). Lets
// `pgh publish` keep the curl-installable tarballs fresh in a single step.
const DEPLOY_DIR = getFlag('deploy-dir', null)
// --emit-manifest <path>: after deploying, write a machine-readable list of
// the deployed artifacts so `pgh publish` can register them into the daemon's
// overlay + signed BootstrapLog (hyper-fleeky-dev #66). Shape:
//   { app, version, files: [ { name, path }, ... ] }
const EMIT_MANIFEST = getFlag('emit-manifest', null)
const deployedArtifacts = []
if (DEPLOY_DIR) {
  const dir = path.resolve(DEPLOY_DIR.replace(/^~(?=$|\/)/, os.homedir()))
  fs.mkdirSync(dir, { recursive: true })
  const base = path.basename(outFile)

  // install.sh travels with every bootstrap deploy so a fresh machine can
  // `curl <host>/bootstrap/install.sh | sh`. Source = the app's own
  // scripts/install.sh (already staged via INCLUDE). Without this, install.sh
  // only ever reached the served dir by a manual copy — the #66 antipattern.
  const installSrc = path.join(ROOT, 'scripts', 'install.sh')

  const copies = [
    [outFile, path.join(dir, base), base],
    [outFile + '.sha256', path.join(dir, base + '.sha256'), base + '.sha256'],
    [installSrc, path.join(dir, 'install.sh'), 'install.sh']
  ]
  for (const [src, dst, name] of copies) {
    if (!fs.existsSync(src)) continue
    if (fs.existsSync(dst)) fs.renameSync(dst, dst + '.bak.prev')
    fs.copyFileSync(src, dst)
    deployedArtifacts.push({ name, path: dst })
  }
  console.log('  deployed -> ' + path.join(dir, base))
  if (deployedArtifacts.find(a => a.name === 'install.sh')) {
    console.log('  deployed -> ' + path.join(dir, 'install.sh'))
  }
}

if (EMIT_MANIFEST) {
  const dst = path.resolve(EMIT_MANIFEST.replace(/^~(?=$|\/)/, os.homedir()))
  fs.mkdirSync(path.dirname(dst), { recursive: true })
  fs.writeFileSync(dst, JSON.stringify({
    app: PKG.name,
    version: VERSION,
    files: deployedArtifacts
  }, null, 2))
  console.log('  manifest -> ' + dst)
}

// ── Helpers ──

function checkStagedSingletons (stagedNodeModules) {
  // Walk the staged node_modules directly and look for multiple
  // versions of each invariant package. Doesn't run npm ls (which
  // operates on source ROOT, not the trimmed stage).
  console.log('Dep-closure sanity check (staged):')
  for (const pkg of INVARIANT_SINGLETONS) {
    const versions = new Set()
    findPackageVersions(stagedNodeModules, pkg, versions)
    if (versions.size === 0) {
      console.log('  ' + pkg + ': NOT PRESENT (ok if optional)')
    } else if (versions.size > 1) {
      // WARN, don't fail. Multi-version is risky for native bindings
      // (load order races, ABI skew) but often runtime-survivable when
      // versions are semver-compatible. Upstream file:dep package.json
      // classification is the right fix; not blocking experimental
      // bundles on it.
      console.warn('  ' + pkg + ': MULTIPLE VERSIONS — ' + [...versions].join(', '))
      console.warn('    (warning only; check FORCE_STRIP set + upstream dep classification)')
    } else {
      console.log('  ' + pkg + ': ' + [...versions][0] + ' (single version ✓)')
    }
  }
}

function findPackageVersions (dir, pkgName, accumulator) {
  let entries
  try { entries = fs.readdirSync(dir) } catch { return }
  for (const name of entries) {
    if (name.startsWith('.')) continue
    const p = path.join(dir, name)
    let stat
    try { stat = fs.lstatSync(p) } catch { continue }
    if (!stat.isDirectory() || stat.isSymbolicLink()) continue
    if (name === pkgName) {
      try {
        const pkg = JSON.parse(fs.readFileSync(path.join(p, 'package.json'), 'utf8'))
        if (pkg.version) accumulator.add(pkgName + '@' + pkg.version)
      } catch {}
    }
    const nested = path.join(p, 'node_modules')
    if (fs.existsSync(nested)) findPackageVersions(nested, pkgName, accumulator)
  }
}

function pruneDevDeps (topNodeModules) {
  const dev = Object.keys(PKG.devDependencies || {})
  for (const name of dev) {
    const dir = path.join(topNodeModules, name)
    if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true })
    // Scoped packages: @scope/name → directory is @scope/name
  }
}

function pruneForceStrip (topNodeModules) {
  // Walk ALL node_modules dirs (including nested) and remove any
  // package whose name is in FORCE_STRIP. Recursive because file:deps
  // (hyper-overlay etc.) carry @hyperswarm/testnet in their own nested
  // node_modules, which keeps pulling the old udx-native version even
  // after we strip the top-level copy.
  let removed = 0
  function visit (nm) {
    let entries
    try { entries = fs.readdirSync(nm) } catch { return }
    for (const name of entries) {
      if (name.startsWith('.')) continue
      const pkgDir = path.join(nm, name)
      let stat
      try { stat = fs.lstatSync(pkgDir) } catch { continue }
      if (stat.isSymbolicLink() || !stat.isDirectory()) continue
      if (name.startsWith('@')) {
        let subs
        try { subs = fs.readdirSync(pkgDir) } catch { continue }
        for (const sub of subs) {
          const fullName = name + '/' + sub
          const subDir = path.join(pkgDir, sub)
          if (FORCE_STRIP.has(fullName)) {
            fs.rmSync(subDir, { recursive: true, force: true })
            removed++
          } else {
            const subNested = path.join(subDir, 'node_modules')
            if (fs.existsSync(subNested)) visit(subNested)
          }
        }
      } else if (FORCE_STRIP.has(name)) {
        fs.rmSync(pkgDir, { recursive: true, force: true })
        removed++
      } else {
        const nested = path.join(pkgDir, 'node_modules')
        if (fs.existsSync(nested)) visit(nested)
      }
    }
  }
  visit(topNodeModules)
  if (removed > 0) console.log('    force-strip removed ' + removed + ' nested + top instance(s)')
}

// indexTopLevel + pruneNestedSmart now live in ./lib/prune-nested.js (shared
// with pgh-sidecar's build, unit-tested in pgh/test/prune-nested.test.js).
// The fix there resolves a nested copy against its NEAREST node_modules
// ancestor, not just the bundle root — see that module for why.

function prunePrebuilds (rootDir, targetLabels) {
  const keep = new Set([].concat(targetLabels))
  function visit (dir) {
    let entries
    try { entries = fs.readdirSync(dir) } catch { return }
    for (const name of entries) {
      const p = path.join(dir, name)
      let stat
      try { stat = fs.lstatSync(p) } catch { continue }
      if (!stat.isDirectory() || stat.isSymbolicLink()) continue
      if (name === 'prebuilds') {
        for (const arch of fs.readdirSync(p)) {
          if (!keep.has(arch)) fs.rmSync(path.join(p, arch), { recursive: true, force: true })
        }
      } else {
        visit(p)
      }
    }
  }
  visit(rootDir)
}

function pruneOtherBareArches (topNodeModules, keepLabels) {
  const keep = new Set([].concat(keepLabels).map((l) => 'bare-runtime-' + l))
  for (const name of fs.readdirSync(topNodeModules)) {
    if (name.startsWith('bare-runtime-') && !keep.has(name)) {
      fs.rmSync(path.join(topNodeModules, name), { recursive: true, force: true })
    }
  }
}

function du (dir) {
  try {
    return parseInt(execSync('du -sb ' + JSON.stringify(dir), { encoding: 'utf8' }).split(/\s+/)[0], 10) || 0
  } catch {
    return 0
  }
}

function fmtBytes (n) {
  if (n < 1024) return n + ' B'
  if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
  if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
  return (n / 1073741824).toFixed(2) + ' GB'
}