/** pgh app install|update|rollback|list|remove|start|stop|restart|status|log|autostart|boot|shutdown|adopt */
import process from '#node:process'
import { isAbstract } from '../daemon-client.js'
import { spawn } from '#node:child_process'
import { execSync } from '../_compat-exec.js'
import path from '#node:path'
import { fileURLToPath } from '#node:url'
import { homedir, tmpdir, hostname } from '#node:os'
import fs from '#node:fs'
import b4a from 'b4a'
import { Client } from 'hyper-svc'
import { TreeStore, DEFAULT_ROOT as DEFAULT_STORE_ROOT } from '../store.js'
import { installTree, linkApp, unlinkApp, swapApp } from '../tree-install.js'
import { installComplete, resolveBundledRuntime, launcherScript, livePath } from '../runtime-factory.js'
import { materializeAtomic, applyStaged } from '../materializers/atomic.js'
import { ReleaseRegistry } from '../release-registry.js'
import { resolveRelease as sharedResolveRelease, isOlderVersion } from '../resolve-release.js'
import { NetworkFetcher } from '../network-fetcher.js'
import {
appDir, pidFile, logFile, installManifestFile,
readInstallManifest, writeInstallManifest, readPackageJsonPgh,
readUserConfig, writeUserConfig,
readPid, clearPid, isPidAlive, processStartedAt, findRunningSupervisors,
readState,
rotateLogIfNeeded, openLogForAppend,
effectiveEnv, effectiveArgs, effectiveAutostart,
expandEnv as expandEnvStr,
listAppNames
} from '../app-state.js'
import { addSubscription } from '../subscriptions.js'
import { orderedTopicCandidates } from '../topic-order.js'
import { topoSort, waitReady, portConflictSkips } from '../dep-graph.js'
const { join } = path
const { mkdirSync, writeFileSync, unlinkSync, readdirSync, existsSync, rmSync, readFileSync, lstatSync, readlinkSync } = fs
const SUPERVISOR_SCRIPT = fileURLToPath(new URL('../supervisor.js', import.meta.url))
export const usage = `pgh app — install and run pgh-managed apps
Install / lifecycle:
pgh app install <bare-name> Resolve via NameRegistry + fetch
pgh app install pgh://<reg-hex>/<name>[@<v>]
pgh app install pear://KEY --name <name> Legacy v3-daemon install
pgh app update <name> Re-install (STOP the daemon first)
pgh app remove <name>
pgh app list
Tree primitives (content-addressed):
pgh app install-tree <name> --tree <hash> [--entry <file>]
pgh app update-tree <name> --tree <hash>
pgh app rollback <name> --tree <previousHash>
pgh app current-tree <name>
Process lifecycle (supervisor-managed):
pgh app start|stop|restart <name>
pgh app status [<name>]
pgh app stale [--all] installed-vs-RUNNING check for every app on this host
pgh app log <name> [-f] [--lines N]
pgh app autostart <name> on|off
pgh app bootstrap-serve <name> on|off Serve bootstrap tarballs for arches this
machine cannot build (forge backends only)
pgh app boot | shutdown
pgh app adopt <path> [--name <name>] [--bin <name>]
Repo health (calls the running app's RPCs):
pgh app gc <name>
pgh app heal [name]
pgh app resync <name> [--source <hex>] [--timeout <ms>]
pgh app reregister <name> [--uds <path>] [--pid <n>]`
const SIDECAR_SOCK = process.env.PGH_SIDECAR_SOCK || path.join(homedir(), '.pgh', 'svc.sock')
// Resolve an app's runtime name ('bare' | 'node') to a launcher invocation
// string. When the runtime matches the interpreter the CLI is itself
// running under, return process.execPath — guarantees the launcher works
// on machines without that runtime on PATH (e.g. bare-bootstrap installs
// on a host without system bare). Otherwise return the literal name so
// the shell does PATH lookup (existing behavior).
// Write a launcher script at `launcher`, replacing any existing file OR
// symlink there. Plain writeFileSync FOLLOWS a symlink and writes THROUGH it
// into the link target — e.g. a stale `~/.local/bin/pgh -> store/apps/pgh/bin/pgh`
// symlink would get the launcher body written into the STORE entry (shared /
// hardlinked content) instead of being replaced. Always unlink first so we
// emit a fresh, standalone regular file.
function writeLauncher (launcher, body) {
try { unlinkSync(launcher) } catch {}
writeFileSync(launcher, body, { mode: 0o755 })
}
// When a materialized/installed app bundles its OWN bare runtime (a
// self-contained bootstrap-bare tree — runtime/bare + bin/<name>.bare.js), the
// launcher/supervisor must exec THAT bare, not a PATH/system bare — otherwise a
// machine without system bare can't run it. Checks by-arch/<host>/runtime/bare
// first (multi-arch trees), then a root-level runtime/bare, then the runtime
// currently executing us, then a sibling app's bundled bare in the same store.
// NOTE: the implementation now lives in ../runtime-factory.js and is imported above. It used to be
// duplicated here "in parity", and the two copies had DRIFTED — this one resolved process.execPath, the
// other fell back to the bare NAME, and the other is the one finalizeInstall uses. That drift is what
// wrote ashatron a PATH-dependent `exec bare ...` launcher. One implementation, no parity to maintain.
// Atomic materialize-and-swap (and, when the app is running, STAGE instead of
// swap) lives in ../materializers/atomic.js — this file used to carry a private
// copy of it; there is one implementation now, shared with pgh-runtime's OTA.
async function resolveBareName (name) {
let c = null
try {
c = await Client.connectUds(SIDECAR_SOCK)
return await c.call('resolve-name', { name })
} catch {
return null
} finally {
try { if (c) await c.close() } catch {}
}
}
// Apps live under the device-wide store's apps/ dir. Launchers go to
// $PGH_BIN_DIR if set (sandboxed installs / cross-machine perf benches),
// else ~/.local/bin/ per XDG convention. Without honoring this env var, a
// sandboxed `pgh app install --name foo-bench` silently overwrites the
// user's real `~/.local/bin/foo` launcher.
const APPS_DIR = join(DEFAULT_STORE_ROOT, 'apps')
const BIN_DIR = process.env.PGH_BIN_DIR || join(homedir(), '.local', 'bin')
/**
* Parse a pgh:// or pgh+registry:// URL:
* pgh://<hex-registry-key>/<name>
* pgh://<hex-registry-key>/<name>@<version>
*
* Returns { registryKeyHex, name, version? } or null if not a pgh URL.
*/
function parsePghUrl (s) {
if (typeof s !== 'string' || !s.startsWith('pgh://')) return null
const rest = s.slice('pgh://'.length)
const slash = rest.indexOf('/')
if (slash === -1) return null
const registryKeyHex = rest.slice(0, slash)
const afterSlash = rest.slice(slash + 1)
const at = afterSlash.lastIndexOf('@')
let name, version
if (at > 0) {
name = afterSlash.slice(0, at)
version = afterSlash.slice(at + 1)
} else {
name = afterSlash
}
if (registryKeyHex.length !== 64) {
throw new Error('pgh:// URL must carry a 64-char hex registry key (got ' + registryKeyHex.length + ')')
}
return { registryKeyHex, name, version }
}
export async function run (client, args) {
const sub = args[0]
if (!sub) throw new Error('usage: pgh app install|update|list|remove')
if (sub === 'install') {
const target = args[1]
if (!target) {
throw new Error(
'usage: pgh app install <name> — resolved via pgh-sidecar\'s NameRegistry\n' +
' pgh app install <pgh://…/name[@ver]> — explicit registry-key install\n' +
' pgh app install <repo> — legacy v3-daemon mode'
)
}
const pgh = (() => { try { return parsePghUrl(target) } catch (e) { throw e } })()
if (pgh) {
return await installViaRegistry(pgh, args)
}
// Bare name — ask pgh-sidecar's NameRegistry to resolve. If that
// works, synthesize the pgh:// URL and proceed with the normal
// install flow.
if ((isAbstract(SIDECAR_SOCK) || fs.existsSync(SIDECAR_SOCK)) && /^[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$/.test(target)) {
const resolved = await resolveBareName(target)
if (resolved) {
console.log('Resolved "' + target + '" → registry ' + resolved.publisher.registryKey.slice(0, 12) + '… via pgh-sidecar NameRegistry')
const synthesized = {
registryKeyHex: resolved.publisher.registryKey,
name: target,
version: args[3] && args[2] === '@' ? args[3] : undefined
}
return await installViaRegistry(synthesized, args)
}
// Sidecar is up but name is unknown — fall through to legacy path
// (or fail with a clearer error below).
}
// Legacy path: expect a daemon client (only works when invoked
// through pear-git-v3's CLI, not standalone pgh).
if (!client) {
throw new Error(
target + ': name not found in pgh-sidecar\'s NameRegistry and not a pgh:// URL.\n' +
'Options: `pgh claim ' + target + '` to register it here, or use a pgh://<registry-key>/' + target + ' URL.'
)
}
return await installViaDaemon(client, target, args)
}
if (sub === 'update') {
const name = args[1]
if (!name) throw new Error('usage: pgh app update <name>')
const dest = join(APPS_DIR, name)
if (!existsSync(dest)) throw new Error(name + ' not installed at ' + dest)
// Read the installed app's pgh.upgrade URL and reinstall via the
// tree-based registry flow (sidecar-preferred, swarm-fallback).
// Legacy git-based update was removed in the sidecar refactor.
const pkgPath = join(dest, 'package.json')
if (!existsSync(pkgPath)) throw new Error(name + ': no package.json at ' + pkgPath)
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const upgradeUrl = pkg && pkg.pgh && pkg.pgh.upgrade
if (!upgradeUrl) {
throw new Error(name + ': no pgh.upgrade URL in package.json — reinstall via `pgh app install pgh://...`')
}
const pgh = parsePghUrl(upgradeUrl)
if (!pgh) throw new Error(name + ': pgh.upgrade is not a pgh:// URL: ' + upgradeUrl)
console.log('Updating ' + name + ' from ' + upgradeUrl + '...')
return await installViaRegistry(pgh, ['install', upgradeUrl, '--name', name, '--no-downgrade'])
}
// Swap in an update that was STAGED because the app was running (see
// materializers/atomic.js). Called by the app's launcher before it starts
// and by the supervisor before each respawn; refuses while the app runs.
if (sub === 'apply-staged') {
const name = args[1]
if (!name) throw new Error('usage: pgh app apply-staged <name>')
const store = new TreeStore()
store.init()
return { name, ...applyStaged(store, name) }
}
if (sub === 'list' || sub === 'ls') {
// Include both regular installs (have package.json) and adopted
// dev installs (.pgh-install.json only). listAppNames filters on
// the install manifest, which is the canonical liveness signal.
return listAppNames().map(name => {
const m = readInstallManifest(name) || {}
const st = readInstallManifest(name + '.next') // a staged update (app was running)
return {
name,
path: m.sourcePath || join(APPS_DIR, name),
launcher: existsSync(join(BIN_DIR, name)),
adopted: !!m.adopted,
version: m.version,
staged: (st && st.version) || ''
}
})
}
if (sub === 'remove') {
const name = args[1]
if (!name) throw new Error('usage: pgh app remove <name>')
const dest = join(APPS_DIR, name)
const launcher = join(BIN_DIR, name)
// If dest is a symlink into the store, decrement refcount cleanly.
let unlinked = null
try {
const stat = lstatSync(dest)
if (stat.isSymbolicLink()) {
const store = new TreeStore()
unlinked = unlinkApp(store, name, APPS_DIR)
} else if (existsSync(dest)) {
rmSync(dest, { recursive: true })
}
} catch {}
if (existsSync(launcher)) unlinkSync(launcher)
return {
removed: name,
treeDecremented: unlinked ? unlinked.rootHash : null
}
}
// ── Tree-based install/update/rollback (0.5c + 0.5f primitives) ──
if (sub === 'install-tree') {
const name = args[1]
const treeHashIdx = args.indexOf('--tree')
const treeHash = treeHashIdx !== -1 && args[treeHashIdx + 1]
if (!name || !treeHash) {
throw new Error('usage: pgh app install-tree <name> --tree <hash> [--entry <file>]')
}
const entryIdx = args.indexOf('--entry')
const entry = (entryIdx !== -1 && args[entryIdx + 1]) || 'index.js'
const store = new TreeStore()
if (!store.hasTree(treeHash)) {
throw new Error('tree not in store: ' + treeHash.slice(0, 12) + '... — fetch it first or use --from')
}
const { installPath, target } = linkApp(store, name, treeHash, APPS_DIR)
// Create launcher
mkdirSync(BIN_DIR, { recursive: true })
const launcher = join(BIN_DIR, name)
writeLauncher(launcher, '#!/bin/sh\nexec ' + resolveBundledRuntime(installPath, 'bare', detectHost()) + ' ' + join(installPath, entry) + ' "$@"\n')
return { installed: name, path: installPath, target, launcher, treeHash }
}
if (sub === 'update-tree') {
const name = args[1]
const treeHashIdx = args.indexOf('--tree')
const newTreeHash = treeHashIdx !== -1 && args[treeHashIdx + 1]
if (!name || !newTreeHash) {
throw new Error('usage: pgh app update-tree <name> --tree <hash>')
}
const store = new TreeStore()
if (!store.hasTree(newTreeHash)) {
throw new Error('tree not in store: ' + newTreeHash.slice(0, 12) + '... — fetch it first')
}
const result = swapApp(store, name, newTreeHash, APPS_DIR)
return { updated: name, previous: result.previous, current: result.current }
}
if (sub === 'rollback') {
const name = args[1]
const treeHashIdx = args.indexOf('--tree')
const previousHash = treeHashIdx !== -1 && args[treeHashIdx + 1]
if (!name || !previousHash) {
throw new Error('usage: pgh app rollback <name> --tree <hash>')
}
const store = new TreeStore()
if (!store.hasTree(previousHash)) {
throw new Error('previous tree not in store: ' + previousHash.slice(0, 12) + '... — can only roll back to trees still present (pin to protect from GC)')
}
const result = swapApp(store, name, previousHash, APPS_DIR)
return { rolledBack: name, previous: result.previous, current: result.current }
}
if (sub === 'current-tree') {
const name = args[1]
if (!name) throw new Error('usage: pgh app current-tree <name>')
const installPath = join(APPS_DIR, name)
try {
const stat = lstatSync(installPath)
if (!stat.isSymbolicLink()) {
return { name, installed: false, reason: 'not a symlinked tree install' }
}
const target = readlinkSync(installPath)
const hash = target.split('/').pop()
return { name, treeHash: hash, target }
} catch {
return { name, installed: false, reason: 'not installed' }
}
}
if (sub === 'start') return await cmdStart(args)
if (sub === 'stop') return await cmdStop(args)
if (sub === 'restart') return await cmdRestart(args)
if (sub === 'status') return await cmdStatus(args)
if (sub === 'stale') return cmdStale(args)
if (sub === 'log') return await cmdLog(args)
if (sub === 'autostart') return cmdAutostart(args)
if (sub === 'bootstrap-serve') return cmdBootstrapServe(args)
if (sub === 'boot') return await cmdBoot(args)
if (sub === 'shutdown') return await cmdShutdown(args)
if (sub === 'adopt') return cmdAdopt(args)
if (sub === 'gc') return await cmdGc(args)
if (sub === 'heal') return await cmdHeal(args)
if (sub === 'reregister') return await cmdReregister(args)
if (sub === 'resync') {
const name = args[1]
if (!name) throw new Error('usage: pgh app resync <name> [--source <hex>]')
const srcIdx = args.indexOf('--source')
const sourceServerKey = (srcIdx !== -1 && args[srcIdx + 1]) || null
// 1. Look up the running app's UDS via pgh-sidecar.
if (!fs.existsSync(SIDECAR_SOCK)) {
throw new Error('pgh-sidecar is not running (no socket at ' + SIDECAR_SOCK + ')')
}
const { Client } = await import('hyper-svc')
const sc = await Client.connectUds(SIDECAR_SOCK)
let lookup
try {
lookup = await sc.call('app-lookup', { name })
} finally {
try { await sc.close() } catch {}
}
if (!lookup || !lookup.found) {
throw new Error(`app '${name}' not registered with pgh-sidecar (is it running?)`)
}
// 2. Connect to the app's own UDS and call resync-from-origin.
// Wrap in a timeout so a hung RPC (daemon alive but swarm/origin
// unreachable) surfaces as an error instead of blocking the CLI
// indefinitely. Socket close already rejects via hyper-svc's
// _failAll; this covers the "pipe is silent" case.
const timeoutIdx = args.indexOf('--timeout')
const timeoutMs = (timeoutIdx !== -1 && parseInt(args[timeoutIdx + 1], 10)) || 10 * 60 * 1000
const app = await Client.connectUds(lookup.udsPath)
let result
try {
result = await Promise.race([
app.call('resync-from-origin', sourceServerKey ? { sourceServerKey } : {}),
new Promise((_, reject) => setTimeout(
() => reject(new Error('resync-from-origin timed out after ' + timeoutMs + 'ms (pass --timeout <ms> to change)')),
timeoutMs
))
])
} finally {
try { await app.close() } catch {}
}
return { app: name, uds: lookup.udsPath, ...result }
}
throw new Error('unknown app subcommand: ' + sub)
}
// ── Install via pgh:// registry resolution ───────────────────
// `pgh app update` must never move an app BACKWARDS. 2026-10-11 on stortron a
// half-synced registry view resolved pear-git-v3 "latest" = 0.1.46 (April) while
// 0.1.254 was current; only a missing tree stopped the install. An explicit
// `@version` install is not an update and is not guarded.
function refuseDowngrade (args, installName, entry) {
if (!args.includes('--no-downgrade')) return
const cur = readInstallManifest(installName)
if (cur && isOlderVersion(entry.version, cur.version) === true) {
throw new Error('refusing to downgrade ' + installName + ' from ' + cur.version + ' to ' + entry.version +
': the registry view looks stale. Check the sidecar (`pgh releases latest ' + entry.name + '`) and retry.')
}
}
// Resolve a pgh:// reference to a release entry — sidecar-first (live replica),
// else swarm-replicate the publisher's ReleaseRegistry. Mirrors the HEAD of
// installViaRegistry. Caller owns closing `registry`/`swarm`. This is the
// "registry/identity" half that stays in pgh (the engine half is the runtime).
async function resolveRelease (pgh) {
// Thin wrapper over the shared resolver (lib/resolve-release.js). The body
// used to live here, which is why embedded OTA could not resolve a release:
// the only way to learn "what is the latest tree" was to run the CLI's
// install path. Same behaviour, same sidecar-first ordering, same fd-lock
// discipline — now reachable by pgh-runtime too.
const { entry, via, cleanup } = await sharedResolveRelease({
registryKeyHex: pgh.registryKeyHex,
name: pgh.name,
version: pgh.version || undefined
})
if (via === 'sidecar') console.log(' resolved via pgh-sidecar (live replica)')
return { entry, cleanup }
}
// Collapsed install path (Phase 2): resolve the release (registry/identity,
// stays in pgh) → select a fetcher (local-closure short-circuit vs multi-topic
// network with fallback) → hand off to the pgh-runtime engine (installComplete:
// fetch closure + atomic materialize + launcher + manifest). Opt-in via
// PGH_INSTALL_VIA_RUNTIME while it is proven against the live path; once it is
// the default, installViaRegistry's duplicated tail is deleted.
async function installViaRuntime (pgh, args) {
const renameIdx = args.indexOf('--name')
const installName = (renameIdx !== -1 && args[renameIdx + 1]) || pgh.name
console.log('Installing ' + pgh.name + (pgh.version ? '@' + pgh.version : '') + ' via runtime engine (registry ' + pgh.registryKeyHex.slice(0, 12) + ')...')
const { entry, cleanup } = await resolveRelease(pgh)
if (!entry) { await cleanup(); throw new Error('no release found for ' + pgh.name + (pgh.version ? '@' + pgh.version : '') + ' in this registry') }
console.log(' found ' + entry.name + '@' + entry.version + ' tree ' + entry.treeHash.slice(0, 12) + '...')
try { refuseDowngrade(args, installName, entry) } catch (err) { await cleanup(); throw err }
const cryptoMod = (await import('hypercore-crypto')).default
const registryTopic = cryptoMod.discoveryKey(b4a.from(pgh.registryKeyHex, 'hex'))
const serverKeyTopic = entry.serverKey || null
const treeStore = new TreeStore(); treeStore.init()
const host = detectHost()
const source = 'pgh://' + pgh.registryKeyHex.slice(0, 8) + '/' + pgh.name
const skipPath = (relPath) => {
if (!relPath.startsWith('by-arch/')) return false
const parts = relPath.split('/')
return parts.length >= 2 && parts[1] !== host
}
const depList = Array.isArray(entry.deps) ? entry.deps : []
const closureHashes = [entry.treeHash]
for (const d of depList) { if (d.tree) closureHashes.push(d.tree); if (d.prebuilds && d.prebuilds[host]) closureHashes.push(d.prebuilds[host]) }
const allLocal = closureHashes.every(h => treeStore.hasTree(h))
let fetcher = null
if (allLocal) {
console.log(' closure already local (' + closureHashes.length + ' tree(s)) — skipping network fetch')
await cleanup()
// Closure is fully local → installComplete's installTree hasTree-skips every
// tree → the fetcher is never invoked. A stub guards against a logic error.
fetcher = { getTree: async () => { throw new Error('unexpected fetch in local install') }, getContent: async () => { throw new Error('unexpected fetch in local install') } }
} else {
const FETCH_TIMEOUT = 300000
// serverKey (publisher) first, shared/registry fallback — see topic-order.js
// + hyper-fleeky-dev #105.
const topicCandidates = orderedTopicCandidates({ serverKeyTopic, registryTopic })
let lastErr = null
for (const cand of topicCandidates) {
console.log(' connecting to ' + cand.label + ' topic...')
const f = new NetworkFetcher({ topic: cand.topic, timeout: FETCH_TIMEOUT })
try { await f.ready() } catch (err) { lastErr = err; console.log(' no peer on ' + cand.label + ' (' + (err && err.message) + ')'); try { await f.close() } catch {}; continue }
// Probe by fetching the root tree: a topic can have a peer yet not hold
// THIS app's content. Success means it serves us — reuse it for the deps.
try {
await installTree(treeStore, entry.treeHash, f, { source, skipPath })
fetcher = f; console.log(' using ' + cand.label + ' topic'); break
} catch (err) { lastErr = err; console.log(' root not served on ' + cand.label + ' (' + (err && err.message) + ')'); try { await f.close() } catch {} }
}
await cleanup()
if (!fetcher) throw new Error('no deploy peer found on any topic for ' + pgh.name + (lastErr ? ' (last err: ' + lastErr.message + ')' : ''))
}
// TAIL — the single engine (pgh-runtime): closure + atomic materialize +
// launcher + manifest.
const binName = renameIdx !== -1 ? installName : undefined
const res = await installComplete({ store: treeStore, release: entry, fetcher, name: installName, host, skipPath, binDir: BIN_DIR, binName })
try { if (fetcher.close) await fetcher.close() } catch {}
try {
const added = addSubscription(pgh.registryKeyHex)
if (added) console.log(' subscribed: registry ' + pgh.registryKeyHex.slice(0, 12) + '... (sidecar restart required for OTA)')
} catch (err) { console.warn(' warn: could not persist subscription: ' + err.message) }
console.log(' install: ' + res.installPath + ' (runtime=' + (entry.runtime || 'bare') + ', deps=' + depList.length + ')')
console.log(' launcher: ' + res.launcher)
return { installed: installName, version: entry.version, treeHash: entry.treeHash, path: res.installPath, launcher: res.launcher, runtime: entry.runtime || 'bare', depCount: depList.length, cached: allLocal }
}
async function installViaRegistry (pgh, args) {
// Phase 2 cutover: the single pgh-runtime engine path is now the DEFAULT.
// The duplicated tail below is retained as an env-gated fallback
// (PGH_INSTALL_VIA_REGISTRY=1) for one release so production can instantly
// revert if the runtime path misbehaves; it is deleted once it has soaked.
if (process.env.PGH_INSTALL_VIA_REGISTRY !== '1' && process.env.PGH_INSTALL_VIA_REGISTRY !== 'true') {
return installViaRuntime(pgh, args)
}
const renameIdx = args.indexOf('--name')
const installName = (renameIdx !== -1 && args[renameIdx + 1]) || pgh.name
// Profile mode — PGH_PROFILE=1 emits `[profile] +<ms>ms <label>` to
// stderr at every phase boundary so test/bench/runner.js can parse
// a per-phase timeline without per-scenario code. Purely additive:
// zero work when disabled, ~one Date.now() per phase otherwise.
const __profile = process.env.PGH_PROFILE === '1' || process.env.PGH_PROFILE === 'true'
const __t0 = Date.now()
const __mark = (label) => {
if (__profile) process.stderr.write('[profile] +' + (Date.now() - __t0) + 'ms ' + label + '\n')
}
__mark('install-start')
console.log('Installing ' + pgh.name + (pgh.version ? '@' + pgh.version : '') + ' via registry ' + pgh.registryKeyHex.slice(0, 12) + '...')
const Corestore = (await import('corestore')).default
const Hyperswarm = (await import('hyperswarm')).default
let store = null
let registry = null
let swarm = null
let entry = null
let resolvedVia = 'swarm'
// Close ALL resolution resources — crucially including the caller-owned
// registry Corestore (`store`), whose exclusive fd-lock ReleaseRegistry.close()
// does NOT release (it only closes the autobase). Leaking `store` orphans the
// lock on registries/<key16> in this process — the "File descriptor could not
// be locked" leak. Every exit below routes through here.
const closeResolution = async () => {
try { if (registry) await registry.close() } catch {}
try { if (swarm) await swarm.destroy() } catch {}
try { if (store) await store.close() } catch {}
}
// Sidecar-first (local-first per holepunch doctrine). Use the
// sidecar's live registry — either the one it owns (publisher case)
// or a warm read-only replica (subscriber case). The sidecar keeps
// replicas current via autobase update events, so we avoid the
// one-shot swarm+setTimeout replication race this CLI used to need.
if (fs.existsSync(SIDECAR_SOCK)) {
let sidecarClient = null
try {
sidecarClient = await Client.connectUds(SIDECAR_SOCK)
const res = await sidecarClient.call('resolve-release', {
registryKey: pgh.registryKeyHex,
name: pgh.name,
version: pgh.version || undefined
})
if (res && res.found && res.entry) {
entry = res.entry
resolvedVia = 'sidecar'
console.log(' resolved via pgh-sidecar (live replica)')
__mark('resolved-sidecar')
}
} catch (err) {
// Fall through to swarm; this isn't fatal
} finally {
try { if (sidecarClient) await sidecarClient.close() } catch {}
}
}
if (!entry) {
// Cross-machine path: replicate the publisher's registry via swarm
const registriesDir = path.join(homedir(), '.config', 'pgh', 'registries')
fs.mkdirSync(registriesDir, { recursive: true })
store = new Corestore(path.join(registriesDir, pgh.registryKeyHex.slice(0, 16)))
registry = new ReleaseRegistry(store, { bootstrap: b4a.from(pgh.registryKeyHex, 'hex') })
await registry.ready()
swarm = new Hyperswarm()
swarm.on('connection', (conn) => store.replicate(conn))
swarm.join(registry._base.discoveryKey, { server: false, client: true })
await swarm.flush()
// TODO: replace with event-driven wait on registry's append/update
// signal bounded by a total-timeout budget. Per-swarm-dial timing
// is the root cause here, not the sidecar path above.
await new Promise(r => setTimeout(r, 500))
await registry.update()
if (pgh.version) {
entry = await registry.get(pgh.name, pgh.version)
} else {
entry = await registry.latest(pgh.name)
}
__mark('resolved-swarm')
}
if (!entry) {
await closeResolution()
throw new Error('no release found for ' + pgh.name + (pgh.version ? '@' + pgh.version : '') + ' in this registry')
}
console.log(' found ' + entry.name + '@' + entry.version + ' tree ' + entry.treeHash.slice(0, 12) + '...')
try { refuseDowngrade(args, installName, entry) } catch (err) { await closeResolution(); throw err }
// Multi-source content distribution. Two candidate deploy topics, tried
// in order:
//
// 1. registryTopic = discoveryKey(registryKey) — the SHARED topic every
// sidecar replicating this registry seeds content on (pgh-sidecar
// 0.2.37+ calls deployServer.serveOnConnection on its registry
// connections). This is the torrent-style path: content is
// blake2b-addressed, so ANY sidecar holding the bytes serves them,
// not just the publisher. It is exactly the discoveryKey this
// installer ALREADY joins for registry replication (see the
// cross-machine path above), and the same one the sidecar joins
// (registry._base.discoveryKey).
//
// 2. entry.serverKey — the publisher's identity-publicKey topic, where
// the publisher's DeployServer has ALWAYS seeded (back-compat for
// already-published releases and for a publisher that only serves
// the identity topic). Used as a FALLBACK if the shared topic yields
// no usable peer/content.
//
// We prefer the shared topic, falling back to serverKey, so installs
// succeed against old publishers while gaining multi-source against new
// ones. The fallback cost is bounded to a single failed root-tree fetch
// (see below) — the happy path pays nothing extra.
const cryptoMod = (await import('hypercore-crypto')).default
const registryTopic = cryptoMod.discoveryKey(b4a.from(pgh.registryKeyHex, 'hex'))
const serverKeyTopic = entry.serverKey || null // hex identity pubkey, joined RAW
if (!serverKeyTopic) {
console.log(' note: release entry has no serverKey — relying on the shared registry topic only')
}
// Fetch trees + content from the publisher's deploy server
const treeStore = new TreeStore()
treeStore.init()
// Local-closure short-circuit. `pgh app update` is supposed to be a
// fallback: in the common case the sidecar's autoupdate has already
// materialized every tree we need. When the full closure is local we
// can skip NetworkFetcher entirely (no Hyperswarm DHT lookup, no
// peer wait, no chunked transfer) and just materialize+link directly.
const host = detectHost()
const depList0 = Array.isArray(entry.deps) ? entry.deps : []
const closureHashes = [entry.treeHash]
for (const d of depList0) {
if (d.tree) closureHashes.push(d.tree)
if (d.prebuilds && d.prebuilds[host]) closureHashes.push(d.prebuilds[host])
}
const allLocal = closureHashes.every(h => treeStore.hasTree(h))
if (allLocal) {
console.log(' closure already local (' + closureHashes.length + ' tree(s)) — skipping network fetch')
await closeResolution()
__mark('closure-local')
const runtime0 = entry.runtime || 'node'
const materializer0 = await loadMaterializer(runtime0)
// Atomic swap (scratch dir → rename into place) so a self-update or a
// mid-materialize failure can't corrupt the live install.
const mat0 = materializeAtomic(treeStore, materializer0, {
installName,
rootTreeHash: entry.treeHash,
deps: depList0,
host
})
console.log(' install: ' + mat0.installPath + ' (runtime=' + runtime0 + ', deps=' + mat0.depCount + ')')
if (mat0.staged) console.log(' staged: ' + installName + ' is running — this version applies at its next launch')
try {
const installedPkgPgh0 = safeReadPkgPgh(mat0.installPath) || {}
const entrypointRel0 = (installedPkgPgh0 && installedPkgPgh0.entrypoint) ||
(fs.existsSync(path.join(mat0.installPath, 'index.js')) ? 'index.js' :
fs.existsSync(path.join(mat0.installPath, 'server.js')) ? 'server.js' : 'index.js')
fs.writeFileSync(
path.join(mat0.installPath, '.pgh-install.json'),
JSON.stringify({
name: installName,
publishedName: entry.name || installName,
version: entry.version,
treeHash: entry.treeHash,
runtime: runtime0,
installedAt: new Date().toISOString(),
sourcePath: mat0.finalPath,
entrypoint: entrypointRel0,
defaults: installedPkgPgh0.defaults || {},
args: Array.isArray(installedPkgPgh0.args) ? installedPkgPgh0.args : [],
depends: Array.isArray(installedPkgPgh0.depends) ? installedPkgPgh0.depends : [],
autostart: !!installedPkgPgh0.autostart,
readiness: installedPkgPgh0.readiness || { type: 'pidfile' }
}, null, 2)
)
} catch (err) {
console.warn(' warn: could not write .pgh-install.json: ' + err.message)
}
const pghSection0 = safeReadPkgPgh(mat0.installPath)
// Explicit --name → the launcher takes THAT name (not pkg.pgh.bin), so
// installing under a custom name can't clobber another app's launcher
// (matches `pgh app adopt`). Default install (no --name) uses pkg.pgh.bin.
const binName0 = renameIdx !== -1 ? installName : ((pghSection0 && pghSection0.bin) || installName)
const entrypoint0 = (pghSection0 && pghSection0.entrypoint) || 'index.js'
fs.mkdirSync(BIN_DIR, { recursive: true })
const launcher0 = path.join(BIN_DIR, binName0)
const interp0 = runtime0 === 'bare' ? 'bare' : runtime0 === 'godot' ? 'bare' : 'node'
writeLauncher(launcher0, launcherScript({
runtimePath: livePath(resolveBundledRuntime(mat0.installPath, interp0, detectHost()), mat0.installPath, mat0.finalPath),
entryAbs: path.join(mat0.finalPath, entrypoint0),
name: installName,
store: treeStore,
binDir: BIN_DIR
}))
console.log(' launcher: ' + launcher0 + ' (' + interp0 + ')')
try {
const added0 = addSubscription(pgh.registryKeyHex)
if (added0) console.log(' subscribed: registry ' + pgh.registryKeyHex.slice(0, 12) + '... (sidecar restart required for OTA)')
} catch (err) {
console.warn(' warn: could not persist subscription: ' + err.message)
}
__mark('install-done')
return {
installed: installName,
version: entry.version,
treeHash: entry.treeHash,
path: mat0.finalPath,
staged: !!mat0.staged,
launcher: launcher0,
runtime: runtime0,
depCount: mat0.depCount,
cached: true
}
}
// 30s was too tight: trees with >1000 chunks (e.g. patched
// keet-core-hyperdb) take longer to stream over the swarm and the
// per-content timer fires mid-transfer ("content request timed out
// on peer"). 5min is overkill for small deps (irrelevant — they
// settle in well under the limit) and comfortable for large ones.
//
// Acquire a NetworkFetcher on the SHARED registry topic first; on the
// first real fetch failing (no peer / no content there), fall back to
// the publisher's serverKey topic. The fallback cost is bounded to a
// single failed root fetch, so the multi-source happy path is free.
const FETCH_TIMEOUT = 300000
// serverKey (publisher) first, shared/registry topic as fallback — see
// topic-order.js for the full rationale (fresh releases dodge the promiscuous
// shared topic's stale-peer timeout). hyper-fleeky-dev #105.
const topicCandidates = orderedTopicCandidates({ serverKeyTopic, registryTopic })
// Build a fetcher on the first candidate topic that yields a peer.
// NetworkFetcher.ready() throws if no peer arrives within the timeout;
// a topic with no seeders is a fast skip to the next candidate. We use
// a short ready-timeout here for peer DISCOVERY only — once a peer is
// found, per-request transfers get the full FETCH_TIMEOUT budget.
let fetcher = null
let activeTopicLabel = null
let lastFetcherErr = null
for (const cand of topicCandidates) {
console.log(' connecting to ' + cand.label + ' topic ' + cand.short + '...')
const f = new NetworkFetcher({ topic: cand.topic, timeout: FETCH_TIMEOUT })
try {
await f.ready()
fetcher = f
activeTopicLabel = cand.label
break
} catch (err) {
lastFetcherErr = err
console.log(' no peer on ' + cand.label + ' topic (' + (err && err.message) + ')')
try { await f.close() } catch {}
}
}
if (!fetcher) {
await closeResolution()
throw new Error('no deploy peer found on any topic for ' + pgh.name +
(lastFetcherErr ? ' (last err: ' + lastFetcherErr.message + ')' : ''))
}
console.log(' using ' + activeTopicLabel + ' topic')
__mark('fetcher-ready')
const depList = Array.isArray(entry.deps) ? entry.deps : []
const source = 'pgh://' + pgh.registryKeyHex.slice(0, 8) + '/' + pgh.name
// host was detected before the local-closure short-circuit above.
console.log(' host: ' + host)
// App-level multi-arch convention: the rootTree may carry top-level
// `by-arch/<host>/` subdirectories holding host-specific binaries
// (e.g. game executables, GDExtension .so/.dll). Skip the foreign
// ones during materialization — Merkle subtrees are content-addressed
// and fetched lazily, so a skipped subtree never crosses the wire.
const skipPath = (relPath) => {
if (!relPath.startsWith('by-arch/')) return false
const parts = relPath.split('/')
if (parts.length < 2) return false
return parts[1] !== host
}
console.log(' fetching rootTree...')
// The root fetch is also our topic-viability probe: a topic can have a
// peer (ready() succeeded) yet not hold THIS app's content — e.g. a
// subscriber sidecar that replicated the registry metadata but never
// materialized these trees. If the root fetch fails on the active topic,
// swap to the next candidate (typically serverKey) and retry. The proven
// fetcher is then reused for every dep below, so the fallback is paid at
// most once.
let rootResult = null
let rootErr = null
try {
rootResult = await installTree(treeStore, entry.treeHash, fetcher, { source, skipPath })
} catch (err) {
rootErr = err
}
if (!rootResult) {
const triedLabels = [activeTopicLabel]
for (const cand of topicCandidates) {
if (cand.label === activeTopicLabel) continue // already tried
console.log(' root not served on ' + activeTopicLabel + ' topic (' +
(rootErr && rootErr.message) + ') — falling back to ' + cand.label + ' topic ' + cand.short + '...')
try { await fetcher.close() } catch {}
const f = new NetworkFetcher({ topic: cand.topic, timeout: FETCH_TIMEOUT })
try {
await f.ready()
rootResult = await installTree(treeStore, entry.treeHash, f, { source, skipPath })
fetcher = f
activeTopicLabel = cand.label
break
} catch (err) {
rootErr = err
triedLabels.push(cand.label)
try { await f.close() } catch {}
}
}
if (!rootResult) {
try { await fetcher.close() } catch {}
await closeResolution()
throw new Error('failed to fetch rootTree ' + entry.treeHash.slice(0, 12) +
'… on any topic (' + triedLabels.join(', ') + ')' +
(rootErr ? ': ' + rootErr.message : ''))
}
console.log(' using ' + activeTopicLabel + ' topic (after fallback)')
}
console.log(' root: ' + rootResult.materialized + ' materialized, ' + rootResult.skipped + ' cached')
__mark('root-fetched')
// Full dep closure — each base tree fetched + its own-arch prebuild
// (if the dep ships per-arch trees). Other arches are skipped entirely.
const verbose = process.env.PGH_VERBOSE === '1' || process.env.PGH_VERBOSE === 'true'
const totalDeps = depList.filter(d => d.tree).length
const totalPrebuilds = depList.filter(d => d.prebuilds && d.prebuilds[host]).length
console.log(' fetching ' + totalDeps + ' dep tree(s) + ' + totalPrebuilds + ' prebuild tree(s) for ' + host + '…')
let depMaterialized = 0
let depSkipped = 0
let prebuildMat = 0
let prebuildMiss = 0
const startedAt = Date.now()
const tickEvery = verbose ? 1 : 10 // verbose: every dep; normal: every 10
// Sequential dep fetch. We tried bounded-concurrency parallelization
// (16-way + PGH_FETCH_CONCURRENCY env override) on 2026-05-13 and the
// perf bench showed it was 7× SLOWER (5m27s vs 46s sequential),
// not faster. The bottleneck is the protocol layer (hyper-overlay/v2)
// serializing concurrent requests internally + contention on the
// single deploy-server connection. CPU stayed at 6% in BOTH cases —
// we're I/O-bound at the protocol mux, not at the application layer.
//
// Real parallelism gains need to happen INSIDE NetworkFetcher /
// hyper-overlay — head-of-line-free multiplexing of content-fetch
// requests on the same protomux channel. Until that lands, sequential
// here matches the protocol's natural cadence and avoids contention.
let idx = 0
for (const dep of depList) {
if (!dep.tree) continue
idx++
if (verbose || idx % tickEvery === 0 || idx === totalDeps) {
const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1)
process.stdout.write(' [' + idx + '/' + totalDeps + '] ' + dep.name +
(verbose ? ' (tree ' + dep.tree.slice(0, 10) + '…)' : '') +
' — ' + elapsed + 's elapsed\r')
if (verbose) process.stdout.write('\n')
}
const r = await installTree(treeStore, dep.tree, fetcher, { source: source + '#' + dep.name })
depMaterialized += r.materialized
depSkipped += r.skipped
if (dep.prebuilds) {
const archHash = dep.prebuilds[host]
if (!archHash) {
prebuildMiss++
continue
}
const p = await installTree(treeStore, archHash, fetcher, { source: source + '#' + dep.name + '/' + host })
prebuildMat += p.materialized
}
}
if (!verbose) process.stdout.write('\n')
console.log(' deps: ' + depMaterialized + ' materialized, ' + depSkipped + ' cached')
console.log(' prebuilds: ' + prebuildMat + ' materialized for ' + host +
(prebuildMiss > 0 ? ' (WARNING: ' + prebuildMiss + ' dep(s) have no prebuild for ' + host + ')' : ''))
__mark('deps-fetched')
const installPath = treeStore.appPath(installName)
// Dispatch to the runtime-specific materializer. Defaults to 'node'
// when entry.runtime is absent (entries published before the field
// was added, or runtimes we haven't wired yet). Load BEFORE rm —
// when pgh is updating itself, the installPath IS pgh's own tree
// and rmSync would nuke the materializer module before we import it.
const runtime = entry.runtime || 'node'
const materializer = await loadMaterializer(runtime)
// Atomic swap (scratch dir → rename into place) so a self-update or a
// mid-materialize failure can't corrupt the live install. (Replaces the old
// rm-then-materialize-in-place, which nuked the running tree for the whole
// materialize.)
const mat = materializeAtomic(treeStore, materializer, {
installName,
rootTreeHash: entry.treeHash,
deps: depList,
host
})
console.log(' install: ' + mat.installPath + ' (runtime=' + runtime + ', deps=' + mat.depCount + ')')
if (mat.staged) console.log(' staged: ' + installName + ' is running — this version applies at its next launch')
__mark('materialized')
// Write install manifest — lets the app read its own tree hash at
// runtime (for OTA, provenance, debugging) AND carries the lifecycle
// fields `pgh app start` needs (entrypoint, depends, autostart,
// defaults.env, readiness). These are pulled from the app's own
// package.json.pgh section at install time. The .pgh-lock.json that
// lives in the source is skipped by walk-strategy (its contents
// include the tree hash, so it can't be part of the tree). This
// manifest is written AFTER materialize and is NOT part of the tree.
try {
const installedPkgPgh = safeReadPkgPgh(mat.installPath) || {}
const entrypointRel = (installedPkgPgh && installedPkgPgh.entrypoint) ||
(fs.existsSync(path.join(mat.installPath, 'index.js')) ? 'index.js' :
fs.existsSync(path.join(mat.installPath, 'server.js')) ? 'server.js' : 'index.js')
fs.writeFileSync(
path.join(mat.installPath, '.pgh-install.json'),
JSON.stringify({
name: installName,
publishedName: entry.name || installName,
version: entry.version,
treeHash: entry.treeHash,
runtime,
installedAt: new Date().toISOString(),
sourcePath: mat.finalPath,
entrypoint: entrypointRel,
defaults: installedPkgPgh.defaults || {},
args: Array.isArray(installedPkgPgh.args) ? installedPkgPgh.args : [],
depends: Array.isArray(installedPkgPgh.depends) ? installedPkgPgh.depends : [],
autostart: !!installedPkgPgh.autostart,
readiness: installedPkgPgh.readiness || { type: 'pidfile' }
}, null, 2)
)
} catch (err) {
console.warn(' warn: could not write .pgh-install.json: ' + err.message)
}
// Launcher — uses pgh section's bin/entrypoint if provided, else index.js.
const pghSection = safeReadPkgPgh(mat.installPath) // the new tree, staged or live
// Explicit --name → launcher uses that name (avoids clobbering another app's
// launcher); default install uses pkg.pgh.bin. (See the local-closure path.)
const binName = renameIdx !== -1 ? installName : ((pghSection && pghSection.bin) || installName)
const entrypoint = (pghSection && pghSection.entrypoint) || 'index.js'
fs.mkdirSync(BIN_DIR, { recursive: true })
const launcher = path.join(BIN_DIR, binName)
// Pick the right interpreter for the runtime. Bare apps use require.addon
// and bare-* built-ins that don't exist in plain Node; running them under
// `node` throws TypeError on first native binding. Godot is implicit-bare
// (its launcher boots a bare daemon, not godot itself).
const interp = runtime === 'bare' ? 'bare' : runtime === 'godot' ? 'bare' : 'node'
writeLauncher(launcher, launcherScript({
runtimePath: livePath(resolveBundledRuntime(mat.installPath, interp, detectHost()), mat.installPath, installPath),
entryAbs: path.join(installPath, entrypoint),
name: installName,
store: treeStore,
binDir: BIN_DIR
}))
console.log(' launcher: ' + launcher + ' (' + interp + ')')
__mark('launcher-written')
await fetcher.close()
__mark('fetcher-closed')
await closeResolution()
__mark('teardown-done')
// Persist the subscription so pgh-sidecar replicates this registry
// on next boot. Without this, cross-machine OTA is broken: a
// subscriber's own registry stays empty and `releases-latest`
// returns nothing. Sidecar restart required to pick up the new
// subscription (or live-reload handler, future work).
try {
const added = addSubscription(pgh.registryKeyHex)
if (added) {
console.log(' subscribed: registry ' + pgh.registryKeyHex.slice(0, 12) + '... (sidecar restart required for OTA)')
}
} catch (err) {
console.warn(' warn: could not persist subscription: ' + err.message)
}
__mark('install-done')
return {
installed: installName,
version: entry.version,
treeHash: entry.treeHash,
path: installPath,
staged: !!mat.staged,
launcher,
runtime,
depCount: mat.depCount
}
}
async function loadMaterializer (runtime) {
switch (runtime) {
// Bare and Node share the npm node_modules/ install layout, so the
// 'node' materializer (hardlink scratch dirs + symlink-wired transitive
// node_modules) works for both runtimes. Differences (bare vs node
// built-ins, exec command in the launcher) live above this layer.
case 'node':
case 'bare':
return await import('../materializers/node.js')
case 'godot': return await import('../materializers/godot.js')
default: throw new Error('no materializer for runtime: ' + runtime)
}
}
function safeLstat (p) {
try { fs.lstatSync(p); return true } catch { return false }
}
/**
* Detect the current host's platform-arch tag, matching require-addon's
* convention: `${platform}-${arch}` with `-musl` suffix on Alpine.
* This is the key used to index into entry.deps[i].prebuilds.
*/
function detectHost () {
let host = process.platform + '-' + process.arch
// Alpine detection: /etc/os-release has ID=alpine
try {
const os = fs.readFileSync('/etc/os-release', 'utf8')
if (/^ID=alpine/m.test(os)) host += '-musl'
} catch {}
return host
}
function safeReadPkgPgh (dir) {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
return pkg.pgh || null
} catch {
return null
}
}
// ── Lifecycle: start/stop/restart/status/log/autostart/boot/shutdown/adopt ──
async function cmdStart (args) {
const name = args[1]
if (!name) throw new Error('usage: pgh app start <name>')
const existing = readPid(name)
if (existing && isPidAlive(existing)) {
return { name, started: false, reason: 'already running', pid: existing }
}
if (existing) clearPid(name) // stale
const manifest = readInstallManifest(name)
if (!manifest) throw new Error('app "' + name + '" is not installed (no .pgh-install.json)')
const sourcePath = manifest.sourcePath || appDir(name)
const entrypoint = path.isAbsolute(manifest.entrypoint || '')
? manifest.entrypoint
: path.join(sourcePath, manifest.entrypoint || 'index.js')
if (!fs.existsSync(entrypoint)) {
throw new Error('entrypoint not found: ' + entrypoint)
}
const runtime = manifest.runtime || 'node'
const childEnv = effectiveEnv(name, manifest)
// Surface the pgh-install-name so the app uses it when registering
// with pgh-sidecar — important when the install name differs from
// package.json name (e.g. adopted dev installs like pear-git-v3-dev).
childEnv.PGH_APP_NAME = name
const childArgs = effectiveArgs(name, manifest)
// Clean up a stale UDS from a prior run. We already confirmed no
// live supervisor owns this app, so the socket (if any) is orphaned.
if (manifest.readiness && manifest.readiness.type === 'uds') {
const udsPath = expandEnvStr(manifest.readiness.path, childEnv)
if (udsPath) { try { fs.unlinkSync(udsPath) } catch {} }
}
rotateLogIfNeeded(name)
const logFd = openLogForAppend(name)
// Resolve the child runtime to an absolute path when possible. On a
// clean machine without system `bare` (or with a too-old system
// `node`), PATH-lookup fails. When this CLI's own interpreter
// basename matches the child runtime (the common case: bundled bare
// CLI -> bare app), pass our execPath. Otherwise leave unset and let
// the supervisor fall back to PATH.
const interpBase = path.basename(process.execPath)
let childRuntimePath = (interpBase === runtime) ? process.execPath : ''
// Prefer the app's OWN bundled bare (self-contained tree) so it runs under
// its own runtime version, independent of this CLI's interpreter / PATH.
if (runtime === 'bare') {
const bundled = resolveBundledRuntime(sourcePath, 'bare', detectHost())
if (bundled !== 'bare') childRuntimePath = bundled
}
const supEnv = {
...process.env,
PGH_SUP_NAME: name,
PGH_SUP_ENTRYPOINT: entrypoint,
PGH_SUP_RUNTIME: runtime,
PGH_SUP_RUNTIME_PATH: childRuntimePath,
PGH_SUP_ARGS_JSON: JSON.stringify(childArgs),
PGH_SUP_CHILD_ENV: JSON.stringify(childEnv)
}
// Spawn the supervisor with the same interpreter this CLI is running
// under. The supervisor's own `import process from '#node:process'`
// requires Node >= 14.6 (package.json `imports` field), so a system
// Node v12 on a clean machine would fail with ERR_MODULE_NOT_FOUND.
// process.execPath = the bundled bare on bare-bootstrap installs.
//
// bare-subprocess's `detached: true` doesn't call setsid() the way
// Node.js does — the supervisor stays in the parent CLI's session
// and inherits SIGHUP/SIGTERM when the CLI exits. Wrap with `setsid`
// (coreutils) on POSIX so the supervisor is the leader of its own
// session and truly outlives the parent. Falls back to plain spawn
// on platforms without setsid (Windows, some BSDs).
const useSetsid = process.platform !== 'win32' && fs.existsSync('/usr/bin/setsid')
const spawnCmd = useSetsid ? '/usr/bin/setsid' : process.execPath
const spawnArgs = useSetsid
? [process.execPath, SUPERVISOR_SCRIPT]
: [SUPERVISOR_SCRIPT]
const supervisor = spawn(spawnCmd, spawnArgs, {
detached: true,
stdio: ['ignore', logFd, logFd],
env: supEnv,
cwd: sourcePath
})
supervisor.unref()
try { fs.closeSync(logFd) } catch {}
// Do NOT writePid(supervisor.pid) here: when wrapped in setsid (see above)
// supervisor.pid is the setsid wrapper, which dies the moment it forks the
// real supervisor — leaving a stale, dead pid in app.pid and making
// `status` report running:false for a live daemon. The supervisor writes
// its own real pid to app.pid at startup (lib/supervisor.js), before it
// signals readiness via state.childPid, so app.pid is correct by the time
// the readiness probe below returns.
// Readiness probe — default to pidfile (waits for supervisor to spawn
// the child, visible via state file's childPid).
const readiness = manifest.readiness || { type: 'pidfile' }
const probeName = { ...readiness, name }
try {
await waitReady(probeName, childEnv)
} catch (err) {
return {
name,
started: true,
pid: supervisor.pid,
ready: false,
reason: err.message,
log: logFile(name)
}
}
return {
name,
started: true,
pid: supervisor.pid,
ready: true,
log: logFile(name)
}
}
// Stop one supervisor process group. The supervisor was spawned via
// setsid, so it's a group leader (pgid = pid). kill(-pid, sig) signals
// the supervisor AND every descendant — critical on SIGKILL, else the
// app child gets orphaned and keeps the corestore lock. Returns
// { pid, escalated? } or null if the group was already gone.
async function stopGroup (pid) {
try { process.kill(-pid, 'SIGTERM') } catch (err) {
if (err.code === 'ESRCH') return null
throw err
}
// Daemon shutdown can be slow (autobase close, swarm drain, sidecar
// unregister). 30s is generous but still bounded.
const deadline = Date.now() + 30000
while (Date.now() < deadline) {
if (!isPidAlive(pid)) return { pid }
await sleep(250)
}
try { process.kill(-pid, 'SIGKILL') } catch {}
return { pid, escalated: 'SIGKILL' }
}
async function cmdStop (args) {
const name = args[1]
if (!name) throw new Error('usage: pgh app stop <name>')
// Resolve which supervisor group(s) to stop. Prefer the pid file, but
// when it's missing or stale fall back to discovering the live
// supervisor(s) by their PGH_SUP_NAME env. Without this, a stale pid
// leaves the real daemon+supervisor running (holding the store lock /
// port) and the next `app start` collides — exactly what bit the
// experimental sidecar restart on 2026-06-02. Discovery returns all
// matches so an orphan herd is reaped in one stop.
let pid = readPid(name)
if (pid && !isPidAlive(pid)) { clearPid(name); pid = null }
let via = 'pidfile'
let targets = pid ? [pid] : []
if (!targets.length) {
targets = findRunningSupervisors(name)
if (targets.length) via = 'discovered'
}
if (!targets.length) {
return { name, stopped: false, reason: 'not running (no pid file, no live supervisor found)' }
}
const reaped = []
for (const t of targets) {
const r = await stopGroup(t)
if (r) reaped.push(r)
}
// Always clear the pid file so we never leave a dangling one behind
// (the supervisor clears it on clean exit, but not on SIGKILL/crash).
clearPid(name)
if (!reaped.length) {
return { name, stopped: false, reason: 'ESRCH (no live group)', via, pid: targets[0] }
}
const escalated = reaped.some((r) => r.escalated) ? 'SIGKILL' : undefined
const result = { name, stopped: true, pid: reaped[0].pid, via }
if (escalated) result.escalated = escalated
if (reaped.length > 1) result.reapedOrphans = reaped.map((r) => r.pid)
return result
}
async function cmdRestart (args) {
const name = args[1]
if (!name) throw new Error('usage: pgh app restart <name>')
const stopped = await cmdStop(['stop', name])
const started = await cmdStart(['start', name])
return { name, restarted: true, stopped, started }
}
// Re-populate the pgh-sidecar's *runtime* app registry (name → udsPath/pid)
// for an already-running daemon. This is the manual escape hatch for the
// boot race where the sidecar's UDS appears later than the daemon's
// registration wait (e.g. the sidecar's slow cold-boot store index): the
// daemon gives up registering and `app-lookup` then fails for every pgh
// daemon command (status, org, issue, repo…). Registration is normally
// daemon-initiated and self-healing, but if the heartbeat never armed there
// was no way to recover without restarting the prod daemon.
//
// We derive the daemon's own UDS exactly as the daemon + `app start` do —
// `manifest.readiness.path` with env expansion — so we point the sidecar at
// the same socket the daemon is actually serving. `app-register` is
// last-writer-wins + idempotent, so this is always safe. We then verify via
// `app-lookup` that the entry took. NOTE: this only touches the sidecar's
// in-memory/apps.json runtime registry — NOT the NameRegistry autobase.
async function cmdReregister (args) {
const name = args.slice(1).find(a => a && !a.startsWith('-'))
if (!name) throw new Error('usage: pgh app reregister <name> [--uds <path>] [--pid <n>]')
const manifest = readInstallManifest(name)
if (!manifest) throw new Error('app "' + name + '" is not installed (no .pgh-install.json)')
// udsPath: --uds override > manifest.readiness.path (env-expanded).
const udsIdx = args.indexOf('--uds')
let udsPath = (udsIdx !== -1 && args[udsIdx + 1]) || null
if (!udsPath) {
if (!manifest.readiness || manifest.readiness.type !== 'uds' || !manifest.readiness.path) {
throw new Error('app "' + name + '" has no readiness.uds path in its manifest — pass --uds <path>')
}
const childEnv = effectiveEnv(name, manifest)
childEnv.PGH_APP_NAME = name
udsPath = expandEnvStr(manifest.readiness.path, childEnv)
}
if (!udsPath) throw new Error('could not determine daemon UDS path for "' + name + '" — pass --uds <path>')
// The daemon must actually be listening there, or we'd register a dead
// pointer that fails every subsequent lookup.
if (!fs.existsSync(udsPath)) {
throw new Error('daemon UDS not found at ' + udsPath + ' — is "' + name + '" running? (`pgh app status ' + name + '`)')
}
// pid: --pid override > running child pid > supervisor pid (best-effort,
// optional for app-register).
const pidIdx = args.indexOf('--pid')
let pid = (pidIdx !== -1 && parseInt(args[pidIdx + 1], 10)) || null
if (!pid) {
const state = readState(name)
pid = (state && state.childPid) || readPid(name) || null
}
if (!fs.existsSync(SIDECAR_SOCK)) {
throw new Error('pgh-sidecar is not running (no socket at ' + SIDECAR_SOCK + ')')
}
const sc = await Client.connectUds(SIDECAR_SOCK)
let lookup
try {
await sc.call('app-register', {
name,
udsPath,
pid,
runtime: manifest.runtime || 'node',
version: manifest.version || null,
treeHash: manifest.treeHash || null
})
lookup = await sc.call('app-lookup', { name })
} finally {
try { await sc.close() } catch {}
}
const ok = !!(lookup && lookup.found && lookup.udsPath === udsPath)
if (!ok) {
throw new Error('re-registered but app-lookup did not confirm (got ' + JSON.stringify(lookup) + ')')
}
return { app: name, reregistered: true, udsPath, pid, registered: lookup.found, lookupUdsPath: lookup.udsPath }
}
async function cmdStatus (args) {
const verbose = args.includes('-v') || args.includes('--verbose')
// Drop flag args before extracting the name positional. Args is
// [<sub>, <name?>, ...flags] and the sub is already consumed by the
// dispatcher — so args[1] is the name when present, but flags can
// appear in any position.
const name = args.slice(1).find(a => a && !a.startsWith('-'))
if (!name) {
if (verbose) {
throw new Error('--verbose requires a single app name (`pgh app status <name> -v`)')
}
return listAppNames().map(n => basicStatus(n))
}
const basic = basicStatus(name)
let sidecarUdsPath = null
try {
if (fs.existsSync(SIDECAR_SOCK)) {
const sc = await Client.connectUds(SIDECAR_SOCK)
try {
const r = await sc.call('app-lookup', { name })
if (r && r.found) {
basic.sidecar = { registered: true, udsPath: r.udsPath, pid: r.pid }
sidecarUdsPath = r.udsPath
} else basic.sidecar = { registered: false }
} finally { try { await sc.close() } catch {} }
} else {
basic.sidecar = { registered: false, reason: 'sidecar not running' }
}
} catch (err) {
basic.sidecar = { registered: false, error: err.message }
}
if (!verbose) return basic
// Verbose path — connect to the daemon's UDS (if registered) and
// gather a snapshot of swarm + autobase + OTA state. Each daemon RPC
// is wrapped in try/catch so a single failure doesn't blank the rest.
//
// Trust sidecar registration over `basic.running`. On legacy installs
// (pre-0.1.23) where pid/state files lived inside the install path,
// a recent `pgh app install` would wipe the state file and report
// `running: false` even though the daemon was alive and registered.
// The 0.1.23 relocation fixes that, but sidecar is still the more
// authoritative signal — it knows whether the daemon process actually
// talked to it.
if (!sidecarUdsPath) {
return renderVerboseStatus(basic, null)
}
let daemonState = null
try {
daemonState = await gatherDaemonState(sidecarUdsPath, name)
} catch (err) {
return renderVerboseStatus(basic, { error: err.message })
}
// OTA latest from sidecar — separate channel from the app's daemon UDS.
try {
if (fs.existsSync(SIDECAR_SOCK)) {
const sc = await Client.connectUds(SIDECAR_SOCK)
try { daemonState.otaLatest = await sc.call('releases-latest', { name }) }
catch (err) { daemonState.otaLatest = { error: err.message } }
finally { try { await sc.close() } catch {} }
}
} catch {}
return renderVerboseStatus(basic, daemonState)
}
/**
* Open a UDS connection to the named app's daemon and gather verbose
* health: status RPC (counts), debug-swarm-peers (peer detail),
* dump-mirror-writers (autobase health). Each call is independently
* try/caught — partial failure populates what works and reports
* errors per-section.
*/
async function gatherDaemonState (udsPath, _appName) {
const client = await Client.connectUds(udsPath)
const callOr = async (method, params) => {
try { return await client.call(method, params || {}) }
catch (err) { return { error: err && err.message ? err.message : String(err) } }
}
try {
const [status, swarmPeers, mirrorWriters] = await Promise.all([
callOr('status'),
callOr('debug-swarm-peers'),
callOr('dump-mirror-writers')
])
// Best-effort role inference. The daemon doesn't yet expose `role`
// directly; mirror has activeWriters >= 2 (primary + at least one
// mirror writer), primary alone has activeWriters === 1.
let role = 'unknown'
if (mirrorWriters && !mirrorWriters.error && mirrorWriters.autobaseState) {
const aw = mirrorWriters.autobaseState.activeWriters
const ic = mirrorWriters.autobaseState.linearizer
? mirrorWriters.autobaseState.linearizer.indexerCount
: null
if (aw === 1 && ic === 1) role = 'primary'
else if (typeof aw === 'number' && aw >= 2) role = 'mirror'
}
return { status, swarmPeers, mirrorWriters, role }
} finally {
try { await client.close() } catch {}
}
}
/**
* Pure renderer: combines the existing basic status with a snapshot of
* daemon RPC responses. Exported for testing.
*
* `daemonState === null` ⇒ daemon UDS unreachable.
* `daemonState.error` ⇒ connect succeeded but the gather threw.
* Each subsection of daemonState may be `undefined` (RPC not called)
* or `{ error }` (RPC called, threw); both are tolerated.
*/
export function renderVerboseStatus (basic, daemonState) {
if (daemonState === null || daemonState === undefined) {
return { ...basic, daemon: { available: false, reason: 'daemon UDS unreachable' } }
}
if (daemonState.error) {
return { ...basic, daemon: { available: false, reason: daemonState.error } }
}
const errors = {}
const out = { ...basic, daemon: { available: true } }
const d = out.daemon
if (daemonState.role) d.role = daemonState.role
if (daemonState.originServerKey) {
d.originServerKey = daemonState.originServerKey.slice(0, 16) + '...'
}
if (daemonState.status && !daemonState.status.error) {
const s = daemonState.status
if (s.users && typeof s.users.total === 'number') d.users = s.users.total
if (typeof s.orgs === 'number') d.orgs = s.orgs
if (s.issues) d.issues = s.issues
if (s.swarm) {
d.swarm = {
topic: s.swarm.topic,
peers: typeof s.swarm.connections === 'number' ? s.swarm.connections : 0
}
}
} else if (daemonState.status && daemonState.status.error) {
errors.status = daemonState.status.error
}
if (daemonState.swarmPeers) {
if (daemonState.swarmPeers.error) {
errors.swarmPeers = daemonState.swarmPeers.error
} else {
d.swarm = d.swarm || {}
if (typeof daemonState.swarmPeers.total === 'number') {
d.swarm.peers = daemonState.swarmPeers.total
}
if (Array.isArray(daemonState.swarmPeers.peers)) {
d.swarm.peerDetails = daemonState.swarmPeers.peers
}
}
}
if (daemonState.mirrorWriters) {
if (daemonState.mirrorWriters.error) {
errors.mirrorWriters = daemonState.mirrorWriters.error
} else {
const mw = daemonState.mirrorWriters
const ab = mw.autobaseState || {}
d.autobase = {
identity: {
viewLength: mw.identityViewLength,
writable: mw.identityWritable,
activeWriters: ab.activeWriters,
indexerCount: ab.linearizer ? ab.linearizer.indexerCount : null,
systemCoreLength: ab.systemCoreLength,
ackTimerArmed: ab.ackTimerArmed
}
}
}
}
if (daemonState.otaLatest) {
if (daemonState.otaLatest.error) {
errors.otaLatest = daemonState.otaLatest.error
} else {
d.ota = {
currentVersion: basic.version,
latestVersion: daemonState.otaLatest.version,
upToDate: basic.version === daemonState.otaLatest.version
}
}
}
if (Object.keys(errors).length > 0) d.errors = errors
return out
}
function basicStatus (name) {
const pid = readPid(name)
const alive = isPidAlive(pid)
const state = readState(name)
const manifest = readInstallManifest(name)
return {
name,
running: alive,
supervisorPid: alive ? pid : null,
childPid: alive ? (state.childPid || null) : null,
version: manifest && manifest.version,
startedAt: state.startedAt || null,
lastExit: state.lastExit || null,
lastExitCode: state.lastExitCode != null ? state.lastExitCode : null,
autostart: effectiveAutostart(name, manifest),
log: logFile(name),
...staleness(name, { alive, supervisorPid: pid, childPid: state.childPid || null, manifest })
}
}
// INSTALLED != RUNNING. `version` above is the INSTALL TREE's version — it says nothing about the code
// the live process is executing, because `pgh app update` materializes a new tree at the SAME path while
// the old process keeps running the old code. Reading a version back off disk therefore cannot detect
// this: the path now holds the NEW tree either way.
//
// What IS decidable: a process that started BEFORE the current tree was installed cannot be running it.
// That comparison is the whole check, and it is the one that has repeatedly been done by hand
// (`ps -o etimes` against the install tree) — twice in one day on 2026-07-27, finding a box that reported
// 0.3.65 while running days-old code.
//
// Deliberately conservative: `stale` is null (not false) whenever the inputs are unknown, so an app whose
// start time or install time cannot be read is never reported as healthy.
// How much "process started before the manifest was written" is just the install/respawn race rather
// than old code. Measured skew on a healthy app is ~2s; days is the failure being caught.
const RESPAWN_SKEW_MS = 60000
function staleness (name, { alive, supervisorPid, childPid, manifest }) {
// The CHILD executes the app code; the supervisor merely respawns it. Prefer the child, fall back to
// the supervisor, and only then to pgh's own bookkeeping.
const pid = alive ? ((childPid && isPidAlive(childPid)) ? childPid : supervisorPid) : null
const started = alive ? (processStartedAt(pid) || readState(name).startedAt || null) : null
return classifyStale({ running: alive, installedAt: (manifest && manifest.installedAt) || null, startedAt: started, version: manifest && manifest.version })
}
// The decision, separated from the I/O so it can be tested exhaustively (same shape as
// renderVerboseStatus). `now` is injectable for the same reason.
export function classifyStale ({ running, installedAt = null, startedAt = null, version = null, now = Date.now() } = {}) {
const started = startedAt
if (!running) return { installedAt, processStartedAt: null, uptimeSeconds: null, skewSeconds: null, stale: false, staleReason: 'not running' }
const uptimeSeconds = started ? Math.max(0, Math.round((now - Date.parse(started)) / 1000)) : null
if (!installedAt) return { installedAt, processStartedAt: started, uptimeSeconds, skewSeconds: null, stale: null, staleReason: 'no installedAt in the install manifest' }
if (!started) return { installedAt, processStartedAt: null, uptimeSeconds: null, skewSeconds: null, stale: null, staleReason: 'could not determine process start time' }
const manifest = { version }
const behindMs = Date.parse(installedAt) - Date.parse(started)
// Install and respawn are NOT atomic with respect to each other: on an OTA update the app exits (75),
// the supervisor respawns it, and the manifest write races that respawn. Measured on a healthy
// tabertron app: processStartedAt 1.7s BEFORE installedAt — which a naive `installedAt > startedAt`
// reports as stale. Only a margin well beyond that ordering noise is evidence of old code; the failure
// this exists to catch is a process running code that is DAYS old. skewSeconds is always reported, so a
// borderline case is visible rather than hidden behind the threshold.
const skewSeconds = Math.round(behindMs / 1000)
if (behindMs > RESPAWN_SKEW_MS) {
return {
installedAt,
processStartedAt: started,
uptimeSeconds,
skewSeconds,
stale: true,
staleReason: `running process started ${skewSeconds}s BEFORE the current tree was installed — it is not running ${manifest.version || 'the installed version'}; restart it`
}
}
return { installedAt, processStartedAt: started, uptimeSeconds, skewSeconds, stale: false, staleReason: null }
}
// `pgh app stale` — the fleet check, in one invocation per host.
//
// Answers "is any app on this box running code it is not supposed to be running", which was previously an
// ad-hoc sweep comparing `ps -o etimes` against install-tree mtimes, reinvented each time it was needed.
// Across hosts this composes the same way as the rest of the stack: `ssh <host> pgh app stale`.
//
// `stale: null` (unknown) counts as a hit, because "I could not tell" is exactly the answer that must not
// be silently rendered as healthy — the whole failure mode here is a green surface over old code.
function cmdStale (args) {
const all = args.includes('--all')
const rows = listAppNames().map(n => basicStatus(n)).map(s => ({
name: s.name,
running: s.running,
installedVersion: s.version || null,
installedAt: s.installedAt || null,
processStartedAt: s.processStartedAt || null,
uptimeSeconds: s.uptimeSeconds,
stale: s.stale,
staleReason: s.staleReason
}))
const flagged = rows.filter(r => r.stale === true || r.stale === null)
return {
host: (() => { try { return hostname() } catch { return null } })(),
at: new Date().toISOString(),
apps: all ? rows : flagged,
checked: rows.length,
stale: rows.filter(r => r.stale === true).map(r => r.name),
unknown: rows.filter(r => r.stale === null).map(r => r.name),
// A restart is the fix; naming it here saves the next person deriving it under pressure.
hint: flagged.length ? 'restart the flagged apps: pgh app restart <name>' : 'every running app started after its current tree was installed'
}
}
async function cmdLog (args) {
const name = args[1]
if (!name) throw new Error('usage: pgh app log <name> [-f|--follow] [--lines N]')
const follow = args.includes('-f') || args.includes('--follow')
const linesIdx = args.indexOf('--lines')
const lines = linesIdx !== -1 ? parseInt(args[linesIdx + 1], 10) : 200
const lp = logFile(name)
if (!fs.existsSync(lp)) {
return { name, log: lp, exists: false }
}
// Delegate to `tail` — it already does the right thing for our volume
// and honours SIGINT for --follow. Running inline so stdout streams.
return await new Promise((resolve, reject) => {
const tailArgs = follow ? ['-n', String(lines), '-F', lp] : ['-n', String(lines), lp]
const tail = spawn('tail', tailArgs, { stdio: ['ignore', 'inherit', 'inherit'] })
tail.on('exit', code => code === 0 || code === 130 ? resolve(undefined) : reject(new Error('tail exited ' + code)))
tail.on('error', reject)
})
}
// Admin setting: does THIS machine serve bootstrap tarballs for arches it cannot
// build? Only a forge backend answers a cold-start HTTPS /bootstrap fetch; every
// other peer installs over hyper-deploy/overlay and never needs a foreign arch's
// ~150MB. Off by default — turn it on for the backends behind the public endpoint.
// Read by pgh-sidecar (_isBootstrapServeEnabled) and passed to
// reconcileBootstrapArtifacts as { serveAllArches }.
function cmdBootstrapServe (args) {
const name = args[1]
const state = args[2]
if (!name || (state !== 'on' && state !== 'off')) {
throw new Error('usage: pgh app bootstrap-serve <name> on|off')
}
const cfg = readUserConfig()
if (!cfg[name]) cfg[name] = {}
cfg[name].bootstrapServeAllArches = state === 'on'
writeUserConfig(cfg)
return { name, bootstrapServeAllArches: cfg[name].bootstrapServeAllArches }
}
function cmdAutostart (args) {
const name = args[1]
const state = args[2]
if (!name || (state !== 'on' && state !== 'off')) {
throw new Error('usage: pgh app autostart <name> on|off')
}
const cfg = readUserConfig()
if (!cfg[name]) cfg[name] = {}
cfg[name].autostart = state === 'on'
writeUserConfig(cfg)
return { name, autostart: cfg[name].autostart }
}
async function cmdBoot (args) {
const all = listAppNames()
const autostarts = all.filter(n => effectiveAutostart(n, readInstallManifest(n)))
if (autostarts.length === 0) return { booted: [], skipped: all, reason: 'no apps have autostart enabled' }
const order = topoSort(autostarts)
// Port-conflict guard: two apps declaring the same pgh.service.port can't both bind it. Starting
// the second just spawns a crash-looper (EADDRINUSE -> SIGABRT -> supervisor respawn storm) that
// masquerades as "started". The earlier app in topo-order owns the port; later same-port apps are
// skipped with a legible reason. (Root cause of the tabertron atproto-pds/atproto-bridge :3399
// clash — a stale duplicate install, both set to autostart.)
const skips = portConflictSkips(order, servicePortOf)
const results = []
for (const name of order) {
if (skips.has(name)) {
results.push({ name, started: false, skipped: true, reason: skips.get(name) })
continue
}
const already = readPid(name)
if (already && isPidAlive(already)) {
results.push({ name, started: false, reason: 'already running', pid: already })
continue
}
try {
const r = await cmdStart(['start', name])
results.push(r)
if (!r.ready) {
results.push({ name, warning: 'started but not ready — downstream apps may fail their readiness probes' })
}
} catch (err) {
results.push({ name, started: false, error: err.message })
}
}
return { booted: results }
}
// The declared service port for an app (pgh.service.port from its package.json), or null. Kept
// separate so cmdBoot's port-conflict planning is a pure, testable function of (order, portOf).
function servicePortOf (name) {
const pgh0 = readPackageJsonPgh(name)
return (pgh0 && pgh0.service && pgh0.service.port != null) ? pgh0.service.port : null
}
async function cmdShutdown () {
const all = listAppNames()
const running = all.filter(n => {
const pid = readPid(n)
return pid && isPidAlive(pid)
})
if (running.length === 0) return { stopped: [], reason: 'no apps running' }
const order = topoSort(running).reverse()
const results = []
for (const name of order) {
try {
const r = await cmdStop(['stop', name])
results.push(r)
} catch (err) {
results.push({ name, stopped: false, error: err.message })
}
}
return { stopped: results }
}
function cmdAdopt (args) {
const srcPath = args[1]
if (!srcPath) throw new Error('usage: pgh app adopt <path> [--name <name>]')
const abs = path.resolve(srcPath)
if (!fs.existsSync(abs)) throw new Error('path does not exist: ' + abs)
const pkgPath = path.join(abs, 'package.json')
if (!fs.existsSync(pkgPath)) throw new Error('no package.json at ' + abs)
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
const pghSection = pkg.pgh || {}
const nameIdx = args.indexOf('--name')
const name = (nameIdx !== -1 && args[nameIdx + 1]) || pkg.name
if (!name) throw new Error('could not determine app name — pass --name <name>')
const dir = appDir(name)
// Allow IN-PLACE adoption: when the source IS the app dir itself (e.g. a
// bootstrap tarball extracted straight into store/apps/<name>), just write
// the manifest in place. Only reject a DIFFERENT, non-adopted dir already
// occupying the name. Without the `abs !== dir` exception, the bootstrap's
// own `pgh app adopt store/apps/pgh-sidecar` always failed → the sidecar was
// left unmanaged (no .pgh-install.json, invisible to `pgh app boot`).
if (fs.existsSync(dir) && !isAdoptedDir(dir) && path.resolve(dir) !== abs) {
throw new Error(dir + ' exists and is not an adopted app — remove first or choose another --name')
}
mkdirSync(dir, { recursive: true })
const entrypointRel = pghSection.entrypoint ||
(fs.existsSync(path.join(abs, 'index.js')) ? 'index.js' :
fs.existsSync(path.join(abs, 'server.js')) ? 'server.js' :
(pkg.main || 'index.js'))
const manifest = {
name,
adopted: true,
sourcePath: abs,
version: pkg.version || '0.0.0-adopted',
runtime: pghSection.runtime || 'node',
installedAt: new Date().toISOString(),
entrypoint: entrypointRel,
defaults: pghSection.defaults || {},
args: Array.isArray(pghSection.args) ? pghSection.args : [],
depends: Array.isArray(pghSection.depends) ? pghSection.depends : [],
autostart: !!pghSection.autostart,
readiness: pghSection.readiness || { type: 'pidfile' }
}
writeInstallManifest(name, manifest)
// Bin name = adopted name (not pkg.pgh.bin) so `pgh app adopt . --name
// foo-dev` doesn't clobber an existing install's launcher that uses
// pkg.pgh.bin. Explicit --bin lets you override.
const binIdx = args.indexOf('--bin')
const binName = (binIdx !== -1 && args[binIdx + 1]) || name
mkdirSync(BIN_DIR, { recursive: true })
const launcher = path.join(BIN_DIR, binName)
const entryAbs = path.isAbsolute(entrypointRel) ? entrypointRel : path.join(abs, entrypointRel)
writeLauncher(
launcher,
'#!/bin/sh\nexec ' + resolveBundledRuntime(abs, manifest.runtime === 'bare' ? 'bare' : 'node', detectHost()) + ' ' + entryAbs + ' "$@"\n'
)
return { adopted: name, sourcePath: abs, manifest: installManifestFile(name), launcher }
}
async function cmdHeal (args) {
// `pgh app heal [name]` — no name = heal all installed apps whose
// daemon exposes a heal-repos RPC. Each app's daemon iterates its
// own reposDir, fscks each bare repo, and calls sync.pull (which
// has the fsck→--refetch→force-reclone tier) for broken ones.
const name = args[1]
const targets = name ? [name] : listAppNames()
if (!fs.existsSync(SIDECAR_SOCK)) {
throw new Error('pgh-sidecar is not running (no socket at ' + SIDECAR_SOCK + ')')
}
const aggregate = []
for (const app of targets) {
const sc = await Client.connectUds(SIDECAR_SOCK)
let lookup
try { lookup = await sc.call('app-lookup', { name: app }) } finally { try { await sc.close() } catch {} }
if (!lookup || !lookup.found) {
aggregate.push({ app, skipped: true, reason: 'not registered with sidecar' })
continue
}
const dc = await Client.connectUds(lookup.udsPath)
let res
try { res = await dc.call('heal-repos', {}) } catch (err) {
aggregate.push({ app, error: err.message })
continue
} finally { try { await dc.close() } catch {} }
if (!res || res.error) {
aggregate.push({ app, error: res ? res.error : 'empty response' })
continue
}
aggregate.push({ app, ...res })
}
return { apps: aggregate }
}
async function cmdGc (args) {
const name = args[1]
if (!name) throw new Error('usage: pgh app gc <name>')
const manifest = readInstallManifest(name)
if (!manifest) throw new Error('app "' + name + '" is not installed')
// Resolve the bare-git-repos dir from the app's effective env. We
// assume a PEAR_GIT_REPOS-style env — keeps the command MVP-simple.
// Other apps with different storage layouts will need per-app gc
// strategies (future: manifest.gc = { strategy, pathEnv }).
const env = effectiveEnv(name, manifest)
const reposDir = env.PEAR_GIT_REPOS || path.join(homedir(), 'git-repos')
if (!fs.existsSync(reposDir)) {
throw new Error('repos dir does not exist: ' + reposDir)
}
const dirs = fs.readdirSync(reposDir).filter(d => d.endsWith('.git'))
if (dirs.length === 0) return { app: name, reposDir, results: [], note: 'no bare repos found' }
const results = []
let totalBefore = 0
let totalAfter = 0
for (const d of dirs) {
const repoPath = path.join(reposDir, d)
const before = dirSize(repoPath)
totalBefore += before
const gc = await runGitGc(repoPath)
const after = dirSize(repoPath)
totalAfter += after
results.push({
repo: d.replace(/\.git$/, ''),
before,
after,
saved: before - after,
error: gc.error || null
})
}
return {
app: name,
reposDir,
results,
totalBefore,
totalAfter,
totalSaved: totalBefore - totalAfter
}
}
function dirSize (p) {
let size = 0
const walk = (d) => {
let entries
try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
for (const e of entries) {
const full = path.join(d, e.name)
try {
if (e.isDirectory()) walk(full)
else if (e.isFile()) size += fs.statSync(full).size
} catch {}
}
}
walk(p)
return size
}
function runGitGc (repoPath) {
return new Promise((resolve) => {
const proc = spawn('git', ['-C', repoPath, 'gc', '--prune=now', '--quiet'], {
stdio: ['ignore', 'pipe', 'pipe']
})
const errChunks = []
proc.stderr.on('data', (d) => errChunks.push(d))
proc.on('exit', (code) => {
if (code === 0) resolve({ ok: true })
else resolve({ error: 'git gc exit ' + code + ': ' + Buffer.concat(errChunks).toString('utf-8').trim() })
})
proc.on('error', (err) => resolve({ error: err.message }))
})
}
function isAdoptedDir (dir) {
try {
const m = JSON.parse(fs.readFileSync(path.join(dir, '.pgh-install.json'), 'utf8'))
return !!m.adopted
} catch { return false }
}
function sleep (ms) { return new Promise(r => setTimeout(r, ms)) }
// Legacy daemon-mediated install (v3-specific) — unchanged.
async function installViaDaemon (client, repo, args) {
const nameIdx = args.indexOf('--name')
const name = (nameIdx !== -1 && args[nameIdx + 1]) || repo
const dest = path.join(APPS_DIR, name)
if (fs.existsSync(dest)) throw new Error(dest + ' already exists — use update or remove first')
const info = await client.call('repo-info', { name: repo })
const serverKey = info.serverKey || info.syncServerKey
const cloneUrl = serverKey
? 'punch://' + serverKey + '/' + repo
: 'http://127.0.0.1:4040/' + repo + '.git'
console.log('Installing ' + repo + ' → ' + dest)
fs.mkdirSync(dest, { recursive: true })
execSync('git clone --depth 1 -b release ' + cloneUrl + ' ' + dest, { stdio: 'inherit' })
if (fs.existsSync(path.join(dest, 'package.json'))) {
console.log('Running npm install...')
execSync('npm install --production', { cwd: dest, stdio: 'inherit' })
}
fs.mkdirSync(BIN_DIR, { recursive: true })
const launcher = path.join(BIN_DIR, name)
const entry = fs.existsSync(path.join(dest, 'index.js')) ? 'index.js'
: fs.existsSync(path.join(dest, 'server.js')) ? 'server.js'
: 'index.js'
writeLauncher(launcher, '#!/bin/sh\nexec ' + resolveBundledRuntime(dest, 'bare', detectHost()) + ' ' + path.join(dest, entry) + ' "$@"\n')
console.log('Launcher: ' + launcher)
return { installed: name, path: dest, launcher }
}