@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 +1 -1
- package/src/cli/index.ts +48 -0
- package/src/index.ts +2 -0
- package/src/onboard/memory.ts +28 -6
- package/src/tui/onboard/app.tsx +108 -14
- package/src/tui/onboard/run.tsx +53 -15
- package/src/uninstall/index.ts +244 -0
package/package.json
CHANGED
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.
|
package/src/onboard/memory.ts
CHANGED
|
@@ -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?: (
|
|
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
|
-
|
|
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',
|
package/src/tui/onboard/app.tsx
CHANGED
|
@@ -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
|
-
|
|
170
|
-
|
|
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('
|
|
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
|
-
<
|
|
243
|
-
|
|
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 ?
|
|
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
|
|
255
|
-
|
|
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>
|
package/src/tui/onboard/run.tsx
CHANGED
|
@@ -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
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
}
|
|
60
|
-
process.
|
|
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
|
|
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
|
+
}
|