@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/README.md +2 -1
- package/docs/03-peers-runtimes-identity.md +1 -1
- package/docs/06-storage.md +12 -0
- package/docs/07-install-and-update.md +17 -10
- package/docs/ru/03-/320/277/320/270/321/200/321/213-/321/200/320/260/320/275/321/202/320/260/320/271/320/274/321/213-/320/270/320/264/320/265/320/275/321/202/320/270/321/207/320/275/320/276/321/201/321/202/321/214.md +1 -1
- package/docs/ru/06-/321/205/321/200/320/260/320/275/320/265/320/275/320/270/320/265.md +12 -0
- package/docs/ru/07-/321/203/321/201/321/202/320/260/320/275/320/276/320/262/320/272/320/260-/320/270-/320/276/320/261/320/275/320/276/320/262/320/273/320/265/320/275/320/270/320/265.md +17 -10
- package/package.json +1 -1
- package/src/cli/index.ts +305 -91
- package/src/create/index.ts +38 -0
- package/src/daemon/index.ts +13 -4
- package/src/init/index.ts +31 -0
- package/src/install/index.ts +61 -2
- package/src/launch/composeSystemPrompt.ts +8 -0
- package/src/onboard/memory.ts +71 -0
- package/src/tui/ansi.ts +71 -0
- package/src/tui/onboard/app.tsx +5 -2
- package/src/tui/onboard/run.tsx +80 -20
- package/src/update/index.ts +51 -3
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)
|
package/src/install/index.ts
CHANGED
|
@@ -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
|
package/src/onboard/memory.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tui/ansi.ts
ADDED
|
@@ -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
|
+
}
|
package/src/tui/onboard/app.tsx
CHANGED
|
@@ -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
|
|
223
|
-
'
|
|
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)
|
package/src/tui/onboard/run.tsx
CHANGED
|
@@ -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(
|
|
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(
|
|
103
|
+
process.stdout.write(paintResult('telegram', ts.state, ts.detail ? ` — ${ts.detail}` : '', a) + '\n')
|
|
64
104
|
} catch (e) {
|
|
65
|
-
process.stdout.write(
|
|
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(
|
|
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
|
-
'\
|
|
75
|
-
'
|
|
76
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
}
|
package/src/update/index.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
// `
|
|
2
|
-
// from npm (the cloud) and restart the daemon onto it. There is deliberately
|
|
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
|
+
}
|