OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / 9e16fe2

0.1.146: publish multi-arch bootstrap trees (by-arch/<label>)
An x64 publisher could only ever produce x64-runnable installs of a bootstrap app. stageBootstrapForPublish built host-arch only, and the builder pruned BOTH the runtime binary and every native prebuild down to the build host — so an aarch64 device could fetch pgh-sidecar 0.2.57 from the registry and end up with an x86-64 runtime/bare it cannot execute. Measured on celltron: the installed tree carried bare-runtime-linux-x64 and nothing else, and its launcher execs that binary directly. That is why celltron has never been able to `pgh app install`/`update` the sidecar the way the other three hosts do, and why it needs an arch-specific bootstrap tarball instead. The consumer side was already built and unused: runtime-factory.js prefers by-arch/<host>/runtime/bare, and store.js/app.js skip foreign by-arch subtrees when materialising, so a remote host fetches only its own arch. Only the producer was missing. build-bootstrap-bare.js: --extra-arch <list> stage each at by-arch/<label>/runtime/bare prunePrebuilds / pruneOtherBareArches now take a SET of labels, so prebuilds and runtime packages survive for every target (this was the real blocker — the runtime binary alone was not enough) generated launcher resolves uname -m -> by-arch/$HOST/runtime/bare, falling back to runtime/bare for single-arch trees a requested-but-unvendored arch is a HARD ERROR — a silently skipped arch yields a tree that looks multi-arch and still cannot run, which is the failure mode that cost an afternoon publish.js requests arm64 by default (PGH_PUBLISH_EXTRA_ARCH overrides) but only for arches actually vendored in the app's node_modules, so publishing does not start failing for apps that have not vendored one. Verified on a real build: runtime/bare x86-64, by-arch/linux-arm64/runtime/bare ARM aarch64, prebuilds 211 linux-arm64 + 211 linux-x64. Staged tree 574.9 MB vs 383.6 MB single-arch — far less than 2x, because most of the original 1.39 GB was darwin/android/win32 being pruned either way. NOTE: publish runs the INSTALLED CLI, so this has no effect until deployed. 331/331, 941/941. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9e16fe2750e3061891542f438fd9476c679cc74b
by plankatron <fleeky@gmail.com> · 2026-08-25T20:28:49.000Z

Changed files (3) +78 -11

lib/commands/publish.js +30 -3
……@@ -450,9 +450,21 @@ export async function run (client, args) {
450450// when not applicable (no pgh.bootstrap / --no-bootstrap / build script absent),
451451// in which case publish falls back to the raw source dir.
452452//
453// Host-arch only for now (single tree). Multi-arch via `by-arch/<arch>/` in one
454// tree is a clean future extension — the install-side launcher resolver already
455// checks `by-arch/<host>/runtime/bare` first.
453// Multi-arch: extra arches are staged under `by-arch/<label>/` and the
454// install-side already prefers `by-arch/<host>/runtime/bare`
455// (lib/runtime-factory.js) while skipping foreign subtrees when materialising
456// (lib/store.js), so a remote host fetches only its own.
457//
458// Host-arch-only publishing meant an x64 publisher could ONLY produce
459// x64-runnable installs: both the runtime binary AND the native prebuilds were
460// pruned to the build host. An aarch64 device could fetch pgh-sidecar and end
461// up with an x86-64 runtime/bare it could not execute — which is why celltron
462// had to be bootstrapped from an arch-specific tarball instead of `pgh app
463// install` like every other host.
464//
465// PGH_PUBLISH_EXTRA_ARCH overrides the default set. Requires
466// bare-runtime-linux-<arch> vendored in the app's node_modules; the builder
467// hard-errors rather than silently emitting a tree that only looks multi-arch.
456468function stageBootstrapForPublish ({ appDir, noBootstrap }) {
457469 const none = { stageDir: null }
458470 let pgh = null
……@@ -486,6 +498,21 @@ function stageBootstrapForPublish ({ appDir, noBootstrap }) {
486498 const parts = ['node', JSON.stringify(buildScript), '--root', JSON.stringify(appDir),
487499 '--out', JSON.stringify(outDir), '--stage-out', JSON.stringify(stageOut)]
488500
501 // Only ask for arches whose runtime package is actually vendored — an
502 // unvendored arch is a hard error in the builder, and publish must not start
503 // failing for every app that has not vendored one.
504 const wanted = (process.env.PGH_PUBLISH_EXTRA_ARCH || 'arm64')
505 .split(',').map((a) => a.trim()).filter(Boolean)
506 const have = wanted.filter((a) =>
507 fs.existsSync(path.join(appDir, 'node_modules', 'bare-runtime-linux-' + a, 'bin', 'bare')))
508 if (have.length) {
509 parts.push('--extra-arch', JSON.stringify(have.join(',')))
510 console.log(' multi-arch: also staging ' + have.join(', '))
511 } else if (wanted.length) {
512 console.log(' multi-arch: skipped (' + wanted.join(', ') +
513 ' runtime not vendored in node_modules — install will be host-arch only)')
514 }
515
489516 console.log('pgh.bootstrap — staging self-contained tree:')
490517 execSync(parts.join(' '), { stdio: 'inherit' })
491518 if (!fs.existsSync(path.join(stageOut, 'package.json'))) {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.145",
3 "version": "0.1.146",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
scripts/build-bootstrap-bare.js +47 -7
……@@ -73,6 +73,18 @@ console.log(' out: ' + path.join(OUT_DIR, TARBALL_NAME))
7373
7474// ── Verify bare binary exists for target arch ──
7575
76// Additional arches staged under by-arch/<label>/runtime/bare. The install
77// side already prefers by-arch/<host>/runtime/bare (lib/runtime-factory.js) and
78// skips foreign subtrees when materialising (lib/store.js), so a multi-arch
79// tree costs a remote host nothing to install — but WITHOUT it an x64 publisher
80// can only ever produce x64-runnable installs, because both the runtime binary
81// and the native prebuilds are pruned to the build host. That is why an aarch64
82// device could fetch pgh-sidecar and still not run it.
83const EXTRA_ARCHES = (getFlag('extra-arch', '') || '')
84 .split(',').map((a) => a.trim()).filter(Boolean)
85const EXTRA_LABELS = EXTRA_ARCHES.map((a) => archLabel(OS, a)).filter((l) => l !== LABEL)
86const ALL_LABELS = [LABEL, ...EXTRA_LABELS]
87
7688const BARE_ARCH_PKG = `bare-runtime-${LABEL}`
7789const BARE_BIN_SRC = path.join(ROOT, 'node_modules', BARE_ARCH_PKG, 'bin', 'bare')
7890if (!fs.existsSync(BARE_BIN_SRC)) {
……@@ -205,14 +217,14 @@ console.log(' smart-nested: removed ' + nestedStats.dupsRemoved + ' hoist-dup
205217const afterNested = du(path.join(stageRoot, 'node_modules'))
206218console.log(' after smart-nested-dup prune: ' + fmtBytes(afterNested))
207219
208prunePrebuilds(path.join(stageRoot, 'node_modules'), LABEL)
220prunePrebuilds(path.join(stageRoot, 'node_modules'), ALL_LABELS)
209221const afterPrebuilds = du(path.join(stageRoot, 'node_modules'))
210222console.log(' after prebuilds prune: ' + fmtBytes(afterPrebuilds))
211223
212224// Prune non-target bare-runtime per-arch packages. We only need the
213225// target's binary; the others (bare-runtime-darwin-arm64, etc.) are
214226// pulled by npm as optional deps but useless on this bundle.
215pruneOtherBareArches(path.join(stageRoot, 'node_modules'), LABEL)
227pruneOtherBareArches(path.join(stageRoot, 'node_modules'), ALL_LABELS)
216228const afterArches = du(path.join(stageRoot, 'node_modules'))
217229console.log(' after bare-runtime arch prune: ' + fmtBytes(afterArches))
218230
……@@ -230,6 +242,27 @@ fs.copyFileSync(BARE_BIN_SRC, bareBinDest)
230242fs.chmodSync(bareBinDest, 0o755)
231243console.log(' + runtime/bare (' + fmtBytes(fs.statSync(bareBinDest).size) + ')')
232244
245// Extra arches live at by-arch/<label>/runtime/bare. runtime/bare stays the
246// BUILD host's copy so existing launchers, which hardcode that path, keep
247// working against an older tree.
248for (const label of EXTRA_LABELS) {
249 const src = path.join(ROOT, 'node_modules', 'bare-runtime-' + label, 'bin', 'bare')
250 if (!fs.existsSync(src)) {
251 // Hard failure: a silently-missing arch produces a by-arch/ tree that
252 // LOOKS multi-arch and still cannot run on that host.
253 console.error('error: --extra-arch ' + label + ' requested but ' + src + ' not found.')
254 console.error(' Vendor it first: npm pack bare-runtime-' + label + '@<pinned>')
255 console.error(' and extract into node_modules/bare-runtime-' + label + '/')
256 process.exit(1)
257 }
258 const dir = path.join(stageRoot, 'by-arch', label, 'runtime')
259 fs.mkdirSync(dir, { recursive: true })
260 const dest = path.join(dir, 'bare')
261 fs.copyFileSync(src, dest)
262 fs.chmodSync(dest, 0o755)
263 console.log(' + by-arch/' + label + '/runtime/bare (' + fmtBytes(fs.statSync(dest).size) + ')')
264}
265
233266// ── Shell launcher at bin/<BIN_NAME> + entry preserved as .bare.js ──
234267//
235268// install.sh symlinks ~/.local/bin/<BIN_NAME> -> <bootstrap>/bin/<BIN_NAME>,
……@@ -269,7 +302,12 @@ const launcherScript = `#!/bin/sh
269302# (or wherever install.sh placed it) still finds the bundled runtime.
270303HERE="$(cd "$(dirname "$(readlink -f "$0" || echo "$0")")" >/dev/null 2>&1 && pwd)"
271304ROOT="$(cd "$HERE/.." >/dev/null 2>&1 && pwd)"
272exec "$ROOT/runtime/bare" "$ROOT/${entryFromRoot}" "$@"
305# Prefer this host's arch when the tree carries several; fall back to the
306# build host's copy for single-arch trees.
307HOSTLABEL="$(uname -s | tr 'A-Z' 'a-z')-$(uname -m | sed 's/x86_64/x64/; s/aarch64/arm64/')"
308BARE="$ROOT/by-arch/$HOSTLABEL/runtime/bare"
309[ -x "$BARE" ] || BARE="$ROOT/runtime/bare"
310exec "$BARE" "$ROOT/${entryFromRoot}" "$@"
273311`
274312fs.writeFileSync(launcherPath, launcherScript)
275313fs.chmodSync(launcherPath, 0o755)
……@@ -506,7 +544,8 @@ function pruneForceStrip (topNodeModules) {
506544// The fix there resolves a nested copy against its NEAREST node_modules
507545// ancestor, not just the bundle root — see that module for why.
508546
509function prunePrebuilds (rootDir, targetLabel) {
547function prunePrebuilds (rootDir, targetLabels) {
548 const keep = new Set([].concat(targetLabels))
510549 function visit (dir) {
511550 let entries
512551 try { entries = fs.readdirSync(dir) } catch { return }
……@@ -517,7 +556,7 @@ function prunePrebuilds (rootDir, targetLabel) {
517556 if (!stat.isDirectory() || stat.isSymbolicLink()) continue
518557 if (name === 'prebuilds') {
519558 for (const arch of fs.readdirSync(p)) {
520 if (arch !== targetLabel) fs.rmSync(path.join(p, arch), { recursive: true, force: true })
559 if (!keep.has(arch)) fs.rmSync(path.join(p, arch), { recursive: true, force: true })
521560 }
522561 } else {
523562 visit(p)
……@@ -527,9 +566,10 @@ function prunePrebuilds (rootDir, targetLabel) {
527566 visit(rootDir)
528567}
529568
530function pruneOtherBareArches (topNodeModules, keepLabel) {
569function pruneOtherBareArches (topNodeModules, keepLabels) {
570 const keep = new Set([].concat(keepLabels).map((l) => 'bare-runtime-' + l))
531571 for (const name of fs.readdirSync(topNodeModules)) {
532 if (name.startsWith('bare-runtime-') && name !== 'bare-runtime-' + keepLabel) {
572 if (name.startsWith('bare-runtime-') && !keep.has(name)) {
533573 fs.rmSync(path.join(topNodeModules, name), { recursive: true, force: true })
534574 }
535575 }