OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / scripts / build-bootstrap.js

185 lines · javascript ref:
#!/usr/bin/env node
/**
 * build-bootstrap — package pgh into a per-arch installable tarball.
 *
 * P.10 Phase 0.5i. Produces `pgh-bootstrap-<os>-<arch>.tar.gz` from the
 * current pear-git-v3 checkout, containing:
 *   - bin/pgh, bin/pgh-sidecar (+ any other bin scripts)
 *   - lib/ (pgh CLI + sidecar + primitives)
 *   - node_modules/ (hoisted via `npm install` ahead of running this)
 *   - package.json with a trimmed `pgh-bootstrap` metadata object
 *
 * This is deliberately NOT a `bare-pack` / esbuild bundle — we want the
 * bootstrap to be inspectable on disk after install, and deps are
 * resolved by whatever runtime (Node or Bare) the user's `pgh` launcher
 * invokes.
 *
 * 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, '..')

// ── 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`

console.log('Building pgh bootstrap tarball')
console.log('  target:  ' + LABEL)
console.log('  version: ' + VERSION)
console.log('  out:     ' + path.join(OUT_DIR, TARBALL_NAME))

// ── Stage a clean bootstrap tree ─────────────────────────────

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

// Files to copy (paths relative to ROOT)
const INCLUDE = [
  'bin/pgh',
  'bin/pgh-sidecar',
  'lib/pgh',
  'lib/pgh-sidecar',
  'lib/pgh-runtime.js',
  'lib/release-registry.js'
]

function copyRecursive (src, dest) {
  const stat = fs.lstatSync(src)
  if (stat.isDirectory()) {
    fs.mkdirSync(dest, { recursive: true })
    for (const name of fs.readdirSync(src)) {
      copyRecursive(path.join(src, name), path.join(dest, name))
    }
  } else if (stat.isSymbolicLink()) {
    // Copy the symlink target contents through (bootstrap is flat)
    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)) {
    console.error('  missing: ' + rel + ' — skipping')
    continue
  }
  copyRecursive(src, path.join(stageRoot, rel))
  console.log('  + ' + rel)
}

// node_modules — copied if present (hoisted). Otherwise the user must
// run `npm install` inside the bootstrap after extraction.
//
// NOTE: a naive copy of pear-git-v3's node_modules produces a ~2 GB
// tarball because v3 currently embeds 10 sibling modules via file: deps,
// each with their own node_modules. The target for a real pgh bootstrap
// is ~30-40 MB. Closing that gap requires either:
//   - extracting pgh to a standalone package with its own minimum
//     dependency set (transport + crypto + hyper-overlay + hyper-sync),
//     or
//   - an --allow <names...> flag here that prunes to a hand-curated list
//     of top-level package names (with transitive closure re-installed).
// Deferred to 0.5k or beyond — current build-bootstrap is scaffolding
// that produces valid tarballs, not yet size-optimized.
const allow = getFlag('allow', null)
if (fs.existsSync(path.join(ROOT, 'node_modules'))) {
  if (allow) {
    const names = allow.split(',')
    fs.mkdirSync(path.join(stageRoot, 'node_modules'), { recursive: true })
    for (const name of names) {
      const src = path.join(ROOT, 'node_modules', name)
      if (fs.existsSync(src)) {
        copyRecursive(src, path.join(stageRoot, 'node_modules', name))
        console.log('  + node_modules/' + name)
      } else {
        console.log('  SKIP node_modules/' + name + ' (not present — transitive?)')
      }
    }
  } else {
    copyRecursive(path.join(ROOT, 'node_modules'), path.join(stageRoot, 'node_modules'))
    console.log('  + node_modules (full — use --allow <names> to prune)')
  }
} else {
  console.log('  (node_modules missing — user will need to npm install after extract)')
}

// Trimmed package.json — strip scripts, devDependencies; add bootstrap metadata
const srcPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
const bootPkg = {
  name: 'pgh-bootstrap',
  version: srcPkg.version,
  type: srcPkg.type,
  bin: {
    pgh: './bin/pgh',
    'pgh-sidecar': './bin/pgh-sidecar'
  },
  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}" pgh-bootstrap`, { stdio: 'inherit' })
} catch (err) {
  console.error('tar failed: ' + err.message)
  process.exit(1)
}

const size = fs.statSync(outFile).size
console.log('\nDone: ' + outFile + ' (' + (size / 1048576).toFixed(1) + ' MB)')

// Clean up stage
fs.rmSync(stage, { recursive: true, force: true })

// Checksum for verification
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 — skip
}