/**
* runtime-factory — batteries-included pgh-runtime wiring.
*
* pgh-runtime's core (installRelease / PghRuntime) is transport-agnostic: it
* takes an injected fetcher + store + materializer. This module is the
* pear-runtime-equivalent "batteries included" layer for the content-addressed
* world — it wires pgh's real NetworkFetcher + TreeStore + atomic node
* materializer and finishes the install (launcher + manifest), so an embedder
* gets a complete, pgh-managed app from `{ name }` alone.
*
* Dependency direction is one-way (pgh -> pgh-runtime). pgh-runtime never
* imports pgh, so its pure core stays dependency-free; only this convenience
* layer pulls the pgh-specific pieces together.
*
* Closes the three "complete updater" gaps:
* 1. launcher + install manifest (so a FIRST install is runnable + visible
* to `pgh app list`, full parity with `pgh app install`);
* 2. apply hook (the `onUpdate` callback / `updated` event drives the
* exit-75 -> supervisor respawn the embedder owns);
* 3. default fetcher/store/materializer (no hand-wiring).
*/
import process from '#node:process' // NOT a global under bare — execPath below needs it
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import { homedir } from '#node:os'
import { Client } from 'hyper-svc'
import { TreeStore } from './store.js'
import { NetworkFetcher } from './network-fetcher.js'
import { materialize as nodeMaterialize } from './materializers/node.js'
import { materialize as godotMaterialize } from './materializers/godot.js'
import { atomicMaterializer, lockPathFor, stagedPathFor } from './materializers/atomic.js'
import { PghRuntime, installRelease } from 'pgh-runtime'
import { makeGetLatestRelease } from './resolve-release.js'
function defaultBinDir () {
return process.env.PGH_BIN_DIR || path.join(homedir(), '.local', 'bin')
}
function defaultSidecarSock () {
// Honour PGH_SIDECAR_SOCK like every other client path (daemon-client.js,
// commands/*). This also lets an '@'-prefixed abstract-namespace socket be
// used where SELinux denies pathname sockets (unrooted Android / celltron).
return process.env.PGH_SIDECAR_SOCK || path.join(homedir(), '.pgh', 'svc.sock')
}
// Prefer the pgh-bundled bare runtime in the same store; fall back to PATH bare.
// The runtime an OTA install/update should exec. DELEGATES to resolveBundledRuntime — it must not
// reimplement the resolution.
//
// It used to check a SINGLE candidate (apps/pgh/runtime/bare) and otherwise return the bare NAME. That
// made it a THIRD copy of the resolution logic, and worse, one that SHADOWED the fixed one: finalizeInstall
// does `runtimePath || resolveBundledRuntime(...)`, and this value is always truthy, so the five-candidate
// resolver was never consulted on the path this feeds. The exact drift #17 fixed between commands/app.js
// and this file, surviving one level down.
//
// Delegating also gains the candidate that matters most here: process.execPath. An OTA update is applied
// BY a process already running under bare, so even if every bundled-runtime lookup misses, the runtime
// executing us is absolute and by definition able to run the stack.
export function defaultRuntimePath (store, name, { execPath } = {}) {
// resolveBundledRuntime keys its per-install and sibling lookups off installPath, so give it the real
// one when we know the app; otherwise a path under appsDir so the sibling scan still resolves.
const installPath = name ? store.appPath(name) : path.join(store.appsDir, '_')
return resolveBundledRuntime(installPath, 'bare', detectHost(), execPath ? { execPath } : {})
}
/**
* The materializer for a runtime kind, as the RELEASE declares it.
*
* Exported because it is the whole of the selection logic and the only part of
* createRuntime worth testing without a network: given a kind, which layout.
*
* Throwing on an unknown kind is deliberate. The alternative — defaulting to the
* npm layout — would materialize a godot app as if it were a node one and produce
* an install that exists, passes every "did it install" check, and does not run.
*/
export function selectMaterializer (runtimeKind) {
return loadMaterializer(runtimeKind || 'node').materialize
}
// Select the materializer for a runtime — node/bare share the npm layout;
// godot uses its own. Parity with app.js loadMaterializer (the live path).
function loadMaterializer (runtime) {
switch (runtime) {
case 'node':
case 'bare':
return { materialize: nodeMaterialize }
case 'godot':
return { materialize: godotMaterialize }
default:
throw new Error('no materializer for runtime: ' + runtime)
}
}
// Launcher interpreter: bare AND godot run under bare; node under node.
// Matches app.js (interp = bare for bare|godot, else node).
function runtimeInterp (runtime) {
return runtime === 'node' ? 'node' : 'bare'
}
// Resolve the launcher's runtime to an ABSOLUTE path wherever possible.
//
// A launcher that says `exec bare <entry>` depends on `bare` being on the ambient PATH, and a
// non-interactive `ssh host 'app ...'` gets a minimal PATH that usually does not include ~/.local/bin.
// Observed on ashatron: `ssh ashatron 'hyper-proto-peer diag'` died with `exec: bare: not found` while the
// identical launcher worked on stortron, purely because of PATH — the worst kind of per-host difference.
//
// This used to claim "Parity with app.js resolveBundledRuntime" and did not have it: app.js falls back to
// resolveRuntimePath (process.execPath when it is already the interp), this one fell back to the bare
// NAME. finalizeInstall is the modern install path, so the PATH-dependent launcher is the one that got
// written. Now there is ONE implementation, exported and used by both.
//
// Candidates, most specific first:
// 1-2. a runtime bundled with THIS install (multi-arch, then root-level)
// 3. the runtime currently executing us — absolute, and by definition able to run this stack
// 4. a sibling app's bundled runtime in the same store; pgh is bootstrap:true, so on a pgh-managed
// machine it is present by construction. This covers an app that ships no runtime of its own while
// the CLI runs under node (execPath is 'node', so 3 does not apply).
// 5. the bare name, i.e. PATH — last resort, and the only case that can still fail like ashatron did.
export function resolveBundledRuntime (installPath, interp, host, { execPath = process.execPath } = {}) {
if (interp === 'bare' && installPath) {
const cands = [
path.join(installPath, 'by-arch', host, 'runtime', 'bare'),
path.join(installPath, 'runtime', 'bare')
]
for (const c of cands) { try { if (fs.existsSync(c)) return c } catch {} }
}
if (execPath && path.basename(execPath) === interp) return execPath
if (interp === 'bare' && installPath) {
const appsDir = path.dirname(installPath) // <store>/apps
for (const sib of ['pgh', 'pgh-sidecar']) {
for (const c of [
path.join(appsDir, sib, 'by-arch', host, 'runtime', 'bare'),
path.join(appsDir, sib, 'runtime', 'bare')
]) {
try { if (fs.existsSync(c)) return c } catch {}
}
}
}
return interp
}
function detectHost () {
let host = os.platform() + '-' + os.arch()
try {
const osr = fs.readFileSync('/etc/os-release', 'utf8')
if (/^ID=alpine/m.test(osr)) host += '-musl'
} catch {}
return host
}
/**
* The launcher script for a registry install (`pgh app install|update`, OTA).
* Besides exec'ing the app it implements the IN-USE contract of
* materializers/atomic.js: while the app runs it holds a SHARED flock on
* <apps>/<name>.lock — fd 9, inherited by the app and its children, released
* by the kernel when they exit or crash — so an update stages its tree as
* <name>.next instead of swapping it under the running app; and a staged
* update is applied HERE, before the app starts, so it always starts on one
* complete tree.
* - apply-staged runs through pgh's own launcher with PGH_NO_APPLY=1, which
* skips both steps there: no recursion when pgh itself is the app, and no
* lock held by the very process trying to take it exclusively;
* - `: >>lock` first, because a failed `exec 9>>` would kill the shell;
* - -w 30: waits out an apply in progress, never blocks the start for good;
* - no flock(1) (macOS) -> the old two-line launcher behaviour.
* @param {{ runtimePath, entryAbs, name?, store?, binDir? }} o — without
* name+store (adopted dev installs, legacy paths) the plain launcher.
*/
export function launcherScript ({ runtimePath, entryAbs, name, store, binDir }) {
const run = 'exec ' + runtimePath + ' ' + entryAbs + ' "$@"\n'
if (!name || !store) return '#!/bin/sh\n' + run
const q = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"
const lock = q(lockPathFor(store, name))
const staged = q(stagedPathFor(store, name))
const pgh = q(path.join(binDir || defaultBinDir(), 'pgh'))
return '#!/bin/sh\n' +
'# pgh in-use lock: an update never swaps this app\'s tree while it runs (pgh lib/materializers/atomic.js)\n' +
'if [ -z "$PGH_NO_APPLY" ] && command -v flock >/dev/null 2>&1; then\n' +
' if [ -d ' + staged + ' ] && [ -x ' + pgh + ' ]; then PGH_NO_APPLY=1 ' + pgh + ' app apply-staged ' + q(name) + ' >/dev/null 2>&1 || true; fi\n' +
' if : >>' + lock + ' 2>/dev/null; then exec 9>>' + lock + '; flock -s -w 30 9 || true; fi\n' +
'fi\n' + run
}
function writeLauncher (launcherPath, runtimePath, entryAbs, lockOpts = {}) {
fs.mkdirSync(path.dirname(launcherPath), { recursive: true })
// Replace any existing file/symlink first — writing THROUGH a symlink into
// the store would corrupt shared content (see app.js writeLauncher note).
try { fs.unlinkSync(launcherPath) } catch {}
fs.writeFileSync(launcherPath, launcherScript({ runtimePath, entryAbs, ...lockOpts }), { mode: 0o755 })
}
/**
* A path resolved inside a STAGED tree, rewritten to where it will live once
* applied (the launcher must never point into <name>.next).
*/
export function livePath (p, contentPath, installPath) {
return (contentPath !== installPath && typeof p === 'string' && p.startsWith(contentPath + path.sep))
? installPath + p.slice(contentPath.length)
: p
}
/**
* Write the launcher + install manifest for an already-materialized install,
* making it a complete, runnable, `pgh app list`-visible app. Reads the
* installed package.json's pgh block for entrypoint/runtime/upgrade. The
* manifest lives inside the install dir (store/apps/<name>/.pgh-install.json),
* matching app-state.installManifestFile so the CLI sees the same app.
*
* @returns {{ launcher: string, entrypoint: string, manifest: string }}
*/
export function finalizeInstall ({ store, name, release = {}, host, binDir, runtimePath, binName }) {
binDir = binDir || defaultBinDir()
host = host || detectHost()
const installPath = store.appPath(name)
// An update that found the app running left its tree STAGED (atomic.js) — the
// new tree's package.json and manifest are there; the launcher still points
// at the live path. A direct swap drops any staged tree, so "staged exists"
// means the latest install is the staged one.
const stagedPath = stagedPathFor(store, name)
const contentPath = fs.existsSync(stagedPath) ? stagedPath : installPath
// Read the installed package.json's pgh block for entrypoint/runtime/etc.
let pgh = {}
try { pgh = JSON.parse(fs.readFileSync(path.join(contentPath, 'package.json'), 'utf8')).pgh || {} } catch {}
const entry = pgh.entrypoint ||
(fs.existsSync(path.join(contentPath, 'index.js')) ? 'index.js' :
fs.existsSync(path.join(contentPath, 'server.js')) ? 'server.js' : 'index.js')
const runtime = release.runtime || pgh.runtime || 'bare'
// Explicit binName (e.g. when installed under --name) wins, so a custom-named
// install can't clobber another app's launcher (parity with app.js). Else
// pkg.pgh.bin, else the app name.
binName = binName || pgh.bin || name
const upgrade = release.upgrade || pgh.upgrade || null
// Launcher — prefer the per-install bundled bare runtime (parity with
// app.js resolveBundledRuntime); else caller-provided, else interp on PATH.
const interp = runtimeInterp(runtime)
const rtPath = runtimePath || livePath(resolveBundledRuntime(contentPath, interp, host), contentPath, installPath)
fs.mkdirSync(binDir, { recursive: true })
const launcher = path.join(binDir, binName)
writeLauncher(launcher, rtPath, path.join(installPath, entry), { name, store, binDir })
// Full install manifest — byte-parity with installViaRegistry (app.js:622-636)
// so `pgh app list/start/boot` read the same fields. Into the tree that will
// go live: the staged one when the update was staged.
const manifestPath = path.join(contentPath, '.pgh-install.json')
fs.writeFileSync(manifestPath, JSON.stringify({
name,
publishedName: release.name || name,
version: release.version || '0.0.0',
treeHash: release.treeHash || null,
runtime,
installedAt: new Date().toISOString(),
sourcePath: installPath,
entrypoint: entry,
upgrade,
defaults: pgh.defaults || {},
args: Array.isArray(pgh.args) ? pgh.args : [],
depends: Array.isArray(pgh.depends) ? pgh.depends : [],
autostart: !!pgh.autostart,
readiness: pgh.readiness || { type: 'pidfile' },
// Run by a systemd unit, not pgh's supervisor (systemd.js)
...(pgh.systemd ? { systemd: pgh.systemd } : {}),
source: 'pgh-runtime'
}, null, 2))
return { launcher, entrypoint: entry, manifest: manifestPath, runtime }
}
/**
* One-shot COMPLETE install: fetch the closure, materialize atomically, AND
* write the launcher + manifest. Full parity with `pgh app install`, driven by
* pgh-runtime's installRelease.
*/
export async function installComplete ({ store, release, fetcher, name, host, binDir, runtimePath, skipPath, binName }) {
name = name || release.name
host = host || detectHost()
const runtime = release.runtime || 'bare'
const res = await installRelease(store, release, fetcher, {
appName: name,
host,
skipPath,
materialize: atomicMaterializer(loadMaterializer(runtime))
})
const fin = finalizeInstall({ store, name, release, host, binDir, runtimePath, binName })
return { ...res, ...fin, installPath: store.appPath(name) }
}
/**
* Batteries-included PghRuntime. Resolves the publisher's deploy topic via the
* pgh-sidecar (the supported same-machine path — avoids the registry-corestore
* lock), wires the real NetworkFetcher + TreeStore + atomic node materializer,
* and finalizes (launcher + manifest) on every install/update.
*
* const rt = await createRuntime({ name: 'godot-keet' })
* rt.on('updated', ({ release }) => { ... exit 75 for respawn ... })
* await rt.start()
*
* @param {object} opts
* @param {string} opts.name app name
* @param {string} [opts.runtime] override the materializer kind; default
* is the RELEASE's own `runtime` field
* @param {string} [opts.registryKeyHex] publisher registry key (the hex in
* pgh.upgrade). WITH it, resolution works
* without a sidecar — see resolve-release.js.
* WITHOUT it, a local sidecar is required.
* @param {string} [opts.sidecarSock] pgh-sidecar UDS (default ~/.pgh/svc.sock)
* @param {TreeStore} [opts.store] default: device store (~/.local/share/store)
* @param {string} [opts.host] default: detected (e.g. linux-x64)
* @param {number} [opts.interval] poll interval ms (default PghRuntime's)
* @param {string} [opts.currentTreeHash] tree this app currently runs at
* @param {string} [opts.binDir] launcher dir (default ~/.local/bin)
* @param {function} [opts.onUpdate] called after finalize on each update (apply hook)
* @returns {Promise<PghRuntime>}
*/
export async function createRuntime (opts = {}) {
if (!opts.name) throw new Error('createRuntime requires opts.name')
const sidecarSock = opts.sidecarSock || defaultSidecarSock()
const store = opts.store || new TreeStore({})
store.init()
const host = opts.host || detectHost()
const binDir = opts.binDir || defaultBinDir()
const runtimePath = opts.runtimePath || defaultRuntimePath(store, opts.name)
// TWO RESOLVERS, and which one you get depends on whether you told us the
// registry key.
//
// Without it we can only ask a local pgh-sidecar "what is the latest <name>",
// which is fine on a pgh-managed machine and impossible anywhere else — no
// sidecar, no answer, and embedded OTA stops at the first step even though the
// transfer half works peer-to-peer. That was the gap: NetworkFetcher's
// getLatestRelease reads the LOCAL registry, and a standalone install has no
// local registry to read.
//
// WITH a registry key (it is the hex in `pgh.upgrade`,
// pgh://<registryKeyHex>/<name>) we use the shared resolver, which tries the
// sidecar first and otherwise replicates the publisher's registry directly over
// Hyperswarm. Same ordering, same fd-lock discipline — see resolve-release.js.
const resolveLatest = opts.registryKeyHex
? makeGetLatestRelease({ registryKeyHex: opts.registryKeyHex })
: async (name) => {
const c = await Client.connectUds(sidecarSock)
try { return await c.call('releases-latest', { name }) } finally { try { await c.close() } catch {} }
}
const latest = await resolveLatest(opts.name)
if (!latest || !latest.serverKey) {
throw new Error('createRuntime: no release with serverKey for ' + opts.name)
}
const fetcher = new NetworkFetcher({ topic: latest.serverKey, timeout: opts.timeout || 30000 })
// Route getLatestRelease through the sidecar (avoids the registry-corestore
// lock the default direct-open would deadlock on, same-machine).
fetcher.getLatestRelease = resolveLatest
await fetcher.ready()
const runtime = new PghRuntime({
name: opts.name,
fetcher,
store,
host,
interval: opts.interval,
currentTreeHash: opts.currentTreeHash,
// FOLLOWS THE RELEASE, not the caller and not a constant. This was
// hardcoded to nodeMaterialize, which is correct for every node/bare app —
// hyper-tps declares runtime 'bare' — and silently wrong for a godot one,
// where it would produce an install that looks complete and does not run.
// The live entries carry `runtime` (verified against the registry: hyper-tps
// resolves with runtime:"bare"), even though release-registry.js's schema
// comment predates the field and does not list it.
//
// Resolved ONCE, from the release we started against. An app that changes
// runtime kind mid-life is a re-install, not an update.
materialize: atomicMaterializer({
materialize: selectMaterializer(opts.runtime || latest.runtime)
})
})
// Finalize (launcher + manifest) after every install/update, then hand off
// the apply decision (respawn) to the embedder.
runtime.on('updated', (info) => {
try { finalizeInstall({ store, name: opts.name, release: info.release || {}, binDir, runtimePath }) } catch (err) { runtime.emit('error', err) }
if (typeof opts.onUpdate === 'function') opts.onUpdate(info)
})
runtime._fetcher = fetcher // expose for close()
return runtime
}