/**
* App lifecycle state: pid files, state files, log files, user overrides,
* env merging. Shared by `pgh app start|stop|status|log` and the
* supervisor child.
*
* ── Path layout ─────────────────────────────────────────────────────
*
* Install-time data (rewritten by every `pgh app install`):
* <STORE_ROOT>/apps/<name>/ ← installPath, rmSync'd on reinstall
* <STORE_ROOT>/apps/<name>/.pgh-install.json
*
* Run-time state (must survive reinstall):
* <RUN_ROOT>/<name>/app.pid
* <RUN_ROOT>/<name>/app.state.json
* <RUN_ROOT>/<name>/app.log
*
* RUN_ROOT defaults to `$XDG_RUNTIME_DIR/pgh` when set, else
* `~/.config/pgh/run`. STORE_ROOT comes from `$PGH_STORE_ROOT` or the
* platform default in store.js.
*
* Pre-0.1.23 versions stored pid/state/log INSIDE installPath, which
* meant `pgh app install`'s rmSync wiped them — leaving `pgh app status`
* reporting `running: false` while the supervisor + daemon were still
* alive. The relocation fixes that. Legacy pid/state files are read as
* a fallback for one boot cycle so already-running apps keep working
* across the upgrade.
*/
import process from '#node:process'
import fs from '#node:fs'
import path from '#node:path'
import { homedir } from '#node:os'
import { DEFAULT_ROOT as STORE_ROOT_DEFAULT } from './store.js'
const USER_CONFIG = path.join(homedir(), '.config', 'pgh', 'apps.json')
const LOG_ROTATE_BYTES = 10 * 1024 * 1024
const LOG_ROTATE_KEEP = 5
// ── Paths (env-aware at call-time so env overrides take effect) ─────
function storeRoot () {
return process.env.PGH_STORE_ROOT || STORE_ROOT_DEFAULT
}
function appsDir () {
return path.join(storeRoot(), 'apps')
}
function runRoot () {
// Run-time state lives outside the install path so `pgh app install`'s
// rmSync(installPath) doesn't wipe pid/state/log of a running app.
if (process.env.XDG_RUNTIME_DIR) return path.join(process.env.XDG_RUNTIME_DIR, 'pgh')
return path.join(homedir(), '.config', 'pgh', 'run')
}
function runAppDir (name) {
return path.join(runRoot(), name)
}
export function appDir (name) {
return path.join(appsDir(), name)
}
export function pidFile (name) {
return path.join(runAppDir(name), 'app.pid')
}
export function logFile (name) {
return path.join(runAppDir(name), 'app.log')
}
export function stateFile (name) {
return path.join(runAppDir(name), 'app.state.json')
}
export function installManifestFile (name) {
// Manifest STAYS inside installPath — it's install-time metadata
// (entrypoint, runtime, defaults.env), rewritten by every install.
return path.join(appDir(name), '.pgh-install.json')
}
// Legacy (pre-0.1.23) paths — used as read-fallback during the
// migration window so apps started under the old scheme don't appear
// dead immediately after the upgrade. The next `pgh app start` writes
// to the new location and the legacy file is left behind (harmless;
// gets rmSync'd on next reinstall).
function legacyPidFile (name) { return path.join(appDir(name), 'app.pid') }
function legacyStateFile (name) { return path.join(appDir(name), 'app.state.json') }
function legacyLogFile (name) { return path.join(appDir(name), 'app.log') }
// ── Install manifest ────────────────────────────────────────────────
export function readInstallManifest (name) {
const p = installManifestFile(name)
try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null }
}
export function writeInstallManifest (name, manifest) {
const p = installManifestFile(name)
fs.mkdirSync(path.dirname(p), { recursive: true })
fs.writeFileSync(p, JSON.stringify(manifest, null, 2))
}
export function readPackageJsonPgh (name) {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(appDir(name), 'package.json'), 'utf8'))
return pkg.pgh || {}
} catch { return {} }
}
// ── User config ─────────────────────────────────────────────────────
export function readUserConfig () {
try { return JSON.parse(fs.readFileSync(USER_CONFIG, 'utf8')) } catch { return {} }
}
export function writeUserConfig (cfg) {
fs.mkdirSync(path.dirname(USER_CONFIG), { recursive: true })
fs.writeFileSync(USER_CONFIG, JSON.stringify(cfg, null, 2))
}
// ── Pid file ────────────────────────────────────────────────────────
function readPidAt (p) {
try {
const raw = fs.readFileSync(p, 'utf8').trim()
const pid = parseInt(raw, 10)
return Number.isFinite(pid) && pid > 0 ? pid : null
} catch { return null }
}
export function readPid (name) {
return readPidAt(pidFile(name)) || readPidAt(legacyPidFile(name))
}
export function writePid (name, pid) {
const p = pidFile(name)
fs.mkdirSync(path.dirname(p), { recursive: true })
fs.writeFileSync(p, String(pid))
}
export function clearPid (name) {
try { fs.unlinkSync(pidFile(name)) } catch {}
// Don't touch the legacy file — installPath is rmSync'd on reinstall
// anyway, and during the migration window leaving the legacy file
// is harmless (readPid prefers the new location).
}
export function isPidAlive (pid) {
if (!pid) return false
try { process.kill(pid, 0); return true } catch { return false }
}
// When a process actually STARTED, from the kernel rather than from our own bookkeeping.
//
// `state.startedAt` records when pgh spawned a supervisor, which is not the same thing: a process
// started outside pgh, or one whose state file was rewritten, reports a time that has nothing to do with
// the code it is executing. The /proc/<pid> directory's ctime is the process start time (verified against
// both process.uptime() and `ps -o lstart` — agreement within milliseconds), so it is the ground truth
// for "is this process older than the tree it is supposed to be running".
//
// Linux-only by construction; returns null anywhere /proc is absent, and every caller treats null as
// "unknown" rather than "fine".
export function processStartedAt (pid) {
if (!pid) return null
try { return new Date(fs.statSync('/proc/' + pid).ctimeMs).toISOString() } catch { return null }
}
// Discover a running app supervisor by its PGH_SUP_NAME env, independent
// of the pid file. The supervisor is spawned with PGH_SUP_NAME=<name> in
// its environment (see `cmdStart`), so when the pid file is missing or
// stale we can still find the live process group to stop — otherwise a
// stale pid leaves the real daemon+supervisor running (holding the store
// lock / port) and the next start collides. Linux-only (reads /proc);
// returns null elsewhere or if not found, so callers fall back to
// pid-file behaviour. readdir/readFile are injectable for tests.
// Returns ALL matching supervisor pids (usually one; more than one means
// an orphan herd that this stop should also reap).
export function findRunningSupervisors (name, opts = {}) {
const procDir = opts.procDir || '/proc'
const readdir = opts.readdir || ((d) => fs.readdirSync(d))
const readFile = opts.readFile || ((p) => fs.readFileSync(p, 'utf8'))
const found = []
let entries
try { entries = readdir(procDir) } catch { return found }
for (const ent of entries) {
if (!/^\d+$/.test(ent)) continue
let environ
try { environ = readFile(procDir + '/' + ent + '/environ') } catch { continue }
// environ is NUL-separated KEY=VALUE; exact-match to avoid prefix
// collisions (e.g. pear-git-v3 vs pear-git-v3-experimental).
if (!environ.split('\0').includes('PGH_SUP_NAME=' + name)) continue
let cmdline
try { cmdline = readFile(procDir + '/' + ent + '/cmdline') } catch { continue }
if (cmdline.indexOf('supervisor.js') === -1) continue
found.push(Number(ent))
}
return found
}
// ── State file ──────────────────────────────────────────────────────
function readStateAt (p) {
try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null }
}
export function readState (name) {
return readStateAt(stateFile(name)) || readStateAt(legacyStateFile(name)) || {}
}
export function writeState (name, patch) {
const cur = readState(name)
const next = { ...cur, ...patch }
const p = stateFile(name)
fs.mkdirSync(path.dirname(p), { recursive: true })
fs.writeFileSync(p, JSON.stringify(next, null, 2))
return next
}
// ── Log file ────────────────────────────────────────────────────────
//
// New supervisor invocations open the log under runAppDir. If a legacy
// log exists at the old in-install location and the new one doesn't
// yet, prefer the legacy one for `pgh app log` reads so pre-upgrade
// log lines are still visible.
/**
* Rotate app.log if it's over the size limit. Keeps app.log, app.log.1,
* up to app.log.<LOG_ROTATE_KEEP>. Called before spawning the supervisor
* (at which point the log is not being written to).
*/
export function rotateLogIfNeeded (name) {
const lp = logFile(name)
let size = 0
try { size = fs.statSync(lp).size } catch { return }
if (size < LOG_ROTATE_BYTES) return
for (let i = LOG_ROTATE_KEEP; i >= 1; i--) {
const src = i === 1 ? lp : lp + '.' + (i - 1)
const dst = lp + '.' + i
try { fs.renameSync(src, dst) } catch {}
}
}
export function openLogForAppend (name) {
const lp = logFile(name)
fs.mkdirSync(path.dirname(lp), { recursive: true })
return fs.openSync(lp, 'a')
}
/**
* Resolve the log path for `pgh app log` — prefer the new location;
* fall back to legacy if the new one doesn't exist yet (migration
* window: previous supervisor wrote to legacy, current daemon may not
* have generated new logs yet).
*/
export function resolveLogPath (name) {
const newP = logFile(name)
if (fs.existsSync(newP)) return newP
const legacy = legacyLogFile(name)
if (fs.existsSync(legacy)) return legacy
return newP // for "no log yet" callers, point at the new location
}
// ── Env / args / autostart ──────────────────────────────────────────
/**
* Compute the effective env for the app: process env < install defaults <
* user overrides. Later layers win. ${VAR} references in values are
* expanded against the merged map.
*/
export function effectiveEnv (name, manifest) {
const defaults = (manifest && manifest.defaults && manifest.defaults.env) || {}
const user = (readUserConfig()[name] || {}).env || {}
const merged = { ...process.env, ...defaults, ...user }
for (const k of Object.keys(merged)) {
if (typeof merged[k] === 'string') merged[k] = expandEnv(merged[k], merged)
}
return merged
}
export function effectiveArgs (name, manifest) {
// Precedence: user-overrides > manifest.args (install-time CLI) >
// manifest.defaults.args (the canonical install-time setting written
// by `pgh app install` / `pgh app adopt`). Mirror effectiveEnv's
// defaults-as-baseline behaviour: an empty manifest.args means
// "fall back to defaults", not "I want no args".
const defaults = (manifest && manifest.defaults && manifest.defaults.args) || []
const installArgs = (manifest && manifest.args) || []
const userArgs = (readUserConfig()[name] || {}).args
let chosen
if (Array.isArray(userArgs)) chosen = userArgs
else if (installArgs.length > 0) chosen = installArgs
else chosen = defaults
// Expand ${VAR} so e.g. --storage=${HOME}/pgv3-experimental-storage
// resolves at start time (mirrors effectiveEnv's expandEnv pass).
const env = { ...process.env, ...((manifest && manifest.defaults && manifest.defaults.env) || {}) }
return chosen.map(a => typeof a === 'string' ? expandEnv(a, env) : a)
}
export function effectiveAutostart (name, manifest) {
const user = readUserConfig()[name]
if (user && typeof user.autostart === 'boolean') return user.autostart
if (manifest && typeof manifest.autostart === 'boolean') return manifest.autostart
return false
}
export function expandEnv (s, env) {
if (typeof s !== 'string') return s
return s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, k) => (env[k] != null ? env[k] : '${' + k + '}'))
}
// Install-machinery siblings of an app dir, never apps themselves: the update
// scratch (.upd), the swap's leftover (.old) and a STAGED update waiting for
// the app to stop (.next — it has a manifest; see materializers/atomic.js).
const INSTALL_SIBLING = /\.(upd|old|next)$/
export function listAppNames () {
try {
return fs.readdirSync(appsDir()).filter(n => {
if (INSTALL_SIBLING.test(n)) return false
try { return fs.existsSync(installManifestFile(n)) } catch { return false }
})
} catch { return [] }
}
export { USER_CONFIG }