@agfpd/iapeer 0.3.0 → 0.4.1

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.
@@ -19,6 +19,7 @@
19
19
  import React from 'react'
20
20
  import { render } from 'ink'
21
21
  import { OnboardApp, type WizardResult } from './app.tsx'
22
+ import { type Ansi, colorEnabled, makeAnsi } from '../ansi.ts'
22
23
 
23
24
  /** Sentinel: the environment is not a real interactive TTY — caller should fall
24
25
  * back to the linear non-interactive onboard path (never render raw + hang). */
@@ -28,6 +29,36 @@ export interface OnboardWizardOptions {
28
29
  env?: NodeJS.ProcessEnv
29
30
  }
30
31
 
32
+ /** Style one post-Ink summary line to match the Ink wizard exactly: the headline
33
+ * (no leading glyph) bold — red when it announces errors; each item line keeps its
34
+ * glyph colored by status (✓ green / ! yellow / ✗ red / · gray) and dims the
35
+ * trailing " — detail", just as the in-wizard render does. */
36
+ function paintSummaryLine(line: string, a: Ansi): string {
37
+ const m = line.match(/^(\s*)([✓!✗·]) (.*)$/)
38
+ if (!m) return /error/i.test(line) ? a.bold(a.red(line)) : a.bold(line)
39
+ const [, indent, mark, rest] = m
40
+ const paintMark = mark === '✓' ? a.green : mark === '!' ? a.yellow : mark === '✗' ? a.red : a.gray
41
+ const i = rest.indexOf(' — ')
42
+ const label = i >= 0 ? rest.slice(0, i) : rest
43
+ const detail = i >= 0 ? rest.slice(i) : ''
44
+ return `${indent}${paintMark(mark)} ${label}${detail ? a.dim(detail) : ''}`
45
+ }
46
+
47
+ /** Color a `prefix: state[ rest]` progress line by the state's semantics, matching
48
+ * the wizard palette + the cross-CLI convention agreed with iapeer-memory: red for
49
+ * failure, GRAY for a neutral skip/decline (· semantics — yellow is reserved for
50
+ * warn/attention, not "user chose not to"), yellow for warn, green otherwise. */
51
+ function paintResult(prefix: string, state: string, rest: string, a: Ansi): string {
52
+ const paint = /(fail|error|refus)/i.test(state)
53
+ ? a.red
54
+ : /(skip|declin)/i.test(state)
55
+ ? a.gray
56
+ : /(warn|missing|incomplete)/i.test(state)
57
+ ? a.yellow
58
+ : a.green
59
+ return `${a.bold(`${prefix}:`)} ${paint(state)}${rest}`
60
+ }
61
+
31
62
  /** Render the interactive onboard wizard. Resolves with the process exit code, or
32
63
  * WIZARD_NOT_INTERACTIVE when there is no real TTY to drive it. */
33
64
  export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise<number> {
@@ -35,8 +66,12 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
35
66
  if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
36
67
  return WIZARD_NOT_INTERACTIVE
37
68
  }
69
+ // Post-Ink lines are plain stdout (Ink has unmounted), so they carry their own
70
+ // ANSI to stay visually continuous with the wizard. Gated: real TTY here (the
71
+ // wizard only runs on one), off under NO_COLOR.
72
+ const a = makeAnsi(colorEnabled(env, process.stdout))
38
73
 
39
- let result: WizardResult = { code: 0, memoryConsent: false, advisories: [], summary: [] }
74
+ let result: WizardResult = { code: 0, memoryConsent: false, telegramConsent: false, advisories: [], summary: [] }
40
75
  try {
41
76
  const app = render(<OnboardApp env={env} onResult={r => (result = r)} />)
42
77
  await app.waitUntilExit()
@@ -44,29 +79,92 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
44
79
  return WIZARD_NOT_INTERACTIVE
45
80
  }
46
81
 
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`)
82
+ // Post-Ink interactive provisioning — telegram THEN memory (the canonical order:
83
+ // telegram creates the human peer that memory's --human resolves from). These own
84
+ // the terminal (inherited stdio / readline), so they run AFTER Ink unmounts (no
85
+ // contention). NON-WEDGE: ignore SIGINT in the parent so Ctrl-C kills only the
86
+ // CURRENT step's child/readline and onboard continues to the summary, instead of
87
+ // the whole flow dying. The no-op handler is queued past the blocking spawnSync.
88
+ const prevInt = process.listeners('SIGINT')
89
+ process.removeAllListeners('SIGINT')
90
+ const ignore = (): void => {}
91
+ process.on('SIGINT', ignore)
92
+ try {
93
+ if (result.telegramConsent) {
94
+ const { onboardTelegramStep } = await import('../../onboard/steps.ts')
95
+ process.stdout.write(
96
+ '\n' +
97
+ a.bold('Setting up Telegram…') +
98
+ a.dim(' (Ctrl-C to skip — add later: `iapeer create <you> --runtime telegram`)') +
99
+ '\n',
100
+ )
101
+ try {
102
+ const ts = await onboardTelegramStep({ env })
103
+ process.stdout.write(paintResult('telegram', ts.state, ts.detail ? ` — ${ts.detail}` : '', a) + '\n')
104
+ } catch (e) {
105
+ process.stdout.write(paintResult('telegram', 'step skipped', ` — ${e instanceof Error ? e.message : String(e)}`, a) + '\n')
106
+ }
107
+ } else {
108
+ process.stdout.write(
109
+ '\n' + paintResult('telegram', 'skipped', ' — add later with `iapeer create <you> --runtime telegram`', a) + '\n',
110
+ )
111
+ }
112
+
113
+ if (result.memoryConsent) {
114
+ const { onboardMemoryProvider } = await import('../../onboard/memory.ts')
115
+ process.stdout.write(
116
+ '\n' +
117
+ a.bold('Installing the shared-memory provider (@agfpd/iapeer-memory)…') +
118
+ '\n' +
119
+ a.dim(
120
+ 'This can take a few minutes — it builds a native module. Memory is OPTIONAL:\n' +
121
+ 'press Ctrl-C to skip it (set it up later with `npx @agfpd/iapeer-memory init`).',
122
+ ) +
123
+ '\n\n',
124
+ )
125
+ try {
126
+ const mem = await onboardMemoryProvider({ env, runtime: result.hostRuntime, timeoutMs: 12 * 60_000 })
127
+ const label = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
128
+ const rest = `${mem.detail ? ` — ${mem.detail}` : ''} ${a.dim(`(slot: ${label})`)}`
129
+ process.stdout.write('\n' + paintResult('memory', mem.state, rest, a) + '\n')
130
+ } catch (e) {
131
+ const rest = ` — ${e instanceof Error ? e.message : String(e)} ${a.dim('(set up later: `npx @agfpd/iapeer-memory init`)')}`
132
+ process.stdout.write('\n' + paintResult('memory', 'step skipped', rest, a) + '\n')
133
+ }
134
+ } else {
135
+ process.stdout.write(
136
+ '\n' + paintResult('memory', 'skipped', ' — set it up later with `npx @agfpd/iapeer-memory init`', a) + '\n',
137
+ )
58
138
  }
59
- } else {
60
- process.stdout.write('\nmemory: skipped — set it up later with `iapeer onboard` or `npx @agfpd/iapeer-memory init`\n')
139
+ } finally {
140
+ process.removeListener('SIGINT', ignore)
141
+ for (const h of prevInt) process.on('SIGINT', h as NodeJS.SignalsListener)
61
142
  }
62
143
 
63
- // Final summary (plain text, post-Ink).
144
+ // Final summary (post-Ink, ANSI-styled to match the wizard's palette).
64
145
  process.stdout.write('\n')
65
- for (const line of result.summary) process.stdout.write(line + '\n')
146
+ for (const line of result.summary) process.stdout.write(paintSummaryLine(line, a) + '\n')
66
147
  if (result.advisories.length > 0) {
67
148
  process.stdout.write('\n')
68
- for (const a of result.advisories) process.stdout.write(a + '\n')
149
+ // Advisories mirror the wizard's yellow box (a multi-line entry stays yellow
150
+ // line-to-line until the trailing reset).
151
+ for (const adv of result.advisories) process.stdout.write(a.yellow(adv) + '\n')
152
+ }
153
+
154
+ // Next steps — an aligned 2-column block (label padded, command highlighted), the
155
+ // bot-token left as its own explicit step (no surprise / don't-make-them-guess).
156
+ const next: Array<{ label: string; cmd: string; note?: string }> = [
157
+ { label: 'Create your first agent', cmd: 'iapeer create <name> --runtime claude' },
158
+ ]
159
+ if (result.telegramConsent) {
160
+ next.push({ label: 'Activate Telegram', cmd: 'iapeer connect telegram', note: 'paste a bot token from @BotFather' })
161
+ }
162
+ const w = Math.max(...next.map(n => n.label.length))
163
+ process.stdout.write('\n' + a.bold('Next steps:') + '\n')
164
+ for (const n of next) {
165
+ process.stdout.write(
166
+ ` ${a.cyan('•')} ${n.label.padEnd(w)} ${a.cyan(n.cmd)}${n.note ? ' ' + a.dim(`(${n.note})`) : ''}\n`,
167
+ )
69
168
  }
70
- process.stdout.write('\nNext: create your first agent — `iapeer create <name> --runtime claude`\n')
71
169
  return result.code
72
170
  }
@@ -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
+ }