@agfpd/iapeer 0.4.0 → 0.4.2

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/src/init/index.ts CHANGED
@@ -21,6 +21,7 @@ import { IapError } from '../core/errors.ts'
21
21
  import {
22
22
  ensureLocalIapScaffold,
23
23
  pluginStateDir,
24
+ resolveGlobalRoot,
24
25
  writeFileAtomic,
25
26
  type StorageOptions,
26
27
  } from '../storage/index.ts'
@@ -245,6 +246,36 @@ export function ensureDoctrineTemplate(cwd: string): { path: string; created: bo
245
246
  return { path, created: true }
246
247
  }
247
248
 
249
+ /**
250
+ * Create the GLOBAL host-doctrine template `~/.iapeer/IAPEER.md` if absent — the
251
+ * host-wide owner doctrine (Layer-2 GLOBAL in composeSystemPrompt, shared by EVERY
252
+ * peer; the general layer ABOVE each peer's own `.iapeer/IAPEER.md`). It is consumed at
253
+ * launch but was never scaffolded, so a fresh install left no host-doctrine file for the
254
+ * owner to find — asymmetric with the per-peer stub (ensureDoctrineTemplate). This
255
+ * mirrors that stub at host scope (discoverability + consistency). NEVER overwrites an
256
+ * existing host doctrine. Returns {path, created}.
257
+ */
258
+ export function ensureGlobalDoctrineTemplate(env: NodeJS.ProcessEnv = process.env): { path: string; created: boolean } {
259
+ const root = resolveGlobalRoot(env)
260
+ const path = join(root, IAPEER_DOCTRINE_FILE)
261
+ if (existsSync(path)) return { path, created: false }
262
+ mkdirSync(root, { recursive: true })
263
+ writeFileSync(
264
+ path,
265
+ [
266
+ '# Host doctrine',
267
+ '',
268
+ '<!-- Host-wide rules shared by EVERY peer on this machine — the general layer,',
269
+ " merged into every peer's system prompt at launch ABOVE that peer's own",
270
+ ' .iapeer/IAPEER.md. Replace this with your host-wide conventions. An empty',
271
+ ' file contributes nothing. -->',
272
+ '',
273
+ ].join('\n'),
274
+ { mode: 0o644 },
275
+ )
276
+ return { path, created: true }
277
+ }
278
+
248
279
  // ─────────────────────────────────────────────────────────────────────────────
249
280
  // initPeer — orchestrate: provision (identity + registry + infra plist) + per-peer
250
281
  // ecosystem wiring (MCP transport per agentic runtime + doctrine template)
@@ -13,10 +13,11 @@
13
13
  // signInstalledBinary (signing.ts) re-signs each install with the
14
14
  // stable local identity so the designated requirement — and the grants — survive.
15
15
 
16
- import { copyFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs'
16
+ import { copyFileSync, cpSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
17
17
  import { homedir } from 'os'
18
- import { join } from 'path'
18
+ import { basename, join, relative, sep } from 'path'
19
19
  import { spawnSync } from 'child_process'
20
+ import { resolveGlobalRoot } from '../storage/index.ts'
20
21
  import { signInstalledBinary, type SigningOutcome } from './signing.ts'
21
22
 
22
23
  /** The stable host-wide install path of the `iapeer` binary. Standard user-bin (no
@@ -95,6 +96,64 @@ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = pr
95
96
  return { binPath, prevPath, size, signing }
96
97
  }
97
98
 
99
+ /**
100
+ * Scaffold an ecosystem package's docs to the STABLE, versioned, per-package host
101
+ * path `~/.iapeer/docs/<pkg>/` so an agent can read the contract OFFLINE (a compiled
102
+ * binary embeds no docs; the npm tarball's docs/ is discarded after install — proven:
103
+ * `iapeer update` extracts to a temp dir it rm's in a `finally`, and the bunx cache
104
+ * does not retain the package findably). This is the FOUNDATION-OWNED CONVENTION every
105
+ * ecosystem package follows: each copies its OWN docs on its OWN install/update, so a
106
+ * package's on-host docs always match its installed version.
107
+ *
108
+ * Mechanics: copy <docsSource> → ~/.iapeer/docs/<pkg> EXCLUDING internals/ (mirroring
109
+ * the npm `files` exclusion), via an atomic temp-dir swap so a reader never sees a
110
+ * half-copied tree. BEST-EFFORT — a missing source or copy hiccup NEVER fails the
111
+ * install (the binary works without on-host docs); the caller logs the outcome.
112
+ */
113
+ export function scaffoldHostDocs(
114
+ pkg: string,
115
+ docsSource: string,
116
+ env: NodeJS.ProcessEnv = process.env,
117
+ ): { copied: boolean; dest: string; reason?: string } {
118
+ const root = resolveGlobalRoot(env)
119
+ const dest = join(root, 'docs', pkg)
120
+ // Fail-closed sandbox guard (in spirit with assertInstallSandboxIsolated): never write
121
+ // the REAL machine ~/.iapeer under a sandboxed test that forgot to set IAPEER_ROOT.
122
+ // Compared against the ACTUAL OS home (homedir()), not env.HOME — the root IS the docs
123
+ // isolation, so an env.HOME-based check would false-trip when a test legitimately
124
+ // points IAPEER_ROOT at <tmp>/.iapeer (with env.HOME also <tmp>).
125
+ if (env.IAPEER_TEST_SANDBOX === '1' && root === join(homedir(), '.iapeer')) {
126
+ throw new Error(`refusing to scaffold docs into the REAL ${join(root, 'docs')} under IAPEER_TEST_SANDBOX=1 — set IAPEER_ROOT`)
127
+ }
128
+ if (!existsSync(docsSource)) return { copied: false, dest, reason: `docs source not found: ${docsSource}` }
129
+ const tmp = `${dest}.tmp-${process.pid}`
130
+ try {
131
+ rmSync(tmp, { recursive: true, force: true })
132
+ mkdirSync(join(dest, '..'), { recursive: true })
133
+ cpSync(docsSource, tmp, {
134
+ recursive: true,
135
+ // Skip the internals/ subtree (matches package.json files: "!docs/internals";
136
+ // returning false for a directory skips its whole subtree) and macOS .DS_Store
137
+ // cruft (the foundation is macOS-targeted, so every dev/host tree carries it).
138
+ filter: src => {
139
+ if (basename(src) === '.DS_Store') return false
140
+ const rel = relative(docsSource, src)
141
+ return rel !== 'internals' && !rel.startsWith(`internals${sep}`)
142
+ },
143
+ })
144
+ rmSync(dest, { recursive: true, force: true })
145
+ renameSync(tmp, dest)
146
+ return { copied: true, dest }
147
+ } catch (e) {
148
+ try {
149
+ rmSync(tmp, { recursive: true, force: true })
150
+ } catch {
151
+ /* best-effort cleanup */
152
+ }
153
+ return { copied: false, dest, reason: e instanceof Error ? e.message : String(e) }
154
+ }
155
+ }
156
+
98
157
  /** The previous-binary path kept by the last install for one-step rollback. */
99
158
  export function iapeerPrevBinPath(env: NodeJS.ProcessEnv = process.env): string {
100
159
  return `${iapeerBinPath(env)}.prev`
@@ -103,6 +103,14 @@ export function composeSystemPrompt(input: ComposePromptInput): string {
103
103
  // The YAML identity block is the only Layer-1 section (synthesized, no marker).
104
104
  const sections: string[] = [yaml]
105
105
 
106
+ // Host-context pointer to the on-host ecosystem docs (FU6). Each package's install
107
+ // scaffolds its own contract docs to the stable, versioned, per-package path
108
+ // ~/.iapeer/docs/<package>/, so an agent can read "how does iapeer do X" offline
109
+ // instead of guessing. Synthesized Layer-1 fact (no marker). Wording: owner-approved.
110
+ sections.push(
111
+ 'iapeer reference docs are on this host at ~/.iapeer/docs/ — one folder per package (start at iapeer/README.md). Consult them for how iapeer works instead of guessing.',
112
+ )
113
+
106
114
  // Layer 2 — IAPEER.md doctrine, GLOBAL then LOCAL (general → specific), each its
107
115
  // OWN marked file-section. The bash existence-gate (present-but-empty global → a
108
116
  // bare "\n") is dropped deliberately: an empty doctrine file now contributes no
@@ -144,3 +144,74 @@ export async function onboardMemoryProvider(opts: MemoryOnboardOptions = {}): Pr
144
144
  }
145
145
  return { state: 'installed', provider: after }
146
146
  }
147
+
148
+ // ─────────────────────────────────────────────────────────────────────────────
149
+ // updateMemoryProvider — the memory leg of the cascade `iapeer update` (FU12)
150
+ // ─────────────────────────────────────────────────────────────────────────────
151
+
152
+ export interface MemoryUpdateResult {
153
+ state:
154
+ | 'updated' // provider update ran and the slot version advanced
155
+ | 'already-latest' // ran but the slot version is unchanged
156
+ | 'no-slot' // no provider claimed — nothing to update
157
+ | 'skipped-unavailable' // package not published / no network — soft skip
158
+ | 'failed' // provider update exited non-zero
159
+ package?: string
160
+ from?: string
161
+ to?: string
162
+ detail?: string
163
+ }
164
+
165
+ /** The provider's CLI bin name = the unscoped package name (`@agfpd/iapeer-memory` →
166
+ * `iapeer-memory`). */
167
+ function memoryBinName(pkg: string): string {
168
+ return pkg.replace(/^@[^/]+\//, '')
169
+ }
170
+
171
+ function defaultRunMemoryUpdate(pkg: string, env: NodeJS.ProcessEnv): { status: number | null; unavailable: boolean } {
172
+ // Availability probe FIRST (cheap, bounded): unpublished / no network → soft-skip.
173
+ const probe = spawnSync('npm', ['view', `${pkg}@latest`, 'version'], { encoding: 'utf8', env, timeout: 60_000 })
174
+ if (probe.status !== 0) return { status: probe.status, unavailable: true }
175
+ // The provider ships a COMPILED bin on PATH (~/.local/bin/<bin>), NOT an npm package,
176
+ // so a bare `npx <bin>` / `npx <pkg>@latest` re-runs the STALE installed binary —
177
+ // verified by the provider: it reports the OLD version, "running FROM the installed
178
+ // binary", a SILENT non-update (fatal for the upgrade case). `npm exec
179
+ // --package=<pkg>@latest -- <bin> update` fetches latest + rebuilds, bypassing the PATH
180
+ // name-shadow. The provider OWNS update + slot-write + its own daemon (memoryd) restart.
181
+ const r = spawnSync(
182
+ 'npm',
183
+ ['exec', '--yes', `--package=${pkg}@latest`, '--', memoryBinName(pkg), 'update'],
184
+ { stdio: 'inherit', env },
185
+ )
186
+ return { status: r.status, unavailable: false }
187
+ }
188
+
189
+ /**
190
+ * Update the CLAIMED memory provider via ITS OWN npx update verb (slot contract: the
191
+ * provider owns install/update + slot-write + peer restart; the core only invokes +
192
+ * reads the slot). No slot → nothing to update. Reports from→to by reading the slot
193
+ * version before/after. BEST-EFFORT by the caller (the cascade never aborts on it).
194
+ */
195
+ export function updateMemoryProvider(opts: {
196
+ env?: NodeJS.ProcessEnv
197
+ dryRun?: boolean
198
+ runUpdate?: (pkg: string, env: NodeJS.ProcessEnv) => { status: number | null; unavailable: boolean }
199
+ } = {}): MemoryUpdateResult {
200
+ const env = opts.env ?? process.env
201
+ const slot = readMemoryProvider(env)
202
+ if (!slot) return { state: 'no-slot', detail: 'no memory provider claimed — nothing to update' }
203
+ const pkg = slot.package
204
+ const from = slot.version
205
+ if (opts.dryRun) {
206
+ return { state: 'updated', package: pkg, from, detail: `would run: npm exec --package=${pkg}@latest -- ${memoryBinName(pkg)} update` }
207
+ }
208
+ const run = opts.runUpdate ?? defaultRunMemoryUpdate
209
+ const r = run(pkg, env)
210
+ if (r.unavailable) {
211
+ return { state: 'skipped-unavailable', package: pkg, from, detail: `${pkg}@latest not reachable (no network / unpublished)` }
212
+ }
213
+ if (r.status !== 0) return { state: 'failed', package: pkg, from, detail: `provider update exited ${r.status ?? 'null'}` }
214
+ const to = readMemoryProvider(env)?.version
215
+ if (from && to && from === to) return { state: 'already-latest', package: pkg, from, to }
216
+ return { state: 'updated', package: pkg, from, to }
217
+ }
@@ -0,0 +1,71 @@
1
+ // ANSI palette for plain-text CLI output that runs OUTSIDE Ink — chiefly the
2
+ // post-Ink onboard summary (run.tsx), which prints after the wizard unmounts and
3
+ // would otherwise read as a flat, colorless tail to a styled flow.
4
+ //
5
+ // It mirrors the Ink wizard's palette one-for-one (see app.tsx color()/glyph()):
6
+ // ok → green ✓ · warn → yellow ! · fail → red ✗ · pending → gray · ·
7
+ // headers → bold (cyan for emphasis) · secondary detail/paths → dim
8
+ // so the seam between the React-rendered part and the printed part is invisible.
9
+ //
10
+ // Color is GATED (colorEnabled): on by default only on a real TTY, force-on with
11
+ // FORCE_COLOR (covers inherited-pipe passthrough by a child process), and hard-off
12
+ // with NO_COLOR (https://no-color.org — presence disables, any value) so piped /
13
+ // redirected / CI captures stay byte-clean. This is the team-wide convention we
14
+ // hand to peer providers (e.g. iapeer-memory) whose stdout we surface verbatim.
15
+
16
+ const ESC = '\x1b['
17
+ const RESET = `${ESC}0m`
18
+
19
+ const CODE = {
20
+ bold: '1',
21
+ dim: '2',
22
+ red: '31',
23
+ green: '32',
24
+ yellow: '33',
25
+ cyan: '36',
26
+ gray: '90',
27
+ } as const
28
+
29
+ export type AnsiPaint = (s: string) => string
30
+
31
+ export interface Ansi {
32
+ bold: AnsiPaint
33
+ dim: AnsiPaint
34
+ red: AnsiPaint
35
+ green: AnsiPaint
36
+ yellow: AnsiPaint
37
+ cyan: AnsiPaint
38
+ gray: AnsiPaint
39
+ }
40
+
41
+ /** Whether to emit ANSI color for `stream`. NO_COLOR (any value) hard-disables;
42
+ * FORCE_COLOR (truthy, not "0"/"false") forces on; otherwise gated on a real TTY. */
43
+ export function colorEnabled(
44
+ env: NodeJS.ProcessEnv = process.env,
45
+ stream: { isTTY?: boolean } = process.stdout,
46
+ ): boolean {
47
+ if (env.NO_COLOR != null) return false
48
+ const fc = env.FORCE_COLOR
49
+ if (fc != null && fc !== '0' && fc !== 'false' && fc !== '') return true
50
+ return stream.isTTY === true
51
+ }
52
+
53
+ /** Build a palette. When `enabled` is false every paint fn is identity (no codes),
54
+ * so the same call sites produce clean text for pipes/CI. Codes compose: an inner
55
+ * paint applies before the outer, both before the run resets — `a.bold(a.cyan(s))`
56
+ * renders bold+cyan. */
57
+ export function makeAnsi(enabled: boolean): Ansi {
58
+ const wrap =
59
+ (code: string): AnsiPaint =>
60
+ (s: string) =>
61
+ enabled ? `${ESC}${code}m${s}${RESET}` : s
62
+ return {
63
+ bold: wrap(CODE.bold),
64
+ dim: wrap(CODE.dim),
65
+ red: wrap(CODE.red),
66
+ green: wrap(CODE.green),
67
+ yellow: wrap(CODE.yellow),
68
+ cyan: wrap(CODE.cyan),
69
+ gray: wrap(CODE.gray),
70
+ }
71
+ }
@@ -219,8 +219,11 @@ export function OnboardApp({
219
219
  if (installed.length === 0) {
220
220
  set('auth', { status: 'warn', detail: 'no runtime installed' })
221
221
  adv.push(
222
- '⚠ SETUP INCOMPLETE — no agent runtime found. iapeer needs Claude Code or Codex CLI\n' +
223
- ' to run peers. Install one, sign in, then re-run `iapeer onboard`.',
222
+ '⚠ SETUP INCOMPLETE — no agent runtime found. iapeer needs an agent runtime: Claude Code OR Codex (either works).\n' +
223
+ ' Install EITHER (commands verified against the official docs), sign in, then re-run `iapeer onboard`:\n' +
224
+ ' Claude Code: curl -fsSL https://claude.ai/install.sh | bash (or: brew install --cask claude-code)\n' +
225
+ ' Codex: npm install -g @openai/codex (or: brew install --cask codex)\n' +
226
+ ' If a download is blocked in your region, try the brew form or the other runtime.',
224
227
  )
225
228
  } else {
226
229
  const notes = installed.map(rt => runtimeAuthNote(rt, env)).filter((n): n is string => n != null)
@@ -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,6 +66,10 @@ 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
74
  let result: WizardResult = { code: 0, memoryConsent: false, telegramConsent: false, advisories: [], summary: [] }
40
75
  try {
@@ -57,54 +92,79 @@ export async function runOnboardWizard(opts: OnboardWizardOptions = {}): Promise
57
92
  try {
58
93
  if (result.telegramConsent) {
59
94
  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')
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
+ )
61
101
  try {
62
102
  const ts = await onboardTelegramStep({ env })
63
- process.stdout.write(`telegram: ${ts.state}${ts.detail ? ` — ${ts.detail}` : ''}\n`)
103
+ process.stdout.write(paintResult('telegram', ts.state, ts.detail ? ` — ${ts.detail}` : '', a) + '\n')
64
104
  } catch (e) {
65
- process.stdout.write(`telegram: step skipped — ${e instanceof Error ? e.message : String(e)}\n`)
105
+ process.stdout.write(paintResult('telegram', 'step skipped', ` — ${e instanceof Error ? e.message : String(e)}`, a) + '\n')
66
106
  }
67
107
  } else {
68
- process.stdout.write('\ntelegram: skipped — add later with `iapeer create <you> --runtime telegram`\n')
108
+ process.stdout.write(
109
+ '\n' + paintResult('telegram', 'skipped', ' — add later with `iapeer create <you> --runtime telegram`', a) + '\n',
110
+ )
69
111
  }
70
112
 
71
113
  if (result.memoryConsent) {
72
114
  const { onboardMemoryProvider } = await import('../../onboard/memory.ts')
73
115
  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',
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',
77
124
  )
78
125
  try {
79
126
  const mem = await onboardMemoryProvider({ env, runtime: result.hostRuntime, timeoutMs: 12 * 60_000 })
80
127
  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`)
128
+ const rest = `${mem.detail ? ` — ${mem.detail}` : ''} ${a.dim(`(slot: ${label})`)}`
129
+ process.stdout.write('\n' + paintResult('memory', mem.state, rest, a) + '\n')
82
130
  } 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
- )
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')
86
133
  }
87
134
  } else {
88
- process.stdout.write('\nmemory: skipped — set it up later with `npx @agfpd/iapeer-memory init`\n')
135
+ process.stdout.write(
136
+ '\n' + paintResult('memory', 'skipped', ' — set it up later with `npx @agfpd/iapeer-memory init`', a) + '\n',
137
+ )
89
138
  }
90
139
  } finally {
91
140
  process.removeListener('SIGINT', ignore)
92
141
  for (const h of prevInt) process.on('SIGINT', h as NodeJS.SignalsListener)
93
142
  }
94
143
 
95
- // Final summary (plain text, post-Ink).
144
+ // Final summary (post-Ink, ANSI-styled to match the wizard's palette).
96
145
  process.stdout.write('\n')
97
- 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')
98
147
  if (result.advisories.length > 0) {
99
148
  process.stdout.write('\n')
100
- 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')
101
152
  }
102
- process.stdout.write('\nNext steps:\n')
103
- process.stdout.write(' • Create your first agent: iapeer create <name> --runtime claude\n')
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
+ ]
104
159
  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')
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
+ )
108
168
  }
109
169
  return result.code
110
170
  }
@@ -1,8 +1,13 @@
1
- // `iapeer update` — the SINGLE deploy path: pull the latest published foundation
2
- // from npm (the cloud) and restart the daemon onto it. There is deliberately NO
3
- // "deploy from a working tree" — every host, including the dev host, activates a
1
+ // `updateIapeer` — the FOUNDATION leg of the deploy: pull the latest published
2
+ // foundation from npm (the cloud) and restart the daemon onto it. There is deliberately
3
+ // NO "deploy from a working tree" — every host, including the dev host, activates a
4
4
  // release the same way: `npm run release` (publish) → `iapeer update` (pull + restart).
5
5
  //
6
+ // NB the `iapeer update` VERB cascades the whole stack (FU12): it runs this foundation
7
+ // leg FIRST (aborting on a hard failure), then `updateAllRuntimes` + the memory provider
8
+ // (best-effort) via cascadeTail (below). updateIapeer itself stays foundation-scoped —
9
+ // the cascade is composed in the CLI verb.
10
+ //
6
11
  // Flow:
7
12
  // 1. latest = `npm view @agfpd/iapeer version` (the cloud's truth)
8
13
  // 2. installed == latest && !--force → "already latest" (no needless rebuild/restart)
@@ -43,6 +48,10 @@ import {
43
48
  } from '../launch/launchd.ts'
44
49
  import { cycleShadowJob, SHADOW_PERSONALITY, SHADOW_PLIST_LABEL } from '../shadow/install.ts'
45
50
  import { defaultDaemonSocketPath } from '../daemon/index.ts'
51
+ // Type-only (erased at runtime → no import cycle): the cascade tail renders these
52
+ // shapes but receives the component-updaters as injected deps.
53
+ import type { UpdateRuntimeResult } from '../runtime/update.ts'
54
+ import type { MemoryUpdateResult } from '../onboard/memory.ts'
46
55
 
47
56
  /** The npm package the foundation publishes / updates from. */
48
57
  export const IAPEER_PACKAGE = '@agfpd/iapeer'
@@ -316,3 +325,42 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
316
325
  reason: d.state === 'failed' ? `binary updated but daemon restart failed: ${d.detail ?? ''}`.trim() : undefined,
317
326
  }
318
327
  }
328
+
329
+ // ─────────────────────────────────────────────────────────────────────────────
330
+ // cascadeTail — the runtimes + memory legs of the cascade `iapeer update` (FU12).
331
+ // Runs AFTER a healthy foundation update (the CLI owns that, abort-on-hard-fail);
332
+ // here we update every installed runtime then the memory provider, BEST-EFFORT — a
333
+ // component failure is reported, never aborts the rest. Pure orchestration over
334
+ // injected component-updaters (no value-import of runtime/onboard → no cycle), so it
335
+ // is hermetically testable. Returns whether anything failed (→ exit≠0).
336
+ // ─────────────────────────────────────────────────────────────────────────────
337
+
338
+ export interface CascadeTailDeps {
339
+ /** = updateAllRuntimes (runtime/update.ts). */
340
+ runtimes: () => Promise<UpdateRuntimeResult[]>
341
+ /** = updateMemoryProvider (onboard/memory.ts). */
342
+ memory: () => MemoryUpdateResult | Promise<MemoryUpdateResult>
343
+ out: (s: string) => void
344
+ }
345
+
346
+ export async function cascadeTail(deps: CascadeTailDeps): Promise<{ failed: boolean }> {
347
+ let failed = false
348
+
349
+ deps.out('runtimes:\n')
350
+ const rs = await deps.runtimes()
351
+ if (rs.length === 0) deps.out(' (none installed)\n')
352
+ for (const r of rs) {
353
+ const ver = r.from || r.to ? ` ${r.from ?? '?'} → ${r.to ?? '?'}` : ''
354
+ deps.out(` ${r.runtime}: ${r.state}${ver}${r.detail ? ` — ${r.detail}` : ''}\n`)
355
+ for (const p of r.restarted) deps.out(` restart ${p.personality}: ${p.state}${p.detail ? ` — ${p.detail}` : ''}\n`)
356
+ if (r.state === 'install-failed' || r.state === 'deploy-failed' || r.state === 'npm-unreachable') failed = true
357
+ if (r.restarted.some(p => p.state === 'failed')) failed = true
358
+ }
359
+
360
+ const m = await deps.memory()
361
+ const mver = m.from || m.to ? ` ${m.from ?? '?'} → ${m.to ?? '?'}` : ''
362
+ deps.out(`memory: ${m.state}${m.package ? ` (${m.package}${mver})` : ''}${m.detail ? ` — ${m.detail}` : ''}\n`)
363
+ if (m.state === 'failed') failed = true
364
+
365
+ return { failed }
366
+ }