#!/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'
}