@agfpd/iapeer 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -943,6 +943,7 @@ const USAGE = `usage: iapeer <verb> [args]
943
943
  install build binary + global scaffold + daemon plist (one bootstrap)
944
944
  update [version] [--force] pull latest (or exact) @agfpd/iapeer from npm + restart daemon + recycle owned infra jobs
945
945
  rollback revert to the previous binary (.prev) + restart daemon + recycle owned infra jobs
946
+ uninstall [--dry-run] [--yes] [--remove-codesign-identity] remove THIS foundation install (binary, ~/.iapeer, daemon plist, PATH lines); refuses if a persistent-peer fleet is present
946
947
  version | --version | -v print the installed binary's version
947
948
  help | --help | -h print this usage (works appended to any verb; executes nothing)
948
949
  daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
@@ -1639,6 +1640,53 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1639
1640
  ;(failed ? errOut : out)(msg)
1640
1641
  return failed ? 1 : 0
1641
1642
  }
1643
+ case 'uninstall': {
1644
+ // Symmetric foundation removal (namespace-safe; REFUSES on a foreign fleet).
1645
+ // DESTRUCTIVE — removes ~/.iapeer (all peers/memory/logs). --dry-run previews;
1646
+ // --yes skips the confirm; --remove-codesign-identity also drops the shared
1647
+ // agfpd signing identity (otherwise kept).
1648
+ const { planUninstall, executeUninstall } = await import('../uninstall/index.ts')
1649
+ const removeCodesign = flags['remove-codesign-identity'] === true
1650
+ const plan = planUninstall({ env, removeCodesignIdentity: removeCodesign })
1651
+ if (plan.refused) {
1652
+ errOut(`uninstall refused — ${plan.refused.reason}\n`)
1653
+ return 1
1654
+ }
1655
+ out('iapeer uninstall — will remove:\n')
1656
+ for (const it of plan.items) {
1657
+ const mark = it.present ? '•' : '·'
1658
+ const tail = it.detail ? ` — ${it.detail}` : it.present ? '' : ' — already gone'
1659
+ out(` ${mark} ${it.what}${it.path ? ` (${it.path})` : ''}${tail}\n`)
1660
+ }
1661
+ if (flags['dry-run'] === true) {
1662
+ out('\n(dry-run — nothing removed)\n')
1663
+ return 0
1664
+ }
1665
+ if (flags.yes !== true) {
1666
+ if (process.stdin.isTTY !== true) {
1667
+ errOut('\nuninstall is destructive (removes ~/.iapeer) — re-run with --yes in a non-interactive shell.\n')
1668
+ return 2
1669
+ }
1670
+ const { createInterface } = await import('node:readline/promises')
1671
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
1672
+ let answer = ''
1673
+ try {
1674
+ answer = (await rl.question('\nThis permanently removes iapeer and ALL its data. Continue? [y/N] ')).trim().toLowerCase()
1675
+ } finally {
1676
+ rl.close()
1677
+ }
1678
+ if (answer !== 'y' && answer !== 'yes') {
1679
+ out('uninstall aborted.\n')
1680
+ return 1
1681
+ }
1682
+ }
1683
+ const res = executeUninstall({ env, removeCodesignIdentity: removeCodesign })
1684
+ for (const r of res.removed) out(` removed: ${r}\n`)
1685
+ for (const s of res.skipped) out(` skipped (absent): ${s}\n`)
1686
+ for (const f of res.failed) errOut(` FAILED: ${f.what} — ${f.detail}\n`)
1687
+ out(res.failed.length ? '\nuninstall finished with errors (see above).\n' : '\niapeer uninstalled. (bun left in place.)\n')
1688
+ return res.failed.length ? 1 : 0
1689
+ }
1642
1690
  case 'install': {
1643
1691
  // UNIFIED foundation install (contract Установка §1 — "один npx ставит
1644
1692
  // фундамент"): ONE command does all three install-phase steps that used to be
package/src/index.ts CHANGED
@@ -23,6 +23,8 @@ export * from './init/index.ts'
23
23
  export * from './install/index.ts'
24
24
  // Update — the cloud-only deploy path (`iapeer update`: npm latest → rebuild → restart).
25
25
  export * from './update/index.ts'
26
+ // Uninstall — symmetric foundation removal (namespace-safe; refuses on a foreign fleet).
27
+ export * from './uninstall/index.ts'
26
28
  // Onboard — the host-phase (idempotent marketplace registration in claude + codex).
27
29
  export * from './onboard/index.ts'
28
30
  // Memory slot — the declarative provider slot (контракт «Слот памяти»): status read + onboard step.
@@ -25,11 +25,26 @@ export interface MemoryOnboardOptions {
25
25
  skip?: boolean
26
26
  /** --memory <pkg>: override the provider package (default @agfpd/iapeer-memory). */
27
27
  package?: string
28
+ /** Host agentic runtime to thread to the provider init (`--runtime <rt>`) — the
29
+ * onboard↔init contract: the provider provisions its role-peers (index/watcher/
30
+ * timer) on THIS runtime instead of guessing. Omitted on a no-runtime host →
31
+ * the provider degrades (base+BM25, role-peers skipped). */
32
+ runtime?: string
28
33
  dryRun?: boolean
29
34
  env?: NodeJS.ProcessEnv
35
+ /** Wall-clock backstop (ms) for the provider-init subprocess — a hung provider
36
+ * (e.g. a stalled native build) must NEVER wedge onboard (memory is optional).
37
+ * The interactive wizard passes a generous value; the linear path leaves it
38
+ * unset (no timeout). */
39
+ timeoutMs?: number
30
40
  /** Injectable runner (tests). Default: availability probe + `npx -y <pkg> init …`
31
41
  * with inherited stdio. */
32
- runInit?: (pkg: string, args: string[], env: NodeJS.ProcessEnv) => { status: number | null; unavailable: boolean }
42
+ runInit?: (
43
+ pkg: string,
44
+ args: string[],
45
+ env: NodeJS.ProcessEnv,
46
+ timeoutMs?: number,
47
+ ) => { status: number | null; unavailable: boolean }
33
48
  }
34
49
 
35
50
  export interface MemoryOnboardResult {
@@ -49,17 +64,24 @@ function defaultRunInit(
49
64
  pkg: string,
50
65
  args: string[],
51
66
  env: NodeJS.ProcessEnv,
67
+ timeoutMs?: number,
52
68
  ): { status: number | null; unavailable: boolean } {
53
69
  // Availability probe FIRST (cheap, side-effect-free): an unpublished package /
54
70
  // no network → soft-skip per the contract (the provider's release order must
55
- // never block the core's onboard).
56
- const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env })
71
+ // never block the core's onboard). Bounded so the probe itself cannot hang.
72
+ const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env, timeout: 60_000 })
57
73
  if (probe.status !== 0) return { status: probe.status, unavailable: true }
58
74
  // INHERITED stdio — the provider owns its install questions (tty interactive
59
75
  // happens here, once). npx bin-name nuance (cf. the 0.2.9 update pitfall): with
60
76
  // the provider bin already on PATH npx runs the INSTALLED one — acceptable for
61
77
  // an idempotent init (unlike the self-update case where it was a structural bug).
62
- const r = spawnSync('npx', ['-y', pkg, ...args], { stdio: 'inherit', env })
78
+ // timeout: a generous wall-clock backstop so a hung provider can't wedge onboard
79
+ // (the caller also makes Ctrl-C skip just this step).
80
+ const r = spawnSync('npx', ['-y', pkg, ...args], {
81
+ stdio: 'inherit',
82
+ env,
83
+ ...(timeoutMs ? { timeout: timeoutMs } : {}),
84
+ })
63
85
  return { status: r.status, unavailable: false }
64
86
  }
65
87
 
@@ -91,12 +113,12 @@ export async function onboardMemoryProvider(opts: MemoryOnboardOptions = {}): Pr
91
113
  detail: `slot is occupied by "${existing.provider}" (${existing.package}) — uninstall it first`,
92
114
  }
93
115
  }
94
- const args = ['init', ...coreKnownInitArgs(env)]
116
+ const args = ['init', ...coreKnownInitArgs(env), ...(opts.runtime ? ['--runtime', opts.runtime] : [])]
95
117
  if (opts.dryRun) {
96
118
  return { state: 'dry-run', provider: null, detail: `would run: npx -y ${pkg} ${args.join(' ')}` }
97
119
  }
98
120
  const run = opts.runInit ?? defaultRunInit
99
- const r = run(pkg, args, env)
121
+ const r = run(pkg, args, env, opts.timeoutMs)
100
122
  if (r.unavailable) {
101
123
  return {
102
124
  state: 'skipped-unavailable',
@@ -20,15 +20,24 @@ export interface WizardResult {
20
20
  memoryConsent: boolean
21
21
  advisories: string[]
22
22
  summary: string[]
23
+ telegramConsent: boolean
24
+ /** Host agentic runtime detected during the run (claude if installed, else
25
+ * codex, else undefined on a no-runtime host) — threaded to the memory
26
+ * provider init as `--runtime` per the onboard↔init contract. */
27
+ hostRuntime?: OnboardRuntime
23
28
  }
24
29
 
25
- type Phase = 'gate' | 'running' | 'memory' | 'declined' | 'finishing'
30
+ type Phase = 'gate' | 'running' | 'telegram' | 'memory' | 'declined' | 'finishing'
26
31
  type StepStatus = 'pending' | 'running' | 'ok' | 'warn' | 'fail'
27
32
  interface Step {
28
33
  key: string
29
34
  label: string
30
35
  status: StepStatus
31
36
  detail?: string
37
+ /** Honest "this can take a moment" note shown while the step is running (the
38
+ * spinner can't animate during a blocking spawnSync, so the label carries the
39
+ * expectation). */
40
+ hint?: string
32
41
  }
33
42
 
34
43
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
@@ -85,6 +94,8 @@ export function OnboardApp({
85
94
  const [phase, setPhase] = useState<Phase>('gate')
86
95
  const [steps, setSteps] = useState<Step[]>([])
87
96
  const [advisories, setAdvisories] = useState<string[]>([])
97
+ const [hostRuntime, setHostRuntime] = useState<OnboardRuntime | undefined>(undefined)
98
+ const [telegramConsent, setTelegramConsent] = useState(false)
88
99
  const spin = useSpinnerFrame(phase === 'running')
89
100
 
90
101
  const finish = (memoryConsent: boolean): void => {
@@ -94,7 +105,7 @@ export function OnboardApp({
94
105
  return ` ${mark} ${s.label}${s.detail ? ` — ${s.detail}` : ''}`
95
106
  })
96
107
  summary.unshift(failed ? 'Onboard finished with errors:' : 'Onboard complete:')
97
- onResult({ code: failed ? 1 : 0, memoryConsent, advisories, summary })
108
+ onResult({ code: failed ? 1 : 0, memoryConsent, telegramConsent, advisories, summary, hostRuntime })
98
109
  setPhase('finishing')
99
110
  setTimeout(() => exit(), 30)
100
111
  }
@@ -108,6 +119,22 @@ export function OnboardApp({
108
119
  { isActive: phase === 'gate' },
109
120
  )
110
121
 
122
+ // Telegram consent — DEFAULT-YES ([Y/n]). Records the decision; the actual setup
123
+ // (install + ask name/user_id) runs post-Ink (its readline needs a cooked
124
+ // terminal). Then → memory consent.
125
+ useInput(
126
+ (input, key) => {
127
+ if (input === 'n' || input === 'N') {
128
+ setTelegramConsent(false)
129
+ setPhase('memory')
130
+ } else if (input === 'y' || input === 'Y' || key.return) {
131
+ setTelegramConsent(true)
132
+ setPhase('memory')
133
+ }
134
+ },
135
+ { isActive: phase === 'telegram' },
136
+ )
137
+
111
138
  // Memory consent — DEFAULT-YES ([Y/n]: Enter or y → install).
112
139
  useInput(
113
140
  (input, key) => {
@@ -120,7 +147,7 @@ export function OnboardApp({
120
147
  // Declined → exit 1 (no host mutation).
121
148
  useEffect(() => {
122
149
  if (phase !== 'declined') return
123
- onResult({ code: 1, memoryConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
150
+ onResult({ code: 1, memoryConsent: false, telegramConsent: false, advisories: [], summary: ['onboard aborted — risk not accepted.'] })
124
151
  const t = setTimeout(() => exit(), 30)
125
152
  return () => clearTimeout(t)
126
153
  }, [phase, onResult, exit])
@@ -132,10 +159,16 @@ export function OnboardApp({
132
159
  const set = (k: string, patch: Partial<Step>) =>
133
160
  setSteps(prev => prev.map(s => (s.key === k ? { ...s, ...patch } : s)))
134
161
  setSteps([
135
- { key: 'daemon', label: 'Start router daemon', status: 'pending' },
162
+ { key: 'daemon', label: 'Start router daemon', status: 'pending', hint: 'a few seconds' },
136
163
  { key: 'market', label: 'Register marketplace (claude, codex)', status: 'pending' },
137
164
  { key: 'auth', label: 'Check runtime sign-in', status: 'pending' },
138
165
  { key: 'fda', label: 'Check macOS Full Disk Access', status: 'pending' },
166
+ {
167
+ key: 'notifier',
168
+ label: 'Install scheduler (timer · watcher)',
169
+ status: 'pending',
170
+ hint: 'first run can take up to a minute',
171
+ },
139
172
  ])
140
173
 
141
174
  // Backend steps use synchronous spawnSync (launchctl, runtime plugin list) —
@@ -164,10 +197,19 @@ export function OnboardApp({
164
197
  const r = onboardHost({ env })
165
198
  const installed: OnboardRuntime[] = []
166
199
  for (const m of r.marketplaces) if (m.state !== 'runtime-missing') installed.push(m.runtime)
200
+ // host-runtime for the memory init contract: claude if present (covers
201
+ // both-installed → claude default), else codex, else undefined (no runtime).
202
+ setHostRuntime(installed.includes('claude') ? 'claude' : installed[0])
167
203
  const mFail = r.marketplaces.some(m => m.state === 'failed')
204
+ const allMissing = r.marketplaces.length > 0 && r.marketplaces.every(m => m.state === 'runtime-missing')
168
205
  set('market', {
169
- status: mFail ? 'fail' : 'ok',
170
- detail: r.marketplaces.map(m => `${m.runtime}:${m.state}`).join(' '),
206
+ // runtime-missing is NOT success — show '!' (warn), never a contradictory ✓.
207
+ status: mFail ? 'fail' : allMissing ? 'warn' : 'ok',
208
+ detail: allMissing
209
+ ? 'no agent runtime installed — skipped'
210
+ : r.marketplaces
211
+ .map(m => `${m.runtime}: ${m.state === 'runtime-missing' ? 'not installed' : m.state}`)
212
+ .join(', '),
171
213
  })
172
214
  if (cancelled) return
173
215
  await tick()
@@ -188,6 +230,7 @@ export function OnboardApp({
188
230
  })
189
231
  adv.push(...notes)
190
232
  }
233
+ setAdvisories([...adv]) // surface SETUP-INCOMPLETE / auth advisories IN the wizard now
191
234
  if (cancelled) return
192
235
 
193
236
  set('fda', { status: 'running' })
@@ -197,9 +240,32 @@ export function OnboardApp({
197
240
  set('fda', { status: tcc ? 'warn' : 'ok', detail: fda === true ? 'granted' : tcc ? 'not granted' : 'n/a' })
198
241
  if (tcc) adv.push(tcc)
199
242
  if (cancelled) return
243
+ await tick()
244
+
245
+ // notifier — the scheduler (timer · watcher) that memory's index upkeep
246
+ // rides on; a silent default. Its npx install captures stdio (no terminal
247
+ // contention), so it runs in-wizard like the other steps. Idempotent: a
248
+ // present package/peers → no-op. A failure must NOT fail onboard (degrade).
249
+ set('notifier', { status: 'running' })
250
+ await tick()
251
+ try {
252
+ // canonical backbone step (same as the linear onboard path): zero-question,
253
+ // captured-stdio, idempotent, soft-skip on unavailable.
254
+ const { onboardNotifierStep } = await import('../../onboard/steps.ts')
255
+ const ns = await onboardNotifierStep({ env, warn: () => {} })
256
+ set('notifier', {
257
+ status: ns.state === 'deployed' ? 'ok' : 'warn',
258
+ detail: ns.state === 'deployed' ? `${ns.peers.length} peer(s)` : (ns.detail ?? ns.state),
259
+ })
260
+ if (ns.state !== 'deployed') adv.push(`Scheduler (notifier): ${ns.detail ?? ns.state}`)
261
+ } catch (e) {
262
+ set('notifier', { status: 'warn', detail: (e instanceof Error ? e.message : 'failed').slice(0, 60) })
263
+ }
264
+ if (cancelled) return
265
+ await tick()
200
266
 
201
- setAdvisories(adv)
202
- setPhase('memory')
267
+ setAdvisories([...adv])
268
+ setPhase('telegram')
203
269
  })()
204
270
 
205
271
  return () => {
@@ -236,23 +302,51 @@ export function OnboardApp({
236
302
  return <Text color="red">onboard aborted — risk not accepted.</Text>
237
303
  }
238
304
 
239
- // running | memory | finishing
305
+ // running | telegram | memory | finishing
240
306
  return (
241
307
  <Box flexDirection="column">
242
- <Text bold>Setting up the host…</Text>
243
- <Box flexDirection="column" marginTop={1}>
308
+ <Box marginBottom={1}>
309
+ <Text bold color="cyan">
310
+ iapeer
311
+ </Text>
312
+ <Text bold> · setting up the host</Text>
313
+ </Box>
314
+ <Box flexDirection="column">
244
315
  {steps.map(s => (
245
316
  <Box key={s.key}>
246
317
  <Text color={color(s.status)}>{glyph(s.status, spin)} </Text>
247
318
  <Text>{s.label}</Text>
248
- {s.detail ? <Text dimColor> — {s.detail}</Text> : null}
319
+ {s.detail ? (
320
+ <Text dimColor> — {s.detail}</Text>
321
+ ) : s.status === 'running' && s.hint ? (
322
+ <Text dimColor> — {s.hint}…</Text>
323
+ ) : null}
249
324
  </Box>
250
325
  ))}
251
326
  </Box>
327
+ {advisories.length > 0 ? (
328
+ <Box marginTop={1} flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
329
+ {advisories.map((a, i) => (
330
+ <Text key={i} color="yellow">
331
+ {a}
332
+ </Text>
333
+ ))}
334
+ </Box>
335
+ ) : null}
336
+ {phase === 'telegram' ? (
337
+ <Box marginTop={1} flexDirection="column">
338
+ <Text bold color="cyan">
339
+ ? Set up Telegram — talk to your agents from your phone?
340
+ </Text>
341
+ <Text dimColor> It will ask your name + Telegram user_id next. (bot token comes later) [Y/n]</Text>
342
+ </Box>
343
+ ) : null}
252
344
  {phase === 'memory' ? (
253
345
  <Box marginTop={1} flexDirection="column">
254
- <Text>Install the shared-memory provider (@agfpd/iapeer-memory)?</Text>
255
- <Text dimColor>It gives your agents a shared knowledge vault. [Y/n] </Text>
346
+ <Text bold color="cyan">
347
+ ? Install the shared-memory provider (@agfpd/iapeer-memory)?
348
+ </Text>
349
+ <Text dimColor> Gives your agents a shared knowledge vault. [Y/n]</Text>
256
350
  </Box>
257
351
  ) : null}
258
352
  </Box>
@@ -36,7 +36,7 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
36
36
  return WIZARD_NOT_INTERACTIVE
37
37
  }
38
38
 
39
- let result: WizardResult = { code: 0, memoryConsent: false, advisories: [], summary: [] }
39
+ let result: WizardResult = { code: 0, memoryConsent: false, telegramConsent: false, advisories: [], summary: [] }
40
40
  try {
41
41
  const app = render(<OnboardApp env={env} onResult={r => (result = r)} />)
42
42
  await app.waitUntilExit()
@@ -44,20 +44,52 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
44
44
  return WIZARD_NOT_INTERACTIVE
45
45
  }
46
46
 
47
- // Post-Ink: the memory provider install owns the terminal (inherited stdio).
48
- // Ink is fully unmounted now, so there is no contention.
49
- if (result.memoryConsent) {
50
- const { onboardMemoryProvider } = await import('../../onboard/memory.ts')
51
- process.stdout.write('\nInstalling shared-memory provider (@agfpd/iapeer-memory)…\n')
52
- try {
53
- const mem = await onboardMemoryProvider({ env })
54
- const label = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
55
- process.stdout.write(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${label})\n`)
56
- } catch (e) {
57
- process.stdout.write(`memory: step failed — ${e instanceof Error ? e.message : String(e)}\n`)
47
+ // Post-Ink interactive provisioning — telegram THEN memory (the canonical order:
48
+ // telegram creates the human peer that memory's --human resolves from). These own
49
+ // the terminal (inherited stdio / readline), so they run AFTER Ink unmounts (no
50
+ // contention). NON-WEDGE: ignore SIGINT in the parent so Ctrl-C kills only the
51
+ // CURRENT step's child/readline and onboard continues to the summary, instead of
52
+ // the whole flow dying. The no-op handler is queued past the blocking spawnSync.
53
+ const prevInt = process.listeners('SIGINT')
54
+ process.removeAllListeners('SIGINT')
55
+ const ignore = (): void => {}
56
+ process.on('SIGINT', ignore)
57
+ try {
58
+ if (result.telegramConsent) {
59
+ const { onboardTelegramStep } = await import('../../onboard/steps.ts')
60
+ process.stdout.write('\nSetting up Telegram… (Ctrl-C to skip — add later: `iapeer create <you> --runtime telegram`)\n')
61
+ try {
62
+ const ts = await onboardTelegramStep({ env })
63
+ process.stdout.write(`telegram: ${ts.state}${ts.detail ? ` — ${ts.detail}` : ''}\n`)
64
+ } catch (e) {
65
+ process.stdout.write(`telegram: step skipped — ${e instanceof Error ? e.message : String(e)}\n`)
66
+ }
67
+ } else {
68
+ process.stdout.write('\ntelegram: skipped — add later with `iapeer create <you> --runtime telegram`\n')
69
+ }
70
+
71
+ if (result.memoryConsent) {
72
+ const { onboardMemoryProvider } = await import('../../onboard/memory.ts')
73
+ process.stdout.write(
74
+ '\nInstalling the shared-memory provider (@agfpd/iapeer-memory)…\n' +
75
+ 'This can take a few minutes — it builds a native module. Memory is OPTIONAL:\n' +
76
+ 'press Ctrl-C to skip it (set it up later with `npx @agfpd/iapeer-memory init`).\n\n',
77
+ )
78
+ try {
79
+ const mem = await onboardMemoryProvider({ env, runtime: result.hostRuntime, timeoutMs: 12 * 60_000 })
80
+ const label = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
81
+ process.stdout.write(`\nmemory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${label})\n`)
82
+ } catch (e) {
83
+ process.stdout.write(
84
+ `\nmemory: step skipped — ${e instanceof Error ? e.message : String(e)} (set up later: \`npx @agfpd/iapeer-memory init\`)\n`,
85
+ )
86
+ }
87
+ } else {
88
+ process.stdout.write('\nmemory: skipped — set it up later with `npx @agfpd/iapeer-memory init`\n')
58
89
  }
59
- } else {
60
- process.stdout.write('\nmemory: skipped — set it up later with `iapeer onboard` or `npx @agfpd/iapeer-memory init`\n')
90
+ } finally {
91
+ process.removeListener('SIGINT', ignore)
92
+ for (const h of prevInt) process.on('SIGINT', h as NodeJS.SignalsListener)
61
93
  }
62
94
 
63
95
  // Final summary (plain text, post-Ink).
@@ -67,6 +99,12 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
67
99
  process.stdout.write('\n')
68
100
  for (const a of result.advisories) process.stdout.write(a + '\n')
69
101
  }
70
- process.stdout.write('\nNext: create your first agent — `iapeer create <name> --runtime claude`\n')
102
+ process.stdout.write('\nNext steps:\n')
103
+ process.stdout.write(' • Create your first agent: iapeer create <name> --runtime claude\n')
104
+ if (result.telegramConsent) {
105
+ // The wizard set up the human peer (name + user_id) but NOT the bot itself —
106
+ // the bot token is a separate, explicit step (no surprise / don't-make-them-guess).
107
+ process.stdout.write(' • Activate Telegram: iapeer connect telegram (paste a bot token from @BotFather)\n')
108
+ }
71
109
  return result.code
72
110
  }
@@ -0,0 +1,244 @@
1
+ // uninstall — symmetric removal of THIS foundation install (the install.sh / `iapeer
2
+ // install` / onboard artifacts). NAMESPACE-SAFE: it never touches a foreign
3
+ // persistent-peer fleet. A peer plist at the shared `com.iapeer.*` namespace WITHOUT
4
+ // the ownership sentinel is foreign; if ANY is present we REFUSE (option-1) — removing
5
+ // ~/.iapeer would nuke the fleet's own state, which H4 forbids. The operator removes
6
+ // their peers first (`iapeer remove <p>`), then uninstalls.
7
+ //
8
+ // Removed: ~/.local/bin/iapeer (+ .prev) · the daemon plist (com.agfpd.iapeer,
9
+ // bootout→rm) · foundation-OWNED infra jobs (sentinel-marked com.iapeer.*, bootout→rm)
10
+ // · the ~/.iapeer scaffold · the installer's PATH lines in the shell profile.
11
+ // CONSERVATIVE (kept by default): the shared agfpd-stack codesign identity (removed
12
+ // only with removeCodesignIdentity) and bun (the user's). Plan is read-only — `--dry-run`
13
+ // renders it without touching anything. Fail-closed in the test sandbox: a real
14
+ // ~/.iapeer / ~/.local/bin path under IAPEER_TEST_SANDBOX=1 throws (tests must inject
15
+ // temp roots), so a hermetic test can never delete the real install.
16
+
17
+ import { spawnSync } from 'child_process'
18
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
19
+ import { homedir } from 'os'
20
+ import { join } from 'path'
21
+ import { DAEMON_PLIST_LABEL, IapError } from '../core/index.ts'
22
+ import { iapeerBinPath } from '../install/index.ts'
23
+ import { SIGNING_IDENTITY_CN } from '../install/signing.ts'
24
+ import { daemonPlistPath } from '../daemon/main.ts'
25
+ import { isFoundationOwnedPlist, launchAgentsDir } from '../launch/launchd.ts'
26
+ import { resolveGlobalRoot } from '../storage/index.ts'
27
+
28
+ /** The marker comment install.sh writes above its PATH line (strip both lines). */
29
+ const INSTALLER_PROFILE_MARKER = '# Added by the iapeer installer'
30
+
31
+ export type UninstallActionKind =
32
+ | 'remove-file'
33
+ | 'remove-dir'
34
+ | 'bootout-remove-plist'
35
+ | 'strip-profile-lines'
36
+ | 'remove-keychain-identity'
37
+
38
+ export interface UninstallItem {
39
+ what: string
40
+ action: UninstallActionKind
41
+ path?: string
42
+ /** True when the artifact exists / there is something to do (dry-run shows all;
43
+ * absent ones render as "already gone"). */
44
+ present: boolean
45
+ detail?: string
46
+ }
47
+
48
+ export interface UninstallPlan {
49
+ items: UninstallItem[]
50
+ /** Set when a FOREIGN persistent-peer fleet is present → the whole uninstall is
51
+ * refused (option-1, H4-safe). The caller prints the guidance and aborts. */
52
+ refused?: { reason: string; foreignLabels: string[] }
53
+ }
54
+
55
+ /** Injected launchctl/security runner (tests). Default: real spawnSync. */
56
+ export type SysRunner = (cmd: string, args: string[]) => { status: number | null; stderr: string }
57
+
58
+ export interface UninstallOptions {
59
+ env?: NodeJS.ProcessEnv
60
+ /** --remove-codesign-identity: also delete the SHARED agfpd-stack signing identity. */
61
+ removeCodesignIdentity?: boolean
62
+ /** Injected sys runner (launchctl / security) for hermetic tests. */
63
+ run?: SysRunner
64
+ }
65
+
66
+ const defaultRun: SysRunner = (cmd, args) => {
67
+ const r = spawnSync(cmd, args, { encoding: 'utf8' })
68
+ return { status: r.status, stderr: (r.stderr ?? '').trim() }
69
+ }
70
+
71
+ function home(env: NodeJS.ProcessEnv): string {
72
+ return env.HOME?.trim() || homedir()
73
+ }
74
+
75
+ /** Shell-profile files the installer may have edited (per its $SHELL branch). */
76
+ function profileFiles(env: NodeJS.ProcessEnv): string[] {
77
+ const h = home(env)
78
+ return [join(h, '.zshrc'), join(h, '.bash_profile'), join(h, '.profile')]
79
+ }
80
+
81
+ /** All com.iapeer.*.plist in LaunchAgents, split into foundation-owned (sentinel)
82
+ * vs foreign (the persistent-peer fleet). */
83
+ function scanInfraPlists(env: NodeJS.ProcessEnv): { owned: string[]; foreign: string[] } {
84
+ const dir = launchAgentsDir(env)
85
+ const owned: string[] = []
86
+ const foreign: string[] = []
87
+ let entries: string[]
88
+ try {
89
+ entries = readdirSync(dir)
90
+ } catch {
91
+ return { owned, foreign }
92
+ }
93
+ for (const e of entries) {
94
+ if (!e.startsWith('com.iapeer.') || !e.endsWith('.plist')) continue
95
+ const p = join(dir, e)
96
+ ;(isFoundationOwnedPlist(p) ? owned : foreign).push(p)
97
+ }
98
+ return { owned, foreign }
99
+ }
100
+
101
+ /** Fail-closed: under the test sandbox, refuse to operate on the REAL install paths
102
+ * (a hermetic test MUST inject temp roots). Mirrors installIapeer's guard. */
103
+ function assertSandboxSafe(env: NodeJS.ProcessEnv): void {
104
+ if (env.IAPEER_TEST_SANDBOX !== '1' && process.env.IAPEER_TEST_SANDBOX !== '1') return
105
+ const root = resolveGlobalRoot(env)
106
+ const realRoot = join(homedir(), '.iapeer')
107
+ const bin = iapeerBinPath(env)
108
+ const realBin = join(homedir(), '.local', 'bin', 'iapeer')
109
+ if (root === realRoot || bin === realBin) {
110
+ throw new IapError(
111
+ 'refusing to uninstall the REAL install under IAPEER_TEST_SANDBOX=1 — inject IAPEER_ROOT / IAPEER_BIN_DIR temp paths',
112
+ )
113
+ }
114
+ }
115
+
116
+ /** Read-only: compute what an uninstall would remove + the foreign-fleet refusal. */
117
+ export function planUninstall(opts: UninstallOptions = {}): UninstallPlan {
118
+ const env = opts.env ?? process.env
119
+ const { owned, foreign } = scanInfraPlists(env)
120
+
121
+ if (foreign.length > 0) {
122
+ const foreignLabels = foreign.map(p => p.replace(/\.plist$/, '').split('/').pop() ?? p)
123
+ return {
124
+ items: [],
125
+ refused: {
126
+ foreignLabels,
127
+ reason:
128
+ `persistent-peer fleet present (${foreignLabels.join(', ')}). Removing ~/.iapeer would ` +
129
+ `destroy their state. Remove those peers first (\`iapeer remove <peer>\`), then re-run uninstall.`,
130
+ },
131
+ }
132
+ }
133
+
134
+ const bin = iapeerBinPath(env)
135
+ const prev = `${bin}.prev`
136
+ const dPlist = daemonPlistPath(env)
137
+ const root = resolveGlobalRoot(env)
138
+ const items: UninstallItem[] = []
139
+
140
+ // launchd: daemon first, then foundation-owned infra jobs (bootout → rm).
141
+ items.push({
142
+ what: `daemon job ${DAEMON_PLIST_LABEL}`,
143
+ action: 'bootout-remove-plist',
144
+ path: dPlist,
145
+ present: existsSync(dPlist),
146
+ })
147
+ for (const p of owned) {
148
+ items.push({
149
+ what: `infra job ${p.replace(/\.plist$/, '').split('/').pop()}`,
150
+ action: 'bootout-remove-plist',
151
+ path: p,
152
+ present: true,
153
+ })
154
+ }
155
+
156
+ items.push({ what: 'binary ~/.local/bin/iapeer', action: 'remove-file', path: bin, present: existsSync(bin) })
157
+ items.push({ what: 'previous binary (.prev)', action: 'remove-file', path: prev, present: existsSync(prev) })
158
+ items.push({ what: 'state + config ~/.iapeer', action: 'remove-dir', path: root, present: existsSync(root) })
159
+
160
+ for (const pf of profileFiles(env)) {
161
+ const has = existsSync(pf) && readFileSync(pf, 'utf8').includes(INSTALLER_PROFILE_MARKER)
162
+ if (has) items.push({ what: `PATH line in ${pf}`, action: 'strip-profile-lines', path: pf, present: true })
163
+ }
164
+
165
+ items.push({
166
+ what: `codesign identity "${SIGNING_IDENTITY_CN}"`,
167
+ action: 'remove-keychain-identity',
168
+ present: opts.removeCodesignIdentity === true,
169
+ detail: opts.removeCodesignIdentity
170
+ ? 'will remove (shared agfpd-stack identity — other agfpd products re-prompt on next update)'
171
+ : 'KEPT (shared agfpd-stack identity) — pass --remove-codesign-identity to remove',
172
+ })
173
+
174
+ return { items }
175
+ }
176
+
177
+ export interface UninstallResult {
178
+ removed: string[]
179
+ skipped: string[]
180
+ failed: { what: string; detail: string }[]
181
+ refused?: { reason: string; foreignLabels: string[] }
182
+ }
183
+
184
+ /** Remove a foundation install per the plan. Refuses if a foreign fleet is present. */
185
+ export function executeUninstall(opts: UninstallOptions = {}): UninstallResult {
186
+ const env = opts.env ?? process.env
187
+ assertSandboxSafe(env)
188
+ const run = opts.run ?? defaultRun
189
+ const plan = planUninstall(opts)
190
+ const res: UninstallResult = { removed: [], skipped: [], failed: [] }
191
+ if (plan.refused) {
192
+ res.refused = plan.refused
193
+ return res
194
+ }
195
+
196
+ const uid = process.getuid?.() ?? 0
197
+ for (const item of plan.items) {
198
+ if (!item.present) {
199
+ res.skipped.push(item.what)
200
+ continue
201
+ }
202
+ try {
203
+ switch (item.action) {
204
+ case 'bootout-remove-plist': {
205
+ const label = (item.path ?? '').replace(/\.plist$/, '').split('/').pop() ?? ''
206
+ run('launchctl', ['bootout', `gui/${uid}/${label}`]) // best-effort (not-loaded → non-zero, fine)
207
+ if (item.path) rmSync(item.path, { force: true })
208
+ break
209
+ }
210
+ case 'remove-file':
211
+ if (item.path) rmSync(item.path, { force: true })
212
+ break
213
+ case 'remove-dir':
214
+ if (item.path) rmSync(item.path, { recursive: true, force: true })
215
+ break
216
+ case 'strip-profile-lines':
217
+ if (item.path) stripInstallerLines(item.path)
218
+ break
219
+ case 'remove-keychain-identity':
220
+ run('security', ['delete-identity', '-c', SIGNING_IDENTITY_CN])
221
+ break
222
+ }
223
+ res.removed.push(item.what)
224
+ } catch (e) {
225
+ res.failed.push({ what: item.what, detail: e instanceof Error ? e.message : String(e) })
226
+ }
227
+ }
228
+ return res
229
+ }
230
+
231
+ /** Remove the installer's marker comment + the export PATH line it precedes. */
232
+ function stripInstallerLines(profile: string): void {
233
+ const lines = readFileSync(profile, 'utf8').split('\n')
234
+ const out: string[] = []
235
+ for (let i = 0; i < lines.length; i++) {
236
+ if (lines[i]?.trim() === INSTALLER_PROFILE_MARKER) {
237
+ // skip the marker AND the following export PATH line the installer wrote
238
+ if (lines[i + 1]?.includes('.local/bin')) i++
239
+ continue
240
+ }
241
+ out.push(lines[i]!)
242
+ }
243
+ writeFileSync(profile, out.join('\n'), { mode: 0o644 })
244
+ }