#!/usr/bin/env node
/**
* build-bootstrap — package pgh into a per-arch installable tarball.
*
* Produces `pgh-bootstrap-<os>-<arch>.tar.gz` from the pgh package,
* including pgh's own code, its bin scripts, and its hoisted
* node_modules (pgh-runtime + hyper-overlay + transport core).
*
* The target size is < 50 MB — pgh's dep closure is small (content-
* addressable store, CLI, and the bits pgh-runtime + hyper-overlay
* bring in transitively). A bundle ~2 GB means something's wrong
* with dep resolution (e.g. you're building from a pear-git-v3 tree
* that's pulled in all 10 sibling file: deps). Run this from inside
* the pgh/ package root, not from a parent that has more deps.
*
* Usage:
* node scripts/build-bootstrap.js # infers os+arch
* node scripts/build-bootstrap.js --os linux --arch x64
* node scripts/build-bootstrap.js --out /tmp/out # override output dir
*/
import { execSync } from 'child_process'
import fs from 'fs'
import path from 'path'
import os from 'os'
import { fileURLToPath } from 'url'
const HERE = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.resolve(HERE, '..') // pgh package root
// Bare-runtime shim modules — Node-builtin alternatives only loaded
// under Bare. Safe to strip from a Node-targeted bundle. Keeps utility
// packages like bare-addon-resolve that are needed under both runtimes.
const BARE_SHIMS = new Set([
'bare-fs', 'bare-path', 'bare-os', 'bare-process',
'bare-crypto', 'bare-http1', 'bare-https', 'bare-http-parser',
'bare-tls', 'bare-zlib', 'bare-tty', 'bare-readline', 'bare-repl',
'bare-signals', 'bare-subprocess', 'bare-dgram', 'bare-dns',
'bare-net', 'bare-tcp', 'bare-pipe', 'bare-timers', 'bare-events',
'bare-buffer', 'bare-assert', 'bare-url', 'bare-env', 'bare-stream',
'bare-inspect', 'bare-inspector', 'bare-format', 'bare-console',
'bare-hrtime', 'bare-atomics', 'bare-async-hooks',
'bare-debug-log', 'bare-logger'
])
// ── 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 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 VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
const TARBALL_NAME = `pgh-bootstrap-${LABEL}.tar.gz`
const STAGE_NAME = 'pgh-bootstrap'
console.log('Building pgh bootstrap tarball')
console.log(' target: ' + LABEL)
console.log(' version: ' + VERSION)
console.log(' out: ' + path.join(OUT_DIR, TARBALL_NAME))
// ── Dep-closure sanity check ─────────────────────────────────
// Invariants that prevent yesterday's udx-skew disaster. Using npm ls
// gives us "how many copies of X exist in the tree" — should be 1 for
// each of these or the bundle isn't safe to ship.
const INVARIANT_SINGLETONS = ['udx-native', 'hypercore', 'hyperswarm', 'corestore']
function checkSingletons () {
for (const pkg of INVARIANT_SINGLETONS) {
let out
try {
out = execSync('npm ls ' + pkg + ' --all 2>/dev/null', { cwd: ROOT, encoding: 'utf8' })
} catch (err) {
out = err.stdout || ''
}
// Count distinct version lines: `pkg@x.y.z`
const matches = out.match(new RegExp('\\s' + pkg.replace(/[-/]/g, '[-/]') + '@([\\d.]+[^\\s]*)', 'g')) || []
const versions = new Set(matches.map(m => m.trim()))
if (versions.size === 0) {
console.log(' ' + pkg + ': NOT PRESENT (ok if optional)')
} else if (versions.size > 1) {
console.error(' ' + pkg + ': MULTIPLE VERSIONS — ' + [...versions].join(', '))
console.error(' This bundle would reproduce the yesterday-class udx-skew bug.')
console.error(' Run `npm dedupe` or reconcile the dep tree before building.')
process.exit(1)
} else {
console.log(' ' + pkg + ': ' + [...versions][0] + ' (single version ✓)')
}
}
}
console.log('Dep-closure sanity check:')
checkSingletons()
// ── Stage ─────────────────────────────────────────────────────
fs.mkdirSync(OUT_DIR, { recursive: true })
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-bootstrap-'))
const stageRoot = path.join(stage, STAGE_NAME)
fs.mkdirSync(stageRoot)
const INCLUDE = ['bin', 'lib', 'index.js']
// Names that should never appear in the bootstrap tarball — dev cruft,
// nested node_modules (handled separately), and test/doc dirs that
// only apply at development time. Critical for file: deps that are
// checked-out git repos (their .git can be huge — hyper-overlay's is
// 119 MB alone).
const COPY_SKIP = new Set([
'node_modules', // handled at the top level + pruned
'.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'
])
function copyRecursive (src, dest) {
const stat = fs.lstatSync(src)
if (stat.isDirectory()) {
fs.mkdirSync(dest, { recursive: true })
for (const name of fs.readdirSync(src)) {
if (COPY_SKIP.has(name)) continue
copyRecursive(path.join(src, name), path.join(dest, name))
}
} else if (stat.isSymbolicLink()) {
const real = fs.realpathSync(src)
copyRecursive(real, dest)
} 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))
console.log(' + ' + rel)
}
// node_modules — always copy full, then prune.
if (!fs.existsSync(path.join(ROOT, 'node_modules'))) {
console.error(' node_modules missing — run `npm install` in pgh/ first')
process.exit(1)
}
copyRecursive(path.join(ROOT, 'node_modules'), path.join(stageRoot, 'node_modules'))
const bareSize = du(path.join(stageRoot, 'node_modules'))
console.log(' + node_modules (raw: ' + fmtBytes(bareSize) + ')')
// Prune 1: nested node_modules inside file:-resolved deps. npm resolves
// file: deps by copying the target dir wholesale, which means their own
// node_modules come along — even though npm has already hoisted those
// deps up to the top level. Remove the nested dups.
pruneNestedNodeModules(path.join(stageRoot, 'node_modules'))
const afterNested = du(path.join(stageRoot, 'node_modules'))
console.log(' after nested-node_modules prune: ' + fmtBytes(afterNested))
// Prune 2: prebuilds for other architectures. Native modules ship
// prebuilds for every supported platform; we only need the target.
prunePrebuilds(path.join(stageRoot, 'node_modules'), LABEL)
const afterPrebuilds = du(path.join(stageRoot, 'node_modules'))
console.log(' after prebuilds prune: ' + fmtBytes(afterPrebuilds))
// Prune 3 (selective): bare-* modules that are STRICTLY Node-shim
// alternatives — conditionally loaded only when running under Bare via
// imports maps. Carefully curated — some bare-* modules are runtime
// utilities (e.g. bare-addon-resolve is used by require-addon for
// native binding resolution on both Node and Bare).
pruneBareShims(path.join(stageRoot, 'node_modules'))
const afterBare = du(path.join(stageRoot, 'node_modules'))
console.log(' after bare-shim prune: ' + fmtBytes(afterBare))
// Trimmed package.json
const srcPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
const bootPkg = {
name: STAGE_NAME,
version: srcPkg.version,
type: srcPkg.type,
main: srcPkg.main,
bin: srcPkg.bin,
exports: srcPkg.exports,
dependencies: srcPkg.dependencies,
pghBootstrap: {
os: OS,
arch: ARCH,
label: LABEL,
builtAt: new Date().toISOString(),
sourceVersion: srcPkg.version
}
}
fs.writeFileSync(path.join(stageRoot, 'package.json'), JSON.stringify(bootPkg, null, 2))
console.log(' + package.json (trimmed)')
// ── Tar it up ────────────────────────────────────────────────
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
const sizeStr = fmtBytes(size)
console.log('\nDone: ' + outFile + ' (' + sizeStr + ')')
if (size > 50 * 1024 * 1024) {
console.warn('\nWARNING: bundle is ' + sizeStr + ' — target is < 50 MB.')
console.warn('Check npm ls for unexpected deps.')
}
// Clean up stage
fs.rmSync(stage, { recursive: true, force: true })
// Checksum
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 {
// no sha256sum / shasum available
}
// ── Helpers ──────────────────────────────────────────────────
/**
* Remove nested node_modules directories for deps resolved via file:
* paths. When npm resolves "file:../foo", it copies foo/ wholesale —
* including foo/node_modules. But npm also hoists those transitive
* deps to the top-level node_modules, so the nested copies are pure
* duplication. Safe to remove.
*/
function pruneNestedNodeModules (topNodeModules) {
// Only prune nested node_modules whose parent is directly under the
// top-level node_modules. Transitive nested node_modules inside
// non-file: deps (rare, but happens for version conflicts) we leave
// alone — npm had a reason to keep them distinct.
const topEntries = fs.readdirSync(topNodeModules)
for (const name of topEntries) {
if (name.startsWith('.')) continue
const pkgDir = path.join(topNodeModules, name)
let stat
try { stat = fs.statSync(pkgDir) } catch { continue }
if (!stat.isDirectory()) continue
const nested = path.join(pkgDir, 'node_modules')
if (fs.existsSync(nested)) {
fs.rmSync(nested, { recursive: true, force: true })
}
// Scoped packages (@scope/name): one more level deep
if (name.startsWith('@')) {
for (const sub of fs.readdirSync(pkgDir)) {
const subNested = path.join(pkgDir, sub, 'node_modules')
if (fs.existsSync(subNested)) {
fs.rmSync(subNested, { recursive: true, force: true })
}
}
}
}
}
/**
* Remove prebuilt binaries for non-target architectures. Native npm
* modules ship prebuilds under prebuilds/<label>/ — dropping 12 of 13
* arches shrinks the bundle dramatically.
*/
function prunePrebuilds (rootDir, targetLabel) {
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 (arch !== targetLabel) {
fs.rmSync(path.join(p, arch), { recursive: true, force: true })
}
}
} else {
visit(p)
}
}
}
visit(rootDir)
}
/**
* Remove Bare-only shim modules (see BARE_SHIMS set at top of file).
* Utility bare-* packages (bare-addon-resolve, etc.) are preserved
* because require-addon uses them on Node too.
*/
function pruneBareShims (topNodeModules) {
for (const name of fs.readdirSync(topNodeModules)) {
if (BARE_SHIMS.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'
}