OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / commands / app.js

2002 lines · javascript ref:
/** 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 }
}