// Tests for relocating app state (pid / state / log) out of the install
// directory.
//
// Bug being fixed (issue #5 high-priority item 1):
// `pgh app install` calls fs.rmSync(installPath) before materializing.
// pidFile / logFile / stateFile all live inside installPath, so they
// get nuked even when the app's supervisor + child are still running.
// After install, pgh app status returns running:false / pgh app stop
// returns "no pid file" — even though the daemon is alive.
//
// Fix: pid/state/log live in <XDG_RUNTIME_DIR>/pgh/<name>/ (or
// ~/.config/pgh/run/<name>/), outside the install path. installManifest
// stays inside the install path. Migration: readers fall back to the
// old in-install location when the new one is absent (one-time
// migration on next start).
import test from 'brittle'
import process from '#node:process'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import * as M from '../lib/app-state.js'
let n = 0
function tmpRoot () {
return mkdtempSync(join(tmpdir(), 'pgh-relocate-' + (n++) + '-'))
}
function setupEnv () {
// Each test gets fresh storeRoot + runRoot. Paths in app-state.js are
// env-aware at call-time, so resetting the env here applies to all
// subsequent calls within this test.
const storeRoot = tmpRoot()
const runRoot = tmpRoot()
process.env.PGH_STORE_ROOT = storeRoot
process.env.XDG_RUNTIME_DIR = runRoot
return { storeRoot, runRoot }
}
test('relocation: writePid writes to runDir, readPid reads from runDir', (t) => {
const { storeRoot, runRoot } = setupEnv()
const name = 'demo-' + (n++)
M.writePid(name, 12345)
const installPath = join(storeRoot, 'apps', name)
t.absent(existsSync(join(installPath, 'app.pid')), 'pid is NOT inside installPath')
t.ok(existsSync(join(runRoot, 'pgh', name, 'app.pid')), 'pid IS inside runDir')
t.is(M.readPid(name), 12345, 'readPid returns the value just written')
})
test('relocation: rmSync(installPath) does NOT destroy pid/state', (t) => {
// The exact production scenario: app is running (pid + state written),
// then `pgh app install` does rmSync(installPath). Pid/state must
// survive so pgh app status / stop / restart still work.
const { storeRoot } = setupEnv()
const name = 'demo-' + (n++)
// Simulate "app is running" — supervisor has written pid + state.
const installPath = join(storeRoot, 'apps', name)
mkdirSync(installPath, { recursive: true })
writeFileSync(join(installPath, 'index.js'), 'console.log("app")') // pretend code
M.writePid(name, 99999)
M.writeState(name, { childPid: 100000, startedAt: '2026-05-05T00:00:00Z' })
t.is(M.readPid(name), 99999, 'pre-install: pid readable')
t.is(M.readState(name).childPid, 100000, 'pre-install: state readable')
// Production install path: rmSync the install dir.
rmSync(installPath, { recursive: true, force: true })
t.is(M.readPid(name), 99999, 'POST-rmSync: pid still readable (the OTA-orphan fix)')
t.is(M.readState(name).childPid, 100000, 'POST-rmSync: state still readable')
})
test('migration: legacy pid in installPath is readable as fallback', (t) => {
// For pgh users with apps started under the old path scheme — pid
// exists at <install>/app.pid, runDir is empty. readPid must fall
// back to the legacy location so existing running apps don't appear
// dead after the upgrade.
const { storeRoot } = setupEnv()
const name = 'demo-' + (n++)
// Hand-write a legacy pid file in the install path (simulates a
// running app that was started under pre-0.1.23 pgh).
const installPath = join(storeRoot, 'apps', name)
mkdirSync(installPath, { recursive: true })
writeFileSync(join(installPath, 'app.pid'), '7777')
t.is(M.readPid(name), 7777, 'legacy pid in installPath is read as fallback')
// After the next writePid (e.g. supervisor restart), the new location
// takes precedence.
M.writePid(name, 8888)
t.is(M.readPid(name), 8888, 'after writePid, runDir wins')
})
test('migration: legacy state in installPath is readable as fallback', (t) => {
const { storeRoot } = setupEnv()
const name = 'demo-' + (n++)
const installPath = join(storeRoot, 'apps', name)
mkdirSync(installPath, { recursive: true })
writeFileSync(join(installPath, 'app.state.json'),
JSON.stringify({ childPid: 5555, startedAt: 'legacy' }))
t.is(M.readState(name).childPid, 5555, 'legacy state read as fallback')
})
test('relocation: installManifest STAYS in installPath (it is install-time data)', (t) => {
// Manifest is install-time metadata (entrypoint, runtime, defaults.env).
// It SHOULD die with the install — every `pgh app install` writes a
// fresh one. Don't relocate it.
const { storeRoot } = setupEnv()
const name = 'demo-' + (n++)
M.writeInstallManifest(name, { entrypoint: 'server.js', version: '1.0.0' })
const installPath = join(storeRoot, 'apps', name)
t.ok(existsSync(join(installPath, '.pgh-install.json')),
'manifest IS inside installPath (install-time data, will be rewritten on reinstall)')
t.is(M.readInstallManifest(name).version, '1.0.0', 'manifest readable')
})
test('relocation: clearPid removes pid from runDir', (t) => {
setupEnv()
const name = 'demo-' + (n++)
M.writePid(name, 1234)
t.is(M.readPid(name), 1234, 'pid set')
M.clearPid(name)
t.is(M.readPid(name), null, 'pid cleared')
})
test('paths: pidFile / stateFile / logFile all under runDir', (t) => {
const { runRoot } = setupEnv()
const name = 'demo-' + (n++)
t.ok(M.pidFile(name).startsWith(join(runRoot, 'pgh', name)), 'pidFile under runRoot')
t.ok(M.stateFile(name).startsWith(join(runRoot, 'pgh', name)), 'stateFile under runRoot')
t.ok(M.logFile(name).startsWith(join(runRoot, 'pgh', name)), 'logFile under runRoot')
})