// 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//