/**
* bootstrap-artifacts — produce the curl-installable /bootstrap artifacts by
* TARRING the local pgh-store app trees (#66, hyper-fleeky-dev).
*
* The bootstrap app IS the app: `pgh publish` of a `pgh.bootstrap` app stages a
* self-contained bare tree (bundled runtime/bare + sh launcher + pruned prod
* node_modules) and registers THAT as the store tree, which the normal
* hyper-deploy/OTA install path already brings to every machine. So any machine
* that has pgh + pgh-sidecar installed ALREADY has both self-contained trees in
* its store under <store>/apps/<name>.
*
* This produces <store>/bootstrap/ — the dir a forge (pear-git-v3) serves at
* /bootstrap — with ZERO network:
* - tar each <store>/apps/<name> whose package.json declares pgh.bootstrap
* → <store>/bootstrap/<slug>-bootstrap-bare-<label>.tar.gz
* (regenerated only when the app's treeHash changes — cached via
* <store>/bootstrap/.bootstrap-state.json),
* - SYMLINK install.sh from whichever tree ships scripts/install.sh (the pgh
* tree) → <store>/bootstrap/install.sh (a symlink, so it tracks the tree).
*
* This is NOT a polling loop. It runs as a byproduct of an install/OTA event
* (the installer calls it after materializing a bootstrap app) plus a one-time
* catch-up on sidecar boot. The tarball BYTES need not be deterministic — the
* durable content-address is the store TREE hash; the tarball is just an
* ephemeral HTTP serialization, re-derivable on any machine from the same tree.
*/
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import { spawn } from '#node:child_process'
const STATE_FILE = '.bootstrap-state.json'
export function hostLabel () {
const o = { linux: 'linux', darwin: 'darwin', win32: 'windows' }[os.platform()] || os.platform()
const a = { x64: 'x64', arm64: 'arm64' }[os.arch()] || os.arch()
return o + '-' + a
}
// Absolute path of the served bootstrap dir for a store.
export function bootstrapDir (store) {
return path.join(store.root, 'bootstrap')
}
// Default: tar srcDir into destTarball (flat — contents at archive root), atomic
// via a tmp file. ASYNC spawn (NOT execFileSync) so a ~400MB tar never blocks the
// caller's event loop — the sidecar runs this at boot + on every bootstrap-app
// install, and a synchronous tar froze its UDS readiness probe (120s timeout).
// Injectable so reconcile unit-tests without shelling out.
function defaultTarTree (srcDir, destTar) {
const tmp = destTar + '.tmp'
return new Promise((resolve, reject) => {
const p = spawn('tar', ['-czf', tmp, '-C', srcDir, '.'], { stdio: 'ignore' })
p.on('error', reject)
p.on('exit', (code) => {
if (code !== 0) return reject(new Error('tar exited with code ' + code))
try { fs.renameSync(tmp, destTar); resolve() } catch (err) { reject(err) }
})
})
}
function readJSON (p) {
try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null }
}
function slugify (name) {
return String(name || '').replace(/[^a-z0-9-]/gi, '-')
}
/**
* Cross-arch seeding. A machine can only TAR its OWN arch — hostLabel() reads
* os.platform()/os.arch() — so an all-x64 fleet can never produce the
* linux-arm64 tarball install.sh asks for, and /bootstrap 404s for every arm64
* VPS. Bootstrap apps already vendor prebuilt cross-arch tarballs at
* node_modules/<pkg>/dist/<slug>-bootstrap-bare-<label>.tar.gz; this finds them
* so reconcile can promote them into the served dir. Local tar always wins for
* the host arch — these only ever fill in the arches we cannot build here.
*/
function vendoredBootstraps (appDir) {
const out = []
const seen = new Set()
const scan = (dist) => {
let files = []
try { files = fs.readdirSync(dist) } catch { return }
for (const f of files) {
if (!/-bootstrap-bare-.+\.tar\.gz$/.test(f)) continue
if (seen.has(f)) continue // own dist/ wins over a vendored copy
seen.add(f)
out.push({ name: f, src: path.join(dist, f) })
}
}
// The app's OWN dist/ first — that's the canonical home for these artifacts
// (they ship alongside a .sha256), so prefer it over whichever dependency
// happens to carry a copy.
scan(path.join(appDir, 'dist'))
const nm = path.join(appDir, 'node_modules')
let pkgs = []
try { pkgs = fs.readdirSync(nm) } catch { return out }
for (const pkg of pkgs) {
if (pkg.startsWith('.')) continue
scan(path.join(nm, pkg, 'dist'))
}
return out
}
/**
* Reconcile <store>/bootstrap from the local app trees. Idempotent.
*
* @param {TreeStore} store
* @param {object} [o]
* o.tarTree? async/sync (srcDir, destTar) => void (default: shell `tar`)
* o.label? os-arch label fallback if a tree lacks pghBootstrap.label
* o.log? (msg) => void
* @returns {Promise<{ tarred: string[], kept: string[], pruned: string[], installSh: boolean }>}
*/
export async function reconcileBootstrapArtifacts (store, o = {}) {
const out = { tarred: [], kept: [], pruned: [], installSh: false }
if (!store) return out
if (typeof store.init === 'function') store.init()
const tarTree = o.tarTree || defaultTarTree
const label = o.label || hostLabel()
const log = o.log
const appsDir = store.appsDir
const dir = bootstrapDir(store)
fs.mkdirSync(dir, { recursive: true })
const statePath = path.join(dir, STATE_FILE)
const prevState = readJSON(statePath) || {}
const nextState = {}
let appNames = []
try { appNames = fs.readdirSync(appsDir) } catch { return out }
const wantTarballs = new Set()
let installShTarget = null
for (const name of appNames) {
// Skip dotfiles and atomic-swap temp dirs (`<app>.new-<ts>` left by an
// interrupted swapApp) — they carry the app's package.json but are not a
// live install (and would collide with / shadow the real app's tarball).
if (name.startsWith('.') || /\.new-\d+$/.test(name)) continue
const appDir = path.join(appsDir, name)
const pkg = readJSON(path.join(appDir, 'package.json'))
if (!pkg || !pkg.pgh || !pkg.pgh.bootstrap) continue // bootstrap apps only
// The tarball name is `<slug>-bootstrap-bare-<label>` where <label> MUST be
// just `<os>-<arch>` (install.sh fetches exactly that). Guard against a
// malformed pghBootstrap.label (e.g. a tree that baked the stage name in →
// "bootstrap-bare-linux-x64") by validating the shape; fall back to host.
let treeLabel = (pkg.pghBootstrap && pkg.pghBootstrap.label) || label
if (!/^[a-z0-9]+-[a-z0-9]+$/.test(treeLabel)) treeLabel = label
// Slug = the apps/ DIRECTORY name (the install identity install.sh fetches),
// NOT pkg.name — an old build could bake the stage name into pkg.name
// (e.g. "pgh-sidecar-bootstrap-bare"), which would corrupt the tarball name.
const slug = slugify(name)
const tarName = `${slug}-bootstrap-bare-${treeLabel}.tar.gz`
const dest = path.join(dir, tarName)
wantTarballs.add(tarName)
const install = readJSON(path.join(appDir, '.pgh-install.json'))
const treeHash = (install && install.treeHash) || ''
nextState[tarName] = treeHash
const fresh = prevState[tarName] === treeHash && treeHash && fs.existsSync(dest)
if (fresh) {
out.kept.push(tarName)
} else {
await tarTree(appDir, dest)
out.tarred.push(tarName)
if (log) log('[bootstrap] tarred ' + name + ' → ' + tarName + ' (tree ' + (treeHash ? treeHash.slice(0, 12) + '…' : 'no-hash') + ')')
}
if (!installShTarget) {
const sh = path.join(appDir, 'scripts', 'install.sh')
if (fs.existsSync(sh)) installShTarget = sh
}
}
// ── Cross-arch promotion pass ───────────────────────────────────
// OPT-IN (admin setting). Only a forge backend actually serves /bootstrap over
// HTTPS, so only a backend needs the foreign-arch tarballs. Ordinary peers pull
// over hyper-deploy/overlay and never answer a cold-start HTTP fetch — holding
// another arch's 150MB would be pure cost to them.
// pgh app bootstrap-serve pgh-sidecar on
// sets apps.json `bootstrapServeAllArches`, which the sidecar reads and passes
// here. Default OFF: build your own arch, promote nothing.
//
// Deliberately over ALL app trees, not just pgh.bootstrap ones: a vendored
// bootstrap tarball is arch-portable BYTES, and it does not live in the tree
// of the app it bootstraps. Measured on tabertron:
// pgh-bootstrap-bare-linux-arm64.tar.gz → pgh-sidecar tree (bootstrap app)
// pgh-sidecar-bootstrap-bare-linux-arm64.tar.gz → pear-git-v3 tree (NOT a bootstrap app)
// install.sh fetches BOTH and runs under `set -e`, so scanning only bootstrap
// apps would serve pgh for arm64 and still fail at the sidecar step.
// Anything already in wantTarballs was tarred locally for the host arch and
// wins — promotion only ever fills arches we cannot build here.
if (!o.serveAllArches) {
if (log) log('[bootstrap] cross-arch promotion off (enable: pgh app bootstrap-serve <app> on)')
} else {
for (const name of appNames) {
if (name.startsWith('.') || /\.new-\d+$/.test(name)) continue
for (const v of vendoredBootstraps(path.join(appsDir, name))) {
if (wantTarballs.has(v.name)) continue
wantTarballs.add(v.name)
nextState[v.name] = 'vendored'
const vdest = path.join(dir, v.name)
// Same-inode means it's already the promoted hardlink — nothing to do.
// A DIFFERENT inode means the vendored artifact was updated underneath us,
// so re-link rather than serving stale bytes forever.
let linked = false
try { linked = fs.statSync(vdest).ino === fs.statSync(v.src).ino } catch {}
if (linked) { out.kept.push(v.name); continue }
try { fs.rmSync(vdest, { force: true }) } catch {}
try {
fs.linkSync(v.src, vdest) // hardlink: zero extra bytes
} catch {
try { fs.copyFileSync(v.src, vdest) } catch { continue }
}
out.tarred.push(v.name)
if (log) log('[bootstrap] promoted vendored ' + v.name + ' (cross-arch, from ' + name + ')')
}
}
}
// install.sh — a symlink (not a copy) so it tracks the current tree.
const installLink = path.join(dir, 'install.sh')
if (installShTarget) {
let cur = null
try { cur = fs.readlinkSync(installLink) } catch {}
if (cur !== installShTarget) {
try { fs.rmSync(installLink, { force: true }) } catch {}
fs.symlinkSync(installShTarget, installLink)
if (log) log('[bootstrap] linked install.sh → ' + installShTarget)
}
out.installSh = true
} else {
// no tree ships install.sh — drop a stale symlink (never a real file)
let isLink = false
try { isLink = fs.lstatSync(installLink).isSymbolicLink() } catch {}
if (isLink) { try { fs.rmSync(installLink, { force: true }) } catch {} }
}
// Prune stale tarballs (an app that's gone / no longer bootstrap).
let existing = []
try { existing = fs.readdirSync(dir) } catch {}
for (const f of existing) {
if (!/-bootstrap-bare-.+\.tar\.gz$/.test(f)) continue
if (wantTarballs.has(f)) continue
try { fs.rmSync(path.join(dir, f), { force: true }); out.pruned.push(f) } catch {}
}
try { fs.writeFileSync(statePath, JSON.stringify(nextState, null, 2) + '\n') } catch {}
if (log && (out.tarred.length || out.pruned.length)) {
log('[bootstrap] reconciled ' + dir + ': tarred ' + out.tarred.length + ', kept ' + out.kept.length + ', pruned ' + out.pruned.length)
}
return out
}