// uninstall — symmetric removal of THIS foundation install (the install.sh / `iapeer
// install` / onboard artifacts). NAMESPACE-SAFE: it never touches a foreign
// persistent-peer fleet. A peer plist at the shared `com.iapeer.*` namespace WITHOUT
// the ownership sentinel is foreign; if ANY is present we REFUSE (option-1) — removing
// ~/.iapeer would nuke the fleet's own state, which H4 forbids. The operator removes
// their peers first (`iapeer remove
`), then uninstalls.
//
// Removed: ~/.local/bin/iapeer (+ .prev) · the daemon plist (com.agfpd.iapeer,
// bootout→rm) · foundation-OWNED infra jobs (sentinel-marked com.iapeer.*, bootout→rm)
// · the ~/.iapeer scaffold · the installer's PATH lines in the shell profile.
// CONSERVATIVE (kept by default): the shared agfpd-stack codesign identity (removed
// only with removeCodesignIdentity) and bun (the user's). Plan is read-only — `--dry-run`
// renders it without touching anything. Fail-closed in the test sandbox: a real
// ~/.iapeer / ~/.local/bin path under IAPEER_TEST_SANDBOX=1 throws (tests must inject
// temp roots), so a hermetic test can never delete the real install.
import { spawnSync } from 'child_process'
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import { buildProcessAddress, DAEMON_PLIST_LABEL, IapError } from '../core/index.ts'
import { iapeerBinPath, iapeerHealthyStampPath } from '../install/index.ts'
import { SIGNING_IDENTITY_CN } from '../install/signing.ts'
import { daemonPlistPath } from '../daemon/main.ts'
import { hostSessionAlive, killPtyHost, listHostedPeers } from '../launch/ptyHost.ts'
import { isFoundationOwnedPlist, launchAgentsDir } from '../launch/launchd.ts'
import { resolveGlobalRoot } from '../storage/index.ts'
/** The marker comment install.sh writes above its PATH line (strip both lines). */
const INSTALLER_PROFILE_MARKER = '# Added by the iapeer installer'
export type UninstallActionKind =
| 'remove-file'
| 'remove-dir'
| 'bootout-remove-plist'
| 'stop-pty-sessions'
| 'strip-profile-lines'
| 'remove-keychain-identity'
export interface UninstallItem {
what: string
action: UninstallActionKind
path?: string
/** True when the artifact exists / there is something to do (dry-run shows all;
* absent ones render as "already gone"). */
present: boolean
detail?: string
}
export interface UninstallPlan {
items: UninstallItem[]
/** Set when a FOREIGN persistent-peer fleet is present → the whole uninstall is
* refused (option-1, H4-safe). The caller prints the guidance and aborts. */
refused?: { reason: string; foreignLabels: string[] }
}
/** Injected launchctl/security runner (tests). Default: real spawnSync. */
export type SysRunner = (cmd: string, args: string[]) => { status: number | null; stderr: string }
export interface UninstallOptions {
env?: NodeJS.ProcessEnv
/** --remove-codesign-identity: also delete the SHARED agfpd-stack signing identity. */
removeCodesignIdentity?: boolean
/** Injected sys runner (launchctl / security) for hermetic tests. */
run?: SysRunner
/** В56 — how long to wait for a booted-out launchd job to actually unload before
* declaring it stuck (default 10 s; tests inject a short value). */
launchdWaitMs?: number
/** В55 — how long to wait for SIGTERM'd pty sessions to die (default 8 s). */
sessionWaitMs?: number
}
const defaultRun: SysRunner = (cmd, args) => {
const r = spawnSync(cmd, args, { encoding: 'utf8' })
return { status: r.status, stderr: (r.stderr ?? '').trim() }
}
function home(env: NodeJS.ProcessEnv): string {
return env.HOME?.trim() || homedir()
}
/** Shell-profile files the installer may have edited (per its $SHELL branch). */
function profileFiles(env: NodeJS.ProcessEnv): string[] {
const h = home(env)
return [join(h, '.zshrc'), join(h, '.bash_profile'), join(h, '.profile')]
}
/** All com.iapeer.*.plist in LaunchAgents, split into foundation-owned (sentinel)
* vs foreign (the persistent-peer fleet). */
function scanInfraPlists(env: NodeJS.ProcessEnv): { owned: string[]; foreign: string[] } {
const dir = launchAgentsDir(env)
const owned: string[] = []
const foreign: string[] = []
let entries: string[]
try {
entries = readdirSync(dir)
} catch {
return { owned, foreign }
}
for (const e of entries) {
if (!e.startsWith('com.iapeer.') || !e.endsWith('.plist')) continue
const p = join(dir, e)
;(isFoundationOwnedPlist(p) ? owned : foreign).push(p)
}
return { owned, foreign }
}
/** Fail-closed: under the test sandbox, refuse to operate on the REAL install paths
* (a hermetic test MUST inject temp roots). Mirrors installIapeer's guard. */
function assertSandboxSafe(env: NodeJS.ProcessEnv): void {
if (env.IAPEER_TEST_SANDBOX !== '1' && process.env.IAPEER_TEST_SANDBOX !== '1') return
const root = resolveGlobalRoot(env)
const realRoot = join(homedir(), '.iapeer')
const bin = iapeerBinPath(env)
const realBin = join(homedir(), '.local', 'bin', 'iapeer')
if (root === realRoot || bin === realBin) {
throw new IapError(
'refusing to uninstall the REAL install under IAPEER_TEST_SANDBOX=1 — inject IAPEER_ROOT / IAPEER_BIN_DIR temp paths',
)
}
}
/** Read-only: compute what an uninstall would remove + the foreign-fleet refusal. */
export function planUninstall(opts: UninstallOptions = {}): UninstallPlan {
const env = opts.env ?? process.env
const { owned, foreign } = scanInfraPlists(env)
if (foreign.length > 0) {
const foreignLabels = foreign.map(p => p.replace(/\.plist$/, '').split('/').pop() ?? p)
return {
items: [],
refused: {
foreignLabels,
reason:
`persistent-peer fleet present (${foreignLabels.join(', ')}). Removing ~/.iapeer would ` +
`destroy their state. Remove those peers first (\`iapeer remove \`), then re-run uninstall.`,
},
}
}
const bin = iapeerBinPath(env)
const prev = `${bin}.prev`
const dPlist = daemonPlistPath(env)
const root = resolveGlobalRoot(env)
const items: UninstallItem[] = []
// launchd: daemon first, then foundation-owned infra jobs (bootout → rm).
items.push({
what: `daemon job ${DAEMON_PLIST_LABEL}`,
action: 'bootout-remove-plist',
path: dPlist,
present: existsSync(dPlist),
})
for (const p of owned) {
items.push({
what: `infra job ${p.replace(/\.plist$/, '').split('/').pop()}`,
action: 'bootout-remove-plist',
path: p,
present: true,
})
}
// В55 — live warm-on-demand pty sessions (detached supervisors, NO plist: the
// launchd guard above cannot see them). Their run-dir/state live INSIDE ~/.iapeer —
// removing the tree under ~30 running agent processes leaves an orphaned live
// fleet working out of deleted directories. Stop them FIRST (before remove-dir).
const hosted = listHostedPeers(env).map(h => buildProcessAddress(h.runtime, h.personality))
items.push({
what: `live pty sessions (${hosted.length ? hosted.join(', ') : 'none'})`,
action: 'stop-pty-sessions',
present: hosted.length > 0,
detail: hosted.length ? 'will be stopped before ~/.iapeer is removed' : undefined,
})
items.push({ what: 'binary ~/.local/bin/iapeer', action: 'remove-file', path: bin, present: existsSync(bin) })
items.push({ what: 'previous binary (.prev)', action: 'remove-file', path: prev, present: existsSync(prev) })
items.push({
what: 'healthy-stamp (.healthy)',
action: 'remove-file',
path: iapeerHealthyStampPath(env),
present: existsSync(iapeerHealthyStampPath(env)),
})
items.push({ what: 'state + config ~/.iapeer', action: 'remove-dir', path: root, present: existsSync(root) })
for (const pf of profileFiles(env)) {
const has = existsSync(pf) && readFileSync(pf, 'utf8').includes(INSTALLER_PROFILE_MARKER)
if (has) items.push({ what: `PATH line in ${pf}`, action: 'strip-profile-lines', path: pf, present: true })
}
items.push({
what: `codesign identity "${SIGNING_IDENTITY_CN}"`,
action: 'remove-keychain-identity',
present: opts.removeCodesignIdentity === true,
detail: opts.removeCodesignIdentity
? 'will remove (shared agfpd-stack identity — other agfpd products re-prompt on next update)'
: 'KEPT (shared agfpd-stack identity) — pass --remove-codesign-identity to remove',
})
return { items }
}
export interface UninstallResult {
removed: string[]
skipped: string[]
failed: { what: string; detail: string }[]
refused?: { reason: string; foreignLabels: string[] }
}
const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms))
/** В56 — poll `launchctl print gui//