@agfpd/iapeer 0.2.16 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -372,6 +372,7 @@ const USAGE = `usage: iapeer <verb> [args]
372
372
  backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES)
373
373
  status host snapshot: version, daemon health, memory slot (<provider> | none)
374
374
  install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
375
+ update-runtime <runtime> | --all [--force] version-gate → re-install + re-provision declared set → restart the runtime's peers
375
376
  init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
376
377
  create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
377
378
  list [--json] registered peers + per-runtime liveness
@@ -380,6 +381,7 @@ const USAGE = `usage: iapeer <verb> [args]
380
381
  remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
381
382
  send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>] manual IAP send (fallback)
382
383
  <runtime> launch the cwd's peer (ALWAYS fresh)
384
+ connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
383
385
  enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
384
386
  attach <peer> [runtime] ensure-live + resume, then tmux attach
385
387
  interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
@@ -552,6 +554,29 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
552
554
  out(`deployed runtime "${d.runtime}" (${d.peers.length} peer(s))\n`)
553
555
  return d.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed') ? 1 : 0
554
556
  }
557
+ case 'update-runtime': {
558
+ // §(г) runtime-package update: version-gate (npm vs the manifest stamp) →
559
+ // forced re-npx → idempotent re-provision (same path as install-runtime) →
560
+ // restart the runtime's peers via the regular stop/start. The core's own
561
+ // `update` stays foundation-only — this is the runtimes' counterpart.
562
+ const all = flags.all === true
563
+ if (!all && !positionals[0]) return usage(errOut)
564
+ const { updateRuntime, updateAllRuntimes } = await import('../runtime/update.ts')
565
+ const results = all
566
+ ? await updateAllRuntimes({ force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) })
567
+ : [await updateRuntime({ runtime: positionals[0] as Runtime, force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) })]
568
+ let failed = false
569
+ for (const r of results) {
570
+ const ver = r.from || r.to ? ` ${r.from ?? '?'} → ${r.to ?? '?'}` : ''
571
+ out(`${r.runtime}: ${r.state}${ver}${r.detail ? ` — ${r.detail}` : ''}\n`)
572
+ for (const p of r.peers) out(` re-provisioned ${p.personality}: self-config ${p.selfConfig ?? 'n/a'}\n`)
573
+ for (const p of r.restarted) out(` restart ${p.personality}: ${p.state}${p.detail ? ` — ${p.detail}` : ''}\n`)
574
+ if (r.state === 'install-failed' || r.state === 'deploy-failed' || r.state === 'npm-unreachable') failed = true
575
+ if (r.restarted.some(p => p.state === 'failed')) failed = true
576
+ if (!all && r.state === 'not-installed') failed = true
577
+ }
578
+ return failed ? 1 : 0
579
+ }
555
580
  case 'init': {
556
581
  // cwd-DEPENDENT: onboard the CURRENT folder (or positional cwd) as a peer —
557
582
  // identity + MCP wiring + doctrine, runtime resolved from the cwd's markers
@@ -839,6 +864,36 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
839
864
  out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
840
865
  return 0
841
866
  }
867
+ case 'connect': {
868
+ // Per-peer channel attachment in ONE flow (design «Onboard костяка» §(в)):
869
+ // `connect telegram <peer> [--token <t>]`. The human owes only the token;
870
+ // alias/bot-add/interface/router-restart are resolved by the system. The
871
+ // FIRST message from the human to the bot activates the chat (platform rule).
872
+ if (positionals[0] !== 'telegram' || !positionals[1]) return usage(errOut)
873
+ const { connectTelegram } = await import('../connect/index.ts')
874
+ const r = await connectTelegram({
875
+ peer: positionals[1],
876
+ token: typeof flags.token === 'string' ? flags.token : undefined,
877
+ env,
878
+ })
879
+ if (r.state === 'noop-same-token') {
880
+ out(`connect telegram ${r.peer}: ${r.detail}\n`)
881
+ return 0
882
+ }
883
+ if (r.state !== 'connected') {
884
+ errOut(`connect telegram ${r.peer}: ${r.state}${r.detail ? ` — ${r.detail}` : ''}\n`)
885
+ return 1
886
+ }
887
+ const rs = r.restart!
888
+ out(`bot ${r.username ?? `for "${r.peer}"`} added + interfaced to "${r.peer}"\n`)
889
+ out(
890
+ rs.state === 'restarted'
891
+ ? `router restarted — credentials loaded\n`
892
+ : `router restart ${rs.state}${rs.detail ? ` — ${rs.detail}` : ''} (the channel stays dead until the router restarts)\n`,
893
+ )
894
+ out(`activation: send the bot ${r.username ?? '(see @BotFather)'} its FIRST message — Telegram does not let a bot start the chat\n`)
895
+ return rs.state === 'restarted' ? 0 : 1
896
+ }
842
897
  case 'enable': {
843
898
  // Per-peer capability install (contract Установка §3): install <plugin>@agfpd
844
899
  // per-runtime (claude project-scope IN the peer cwd / codex global) + enable +
@@ -0,0 +1,144 @@
1
+ // connect telegram — the one-flow channel attachment (design §(в)). Sandboxed:
2
+ // IAPEER_ROOT temp dirs, injected telegram-runtime runner + router restart. The
3
+ // runner stub mimics the package: `bot add` writes bots/<alias>/.env (stable ABI).
4
+
5
+ import { afterEach, describe, expect, test } from 'bun:test'
6
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
7
+ import { dirname } from 'path'
8
+ import { tmpdir } from 'os'
9
+ import { join } from 'path'
10
+ import { botEnvPath, connectTelegram, type RestartOutcome, type TgRunner } from './index.ts'
11
+ import { writeRuntimeManifest } from '../runtime/index.ts'
12
+ import { upsertPeer } from '../registry/index.ts'
13
+
14
+ const roots: string[] = []
15
+ function mkTmp(): string {
16
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-connect-'))
17
+ roots.push(d)
18
+ return d
19
+ }
20
+ afterEach(() => {
21
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
22
+ })
23
+
24
+ function envFor(root: string): NodeJS.ProcessEnv {
25
+ return {
26
+ IAPEER_ROOT: join(root, 'iapeer'),
27
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
28
+ IAPEER_TEST_SANDBOX: '1',
29
+ HOME: root,
30
+ } as NodeJS.ProcessEnv
31
+ }
32
+
33
+ async function fixture(): Promise<{ env: NodeJS.ProcessEnv; calls: string[][]; runTg: TgRunner; restarts: string[] }> {
34
+ const env = envFor(mkTmp())
35
+ writeRuntimeManifest({ runtime: 'telegram', selfConfig: { command: '/stub/telegram-runtime', args: ['self-config'] } }, { env })
36
+ await upsertPeer({ personality: 'leo', runtime: 'claude', cwd: '/tmp/leo', intelligence: 'artificial' }, { env })
37
+ await upsertPeer({ personality: 'arthur', runtime: 'telegram', cwd: '/tmp/arthur', intelligence: 'natural' }, { env })
38
+ const calls: string[][] = []
39
+ const runTg: TgRunner = (args, e) => {
40
+ calls.push(args)
41
+ if (args[0] === 'bot' && args[1] === 'add') {
42
+ // the package's behavior: token → bots/<alias>/.env; prints the validated @username
43
+ const p = botEnvPath(args[2]!, e)
44
+ mkdirSync(dirname(p), { recursive: true })
45
+ writeFileSync(p, `TELEGRAM_BOT_TOKEN=${args[4]}\n`)
46
+ return { status: 0, stdout: 'bot added: @leo_test_bot\n', stderr: '' }
47
+ }
48
+ return { status: 0, stdout: '', stderr: '' }
49
+ }
50
+ const restarts: string[] = []
51
+ return { env, calls, runTg, restarts }
52
+ }
53
+
54
+ const okRestart =
55
+ (restarts: string[]) =>
56
+ (human: string): RestartOutcome => {
57
+ restarts.push(human)
58
+ return { state: 'restarted' }
59
+ }
60
+
61
+ describe('connectTelegram (one flow: bot add → interface → restart → activation)', () => {
62
+ test('happy path: adds the bot, interfaces the peer, restarts the HUMAN router, surfaces @username', async () => {
63
+ const { env, calls, runTg, restarts } = await fixture()
64
+ const r = await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })
65
+ expect(r.state).toBe('connected')
66
+ expect(r.username).toBe('@leo_test_bot')
67
+ expect(r.restart?.state).toBe('restarted')
68
+ expect(restarts).toEqual(['arthur']) // the router = the natural telegram peer, not leo
69
+ expect(calls[0]).toEqual(['bot', 'add', 'leo', '--token', 'T1:abc'])
70
+ expect(calls[1]).toEqual(['interface', 'bot', 'leo', '--peer', 'leo'])
71
+ })
72
+
73
+ test('IDEMPOTENT: the same token is a byte-stable no-op — router NOT restarted', async () => {
74
+ const { env, runTg, restarts } = await fixture()
75
+ expect((await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })).state).toBe('connected')
76
+ const second = await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })
77
+ expect(second.state).toBe('noop-same-token')
78
+ expect(restarts).toEqual(['arthur']) // exactly ONE restart — the first connect
79
+ })
80
+
81
+ test('a NEW token replaces and RESTARTS again (credentials load at start)', async () => {
82
+ const { env, runTg, restarts } = await fixture()
83
+ await connectTelegram({ peer: 'leo', token: 'T1:abc', env, runTg, restart: okRestart(restarts) })
84
+ const r = await connectTelegram({ peer: 'leo', token: 'T2:new', env, runTg, restart: okRestart(restarts) })
85
+ expect(r.state).toBe('connected')
86
+ expect(restarts).toEqual(['arthur', 'arthur'])
87
+ })
88
+
89
+ test('unregistered peer → refused BEFORE any bot add (interface precondition)', async () => {
90
+ const { env, calls, runTg } = await fixture()
91
+ const r = await connectTelegram({ peer: 'ghost', token: 'T', env, runTg })
92
+ expect(r.state).toBe('unregistered-peer')
93
+ expect(calls.length).toBe(0)
94
+ })
95
+
96
+ test('no telegram manifest → runtime-missing with the install recipe', async () => {
97
+ const env = envFor(mkTmp())
98
+ await upsertPeer({ personality: 'leo', runtime: 'claude', cwd: '/tmp/leo', intelligence: 'artificial' }, { env })
99
+ const r = await connectTelegram({ peer: 'leo', token: 'T', env })
100
+ expect(r.state).toBe('runtime-missing')
101
+ expect(r.detail).toContain('install-runtime telegram')
102
+ })
103
+
104
+ test('non-tty without --token → explicit refusal with the BotFather recipe', async () => {
105
+ const { env, runTg } = await fixture()
106
+ const r = await connectTelegram({ peer: 'leo', env, runTg, isTty: false })
107
+ expect(r.state).toBe('refused-no-token')
108
+ expect(r.detail).toContain('@BotFather')
109
+ })
110
+
111
+ test('tty prompt path: the asked token flows in; empty answer → refusal', async () => {
112
+ const { env, runTg, restarts } = await fixture()
113
+ const r = await connectTelegram({ peer: 'leo', env, runTg, isTty: true, ask: async () => 'T9:asked', restart: okRestart(restarts) })
114
+ expect(r.state).toBe('connected')
115
+ const r2 = await connectTelegram({ peer: 'leo', env, runTg, isTty: true, ask: async () => '' })
116
+ expect(r2.state).toBe('refused-no-token')
117
+ })
118
+
119
+ test('bot add failure (getMe refusal on a bad token) → bot-add-failed with the package detail', async () => {
120
+ const { env } = await fixture()
121
+ const failTg: TgRunner = args =>
122
+ args[0] === 'bot' ? { status: 1, stdout: '', stderr: 'getMe failed: 401 Unauthorized' } : { status: 0, stdout: '', stderr: '' }
123
+ const r = await connectTelegram({ peer: 'leo', token: 'bad', env, runTg: failTg })
124
+ expect(r.state).toBe('bot-add-failed')
125
+ expect(r.detail).toContain('401')
126
+ })
127
+
128
+ test('no natural telegram peer in the registry → connected but restart=no-router (loud)', async () => {
129
+ const env = envFor(mkTmp())
130
+ writeRuntimeManifest({ runtime: 'telegram', selfConfig: '/stub/telegram-runtime self-config' }, { env })
131
+ await upsertPeer({ personality: 'leo', runtime: 'claude', cwd: '/tmp/leo', intelligence: 'artificial' }, { env })
132
+ const runTg: TgRunner = (args, e) => {
133
+ if (args[0] === 'bot') {
134
+ const p = botEnvPath('leo', e)
135
+ mkdirSync(dirname(p), { recursive: true })
136
+ writeFileSync(p, 'TELEGRAM_BOT_TOKEN=T\n')
137
+ }
138
+ return { status: 0, stdout: '', stderr: '' }
139
+ }
140
+ const r = await connectTelegram({ peer: 'leo', token: 'T', env, runTg })
141
+ expect(r.state).toBe('connected')
142
+ expect(r.restart?.state).toBe('no-router')
143
+ })
144
+ })
@@ -0,0 +1,212 @@
1
+ // connect — per-peer channel attachment in ONE flow (design «Onboard костяка» §(в),
2
+ // FINAL 10.06; flow facts confirmed by the telegram-runtime owner against v0.10.3).
3
+ // Namespace: `iapeer connect <channel> <peer>` — extensible to future channels;
4
+ // v1 implements `connect telegram <peer> [--token <t>]`.
5
+ //
6
+ // The human owes EXACTLY ONE external fact: the bot token (prompt walks them
7
+ // through @BotFather → /newbot). Everything else the system resolves: the bot
8
+ // alias (= the peer's personality), `telegram-runtime bot add` (owner adds getMe
9
+ // validation — an invalid token fails THERE, early), `telegram-runtime interface
10
+ // bot` (profile merge; precondition: the peer is registered), then the MANDATORY
11
+ // router-session restart (the live poller reads bots/ ONCE at start, no fs-watch —
12
+ // without the restart the channel is dead both ways), and the activation hint:
13
+ // the FIRST message from the human to the bot opens the chat (a Telegram platform
14
+ // rule — a bot cannot initiate; outbound into an unopened chat is 403).
15
+ //
16
+ // IDEMPOTENT: the same token is a byte-stable no-op (bots/<alias>/.env unchanged →
17
+ // no restart needed); a NEW token replaces with an explicit message AND restarts
18
+ // (credentials load at start).
19
+
20
+ import { existsSync, readFileSync } from 'fs'
21
+ import { join } from 'path'
22
+ import { spawnSync } from 'child_process'
23
+ import { normalizeIntelligenceValue } from '../core/constants.ts'
24
+ import { runtimeRoot } from '../storage/index.ts'
25
+ import { findPeer, readPeersIndex } from '../registry/index.ts'
26
+ import { readRuntimeManifest } from '../runtime/index.ts'
27
+
28
+ export interface TgRunResult {
29
+ status: number | null
30
+ stdout: string
31
+ stderr: string
32
+ }
33
+ export type TgRunner = (args: string[], env: NodeJS.ProcessEnv) => TgRunResult
34
+
35
+ export interface RestartOutcome {
36
+ state: 'restarted' | 'refused-foreign-launchd' | 'failed' | 'no-router'
37
+ detail?: string
38
+ }
39
+ export type RestartFn = (humanPeer: string, env: NodeJS.ProcessEnv) => RestartOutcome
40
+
41
+ export interface ConnectTelegramOptions {
42
+ peer: string
43
+ /** --token; absent → tty prompt (BotFather recipe) / non-tty refusal. */
44
+ token?: string
45
+ env?: NodeJS.ProcessEnv
46
+ /** Injectable prompt (tests). Default: readline on the live tty. */
47
+ ask?: (question: string) => Promise<string>
48
+ /** Override tty detection (tests). */
49
+ isTty?: boolean
50
+ /** Injectable telegram-runtime invoker (tests). Default: spawn the manifest's bin. */
51
+ runTg?: TgRunner
52
+ /** Injectable router restart (tests). Default: stopPeer→startPeer strictly in order. */
53
+ restart?: RestartFn
54
+ }
55
+
56
+ export interface ConnectTelegramResult {
57
+ state:
58
+ | 'connected' // bot added + interfaced + router restarted — channel live after activation
59
+ | 'noop-same-token' // byte-stable .env → nothing changed, no restart
60
+ | 'refused-no-token' // non-tty and no --token (or an empty tty answer)
61
+ | 'unregistered-peer' // precondition: the peer must be in the registry
62
+ | 'runtime-missing' // no telegram runtime manifest — install it first
63
+ | 'bot-add-failed' // telegram-runtime bot add exited non-zero (incl. getMe refusal)
64
+ | 'interface-failed' // telegram-runtime interface bot exited non-zero
65
+ peer: string
66
+ /** Bot @username when `bot add` surfaced one (owner obligation) — best-effort. */
67
+ username?: string
68
+ restart?: RestartOutcome
69
+ detail?: string
70
+ }
71
+
72
+ async function ttyAsk(question: string): Promise<string> {
73
+ const { createInterface } = await import('node:readline/promises')
74
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
75
+ try {
76
+ return (await rl.question(question)).trim()
77
+ } finally {
78
+ rl.close()
79
+ }
80
+ }
81
+
82
+ /** bots/<alias>/.env under the telegram runtime scope (stable ABI — owner's fact). */
83
+ export function botEnvPath(alias: string, env: NodeJS.ProcessEnv): string {
84
+ return join(runtimeRoot('telegram', { env }), 'bots', alias, '.env')
85
+ }
86
+
87
+ function readBotEnv(alias: string, env: NodeJS.ProcessEnv): string | null {
88
+ try {
89
+ const p = botEnvPath(alias, env)
90
+ return existsSync(p) ? readFileSync(p, 'utf8') : null
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ /** Resolve the telegram-runtime CLI bin from the manifest's selfConfig command (the
97
+ * package's own absolute bin — PATH-independent, launchd-safe). */
98
+ function resolveTgBin(env: NodeJS.ProcessEnv): string | null {
99
+ const manifest = readRuntimeManifest('telegram', { env })
100
+ if (!manifest) return null
101
+ const sc = manifest.selfConfig
102
+ if (typeof sc === 'string' && sc.trim()) return sc.trim().split(/\s+/)[0]!
103
+ if (sc && typeof sc === 'object' && sc.command) return sc.command
104
+ return 'telegram-runtime' // manifest present but no hook — fall back to PATH
105
+ }
106
+
107
+ const defaultRunTg =
108
+ (bin: string): TgRunner =>
109
+ (args, env) => {
110
+ const r = spawnSync(bin, args, { encoding: 'utf8', env: env as Record<string, string> })
111
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? (r.error?.message ?? '') }
112
+ }
113
+
114
+ /** The default router restart: stop→start of the human peer's telegram runtime,
115
+ * STRICTLY sequential (the per-bot runtime.lock with live-pid detection refuses a
116
+ * parallel process — a feature). Deferred import: connect → cli would otherwise be
117
+ * a static cycle (cli imports connect). */
118
+ async function defaultRestart(humanPeer: string, env: NodeJS.ProcessEnv): Promise<RestartOutcome> {
119
+ const { stopPeer, startPeer } = await import('../cli/index.ts')
120
+ try {
121
+ const stops = stopPeer(humanPeer, 'telegram', { env })
122
+ if (stops.some(o => o.action === 'refused-foreign-launchd')) {
123
+ return {
124
+ state: 'refused-foreign-launchd',
125
+ detail: `router "${humanPeer}" is persistent-peer-managed (H4 read-only) — restart it yourself to load the new bot`,
126
+ }
127
+ }
128
+ const starts = startPeer(humanPeer, 'telegram', { env })
129
+ const bad = starts.find(o => o.reason && o.action === 'bootstrap')
130
+ return bad ? { state: 'failed', detail: bad.reason } : { state: 'restarted' }
131
+ } catch (e) {
132
+ return { state: 'failed', detail: e instanceof Error ? e.message : String(e) }
133
+ }
134
+ }
135
+
136
+ /** The registry's natural peer — the router-session owner (ONE poller serves all
137
+ * bots; it runs under the human peer's telegram runtime). */
138
+ function findRouterHuman(env: NodeJS.ProcessEnv): string | null {
139
+ try {
140
+ const naturals = readPeersIndex({ env }).peers.filter(
141
+ p => normalizeIntelligenceValue(p.intelligence) === 'natural' && (p.runtime === 'telegram' || p.runtimes.includes('telegram')),
142
+ )
143
+ return naturals[0]?.personality ?? null
144
+ } catch {
145
+ return null
146
+ }
147
+ }
148
+
149
+ export async function connectTelegram(opts: ConnectTelegramOptions): Promise<ConnectTelegramResult> {
150
+ const env = opts.env ?? process.env
151
+ const peer = opts.peer.trim()
152
+
153
+ // Precondition (owner's fact): `interface bot` merges into the peer's profile —
154
+ // the peer must exist in the registry first.
155
+ if (!findPeer(readPeersIndex({ env }), peer)) {
156
+ return { state: 'unregistered-peer', peer, detail: `peer "${peer}" is not registered — create it first (iapeer create ${peer})` }
157
+ }
158
+
159
+ const tgBin = resolveTgBin(env)
160
+ if (!tgBin) {
161
+ return { state: 'runtime-missing', peer, detail: 'telegram runtime is not installed — run: iapeer install-runtime telegram' }
162
+ }
163
+
164
+ // The ONE human-owed fact: the bot token.
165
+ let token = opts.token?.trim()
166
+ if (!token) {
167
+ const tty = opts.isTty ?? (process.stdin.isTTY === true && process.stdout.isTTY === true)
168
+ if (!tty) {
169
+ return { state: 'refused-no-token', peer, detail: 'no tty and no --token — re-run with --token <bot-token> (create one: @BotFather → /newbot)' }
170
+ }
171
+ const ask = opts.ask ?? ttyAsk
172
+ token = (await ask(`bot token for "${peer}" (create: message @BotFather → /newbot → copy the token): `)).trim()
173
+ if (!token) return { state: 'refused-no-token', peer, detail: 'no answer — nothing connected' }
174
+ }
175
+
176
+ const runTg = opts.runTg ?? defaultRunTg(tgBin)
177
+ const alias = peer // bot alias = the peer's personality (design §(в); passes the owner's NAME_RE)
178
+ const before = readBotEnv(alias, env)
179
+
180
+ // (1) bot add — token → bots/<alias>/.env. Owner adds getMe validation here: an
181
+ // invalid token fails EARLY with the platform's reason; we surface it verbatim.
182
+ const add = runTg(['bot', 'add', alias, '--token', token], env)
183
+ if (add.status !== 0) {
184
+ return { state: 'bot-add-failed', peer, detail: (add.stderr || add.stdout || `exit ${add.status}`).trim() }
185
+ }
186
+ const username = add.stdout.match(/@[A-Za-z0-9_]{3,}/)?.[0]
187
+
188
+ // (2) interface bot — merge the channel binding into the peer's profile.
189
+ const iface = runTg(['interface', 'bot', alias, '--peer', peer], env)
190
+ if (iface.status !== 0) {
191
+ return { state: 'interface-failed', peer, username, detail: (iface.stderr || iface.stdout || `exit ${iface.status}`).trim() }
192
+ }
193
+
194
+ // (3) Idempotency gate: the SAME token leaves bots/<alias>/.env byte-stable →
195
+ // the live poller already loaded these credentials → NO restart, clean no-op.
196
+ const after = readBotEnv(alias, env)
197
+ if (before !== null && after === before) {
198
+ return { state: 'noop-same-token', peer, username, detail: 'same token — byte-stable no-op, router not restarted' }
199
+ }
200
+
201
+ // (4) MANDATORY router restart (new/changed credentials load only at start).
202
+ // Cost: a seconds-long delivery blip for the whole fleet; inbound is NOT lost
203
+ // (long-polling offset, Telegram holds up to 24 h) — known, accepted (design).
204
+ const human = findRouterHuman(env)
205
+ const restart: RestartOutcome = human
206
+ ? opts.restart
207
+ ? opts.restart(human, env)
208
+ : await defaultRestart(human, env)
209
+ : { state: 'no-router', detail: 'no natural telegram peer in the registry — start the router manually after onboarding the human' }
210
+
211
+ return { state: 'connected', peer, username, restart }
212
+ }
@@ -64,8 +64,12 @@ function isExecutable(binOrName: string): boolean {
64
64
  return false
65
65
  }
66
66
  }
67
- // bare name → resolved by spawnSync against PATH; probe with `which`-free spawn
68
- const r = spawnSync(binOrName, ['--version'], { stdio: 'ignore' })
67
+ // bare name → resolved by spawnSync against PATH; probe with `which`-free spawn.
68
+ // HARD TIMEOUT (live find 10.06): `codex --version` HANGS FOREVER in a non-tty
69
+ // environment (three stray probes sat 25+ min; an onboard --dry-run piped to a
70
+ // file never printed a byte). A hung probe must degrade to 'runtime-missing',
71
+ // not wedge the whole onboard.
72
+ const r = spawnSync(binOrName, ['--version'], { stdio: 'ignore', timeout: 10_000 })
69
73
  return r.error === undefined && r.status !== null
70
74
  }
71
75
 
@@ -51,6 +51,10 @@ export interface RuntimePeerDecl {
51
51
  export interface RuntimeManifest {
52
52
  /** The runtime id this manifest describes (must match the folder it lives in). */
53
53
  runtime: Runtime
54
+ /** OPTIONAL installed package version (the owners' self-install stamp — the
55
+ * telegram owner's 10.06 obligation). `update-runtime` version-gates on it;
56
+ * absent → no gate, the update re-installs idempotently. */
57
+ version?: string
54
58
  /** OPTIONAL per-peer self-config hook (the shared contract both modes call). */
55
59
  selfConfig?: SelfConfigDescriptor
56
60
  /** OPTIONAL fixed peer-set (mode a). Omitted by an operator-add runtime (mode b). */
@@ -108,7 +112,8 @@ function normalizeManifest(raw: unknown, runtime: Runtime): RuntimeManifest {
108
112
  ]
109
113
  })
110
114
  }
111
- return { runtime: declaredRuntime, ...(selfConfig ? { selfConfig } : {}), ...(peers ? { peers } : {}) }
115
+ const version = typeof obj.version === 'string' && obj.version.trim() ? obj.version.trim() : undefined
116
+ return { runtime: declaredRuntime, ...(version ? { version } : {}), ...(selfConfig ? { selfConfig } : {}), ...(peers ? { peers } : {}) }
112
117
  }
113
118
 
114
119
  /** Read a runtime's manifest (~/.iapeer/runtimes/<runtime>/runtime.json), or null when
@@ -0,0 +1,192 @@
1
+ // update-runtime (§(г)) — version-gate / forced re-install / idempotent re-provision /
2
+ // peer restart. Sandboxed (IAPEER_ROOT temp dirs, IAPEER_TEST_SANDBOX), injected
3
+ // npx + npm-version + restart.
4
+
5
+ import { afterEach, describe, expect, test } from 'bun:test'
6
+ import { mkdtempSync, rmSync, writeFileSync } from 'fs'
7
+ import { tmpdir } from 'os'
8
+ import { join } from 'path'
9
+ import { updateAllRuntimes, updateRuntime, type RestartedPeer } from './update.ts'
10
+ import { readRuntimeManifest, writeRuntimeManifest, type RuntimeManifest } from './index.ts'
11
+ import { upsertPeer } from '../registry/index.ts'
12
+
13
+ const roots: string[] = []
14
+ function mkTmp(): string {
15
+ const d = mkdtempSync(join(tmpdir(), 'iapeer-rtup-'))
16
+ roots.push(d)
17
+ return d
18
+ }
19
+ afterEach(() => {
20
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
21
+ })
22
+
23
+ function envFor(root: string, path?: string): NodeJS.ProcessEnv {
24
+ return {
25
+ IAPEER_ROOT: join(root, 'iapeer'),
26
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
27
+ IAPEER_TEST_SANDBOX: '1',
28
+ HOME: root,
29
+ ...(path ? { PATH: path } : {}),
30
+ } as NodeJS.ProcessEnv
31
+ }
32
+
33
+ function stubBins(): { dir: string; hook: string } {
34
+ const dir = mkTmp()
35
+ writeFileSync(join(dir, 'notifier-runtime'), '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
36
+ const hook = join(dir, 'sc.sh')
37
+ writeFileSync(hook, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
38
+ return { dir, hook }
39
+ }
40
+
41
+ const restartOk =
42
+ (log: string[]) =>
43
+ (personality: string): RestartedPeer => {
44
+ log.push(personality)
45
+ return { personality, state: 'restarted' }
46
+ }
47
+
48
+ describe('updateRuntime (§(г): gate → re-install → re-provision → restart)', () => {
49
+ test('full update: re-npx forced, declared set re-provisioned, peers restarted', async () => {
50
+ const root = mkTmp()
51
+ const { dir, hook } = stubBins()
52
+ const env = envFor(root, dir)
53
+ writeRuntimeManifest(
54
+ { runtime: 'notifier', version: '0.1.0', selfConfig: hook, peers: [{ personality: 'timer', intelligence: 'absent' }] },
55
+ { env },
56
+ )
57
+ // the live-host layout: the declared peer already sits in its DEFAULT location
58
+ // (re-provision must be a no-clobber pass over it, not an identity conflict)
59
+ await upsertPeer(
60
+ { personality: 'timer', runtime: 'notifier', cwd: join(root, 'iapeer', 'peers', 'timer'), intelligence: 'absent' },
61
+ { env },
62
+ )
63
+ let npxRan = false
64
+ const restarts: string[] = []
65
+ const r = await updateRuntime({
66
+ runtime: 'notifier',
67
+ env,
68
+ runNpx: (_pkg, e) => {
69
+ npxRan = true
70
+ // the package self-deploys the NEW manifest with the new version stamp
71
+ const m: RuntimeManifest = { runtime: 'notifier', version: '0.2.0', selfConfig: hook, peers: [{ personality: 'timer', intelligence: 'absent' }] }
72
+ writeRuntimeManifest(m, { env: e })
73
+ return { ok: true }
74
+ },
75
+ npmVersion: () => '0.2.0',
76
+ restartPeer: restartOk(restarts),
77
+ })
78
+ expect(r.state).toBe('updated')
79
+ expect(npxRan).toBe(true) // FORCED re-npx despite the present manifest
80
+ expect(r.from).toBe('0.1.0')
81
+ expect(r.to).toBe('0.2.0')
82
+ expect(r.peers.map(p => p.personality)).toEqual(['timer']) // re-provisioned
83
+ expect(restarts).toEqual(['timer']) // restarted via the injected stop/start
84
+ expect(readRuntimeManifest('notifier', { env })?.version).toBe('0.2.0')
85
+ })
86
+
87
+ test('version-gate: manifest stamp equals npm latest → already-latest, NOTHING runs', async () => {
88
+ const env = envFor(mkTmp())
89
+ writeRuntimeManifest({ runtime: 'notifier', version: '0.2.0' }, { env })
90
+ let npxRan = false
91
+ const r = await updateRuntime({ runtime: 'notifier', env, runNpx: () => ((npxRan = true), { ok: true }), npmVersion: () => '0.2.0' })
92
+ expect(r.state).toBe('already-latest')
93
+ expect(npxRan).toBe(false)
94
+ })
95
+
96
+ test('--force overrides the gate', async () => {
97
+ const root = mkTmp()
98
+ const { dir, hook } = stubBins()
99
+ const env = envFor(root, dir)
100
+ writeRuntimeManifest({ runtime: 'notifier', version: '0.2.0', selfConfig: hook }, { env })
101
+ let npxRan = false
102
+ const r = await updateRuntime({
103
+ runtime: 'notifier',
104
+ env,
105
+ force: true,
106
+ runNpx: () => ((npxRan = true), { ok: true }),
107
+ npmVersion: () => '0.2.0',
108
+ restartPeer: restartOk([]),
109
+ })
110
+ expect(r.state).toBe('updated')
111
+ expect(npxRan).toBe(true)
112
+ })
113
+
114
+ test('no version stamp → gate skipped, idempotent re-install with an honest detail', async () => {
115
+ const root = mkTmp()
116
+ const { dir, hook } = stubBins()
117
+ const env = envFor(root, dir)
118
+ writeRuntimeManifest({ runtime: 'notifier', selfConfig: hook }, { env }) // owners have not stamped yet
119
+ const r = await updateRuntime({
120
+ runtime: 'notifier',
121
+ env,
122
+ runNpx: () => ({ ok: true }),
123
+ npmVersion: () => '0.2.0',
124
+ restartPeer: restartOk([]),
125
+ })
126
+ expect(r.state).toBe('updated')
127
+ expect(r.detail).toContain('no version stamp')
128
+ })
129
+
130
+ test('not installed → not-installed with the install-runtime recipe; npm unreachable → loud', async () => {
131
+ const env = envFor(mkTmp())
132
+ const r = await updateRuntime({ runtime: 'notifier', env, npmVersion: () => '0.2.0' })
133
+ expect(r.state).toBe('not-installed')
134
+ expect(r.detail).toContain('install-runtime notifier')
135
+ writeRuntimeManifest({ runtime: 'notifier', version: '0.1.0' }, { env })
136
+ const r2 = await updateRuntime({ runtime: 'notifier', env, npmVersion: () => null })
137
+ expect(r2.state).toBe('npm-unreachable')
138
+ })
139
+
140
+ test('failed re-npx → install-failed, no deploy, no restarts', async () => {
141
+ const env = envFor(mkTmp())
142
+ writeRuntimeManifest({ runtime: 'notifier', version: '0.1.0' }, { env })
143
+ const restarts: string[] = []
144
+ const r = await updateRuntime({
145
+ runtime: 'notifier',
146
+ env,
147
+ runNpx: () => ({ ok: false, detail: 'network down' }),
148
+ npmVersion: () => '0.2.0',
149
+ restartPeer: restartOk(restarts),
150
+ })
151
+ expect(r.state).toBe('install-failed')
152
+ expect(restarts).toEqual([])
153
+ })
154
+
155
+ test('mode-b runtime (telegram, no declared peers): empty re-provision, registered router restarted', async () => {
156
+ const root = mkTmp()
157
+ const env = envFor(root)
158
+ writeRuntimeManifest({ runtime: 'telegram', version: '0.10.0' }, { env })
159
+ await upsertPeer({ personality: 'arthur', runtime: 'telegram', cwd: '/tmp/arthur', intelligence: 'natural' }, { env })
160
+ const restarts: string[] = []
161
+ const r = await updateRuntime({
162
+ runtime: 'telegram',
163
+ env,
164
+ runNpx: (_pkg, e) => {
165
+ writeRuntimeManifest({ runtime: 'telegram', version: '0.10.3' }, { env: e })
166
+ return { ok: true }
167
+ },
168
+ npmVersion: () => '0.10.3',
169
+ restartPeer: restartOk(restarts),
170
+ })
171
+ expect(r.state).toBe('updated')
172
+ expect(r.peers).toEqual([]) // operator-add runtime: nothing declared to re-provision
173
+ expect(restarts).toEqual(['arthur']) // the router still restarts onto the new code
174
+ })
175
+ })
176
+
177
+ describe('updateAllRuntimes (--all)', () => {
178
+ test('updates installed runtimes, reports the rest not-installed (never an error)', async () => {
179
+ const root = mkTmp()
180
+ const env = envFor(root)
181
+ writeRuntimeManifest({ runtime: 'telegram', version: '1.0.0' }, { env })
182
+ const results = await updateAllRuntimes({
183
+ env,
184
+ runNpx: () => ({ ok: true }),
185
+ npmVersion: () => '1.0.0',
186
+ restartPeer: restartOk([]),
187
+ })
188
+ const byRt = Object.fromEntries(results.map(r => [r.runtime, r.state]))
189
+ expect(byRt.telegram).toBe('already-latest')
190
+ expect(byRt.notifier).toBe('not-installed')
191
+ })
192
+ })
@@ -0,0 +1,185 @@
1
+ // update-runtime — the runtime-package update story (design «Onboard костяка» §(г),
2
+ // FINAL 10.06; owner facts: NO state migrations on either package — notifier is
3
+ // stateless-by-design (registrations live durable in the OWNING peers' profiles),
4
+ // telegram's bots/.env is a stable ABI, its lock is transient self-healing).
5
+ //
6
+ // version-gate (npm) → re-install the package → IDEMPOTENT re-provision through
7
+ // the SAME path install-runtime uses (npx self-deploy + declared-set deploy:
8
+ // PEER_BLURB registry sync, re-self-config, manifest refresh; live peers are
9
+ // never clobbered — the notifier owner's point: without the re-provision a
10
+ // version with a new blurb/self-doc leaves stale descriptions) → restart the
11
+ // runtime's infra peers via the REGULAR stop/start verbs.
12
+ //
13
+ // The core's own `iapeer update` deliberately does NOT touch runtimes (foundation-
14
+ // only — the standing contract; the symmetry is conscious).
15
+ //
16
+ // Version-gate honesty: the installed version comes from the manifest's `version`
17
+ // stamp (the owners' self-install obligation, telegram 10.06). A manifest WITHOUT
18
+ // the stamp cannot be gated — the update proceeds idempotently and says so.
19
+
20
+ import { spawnSync } from 'child_process'
21
+ import { type Runtime } from '../core/constants.ts'
22
+ import { readPeersIndex } from '../registry/index.ts'
23
+ import { readRuntimeManifest } from './index.ts'
24
+ import {
25
+ deployRuntime,
26
+ installRuntimePackage,
27
+ resolveRuntimePackage,
28
+ RUNTIME_PACKAGES,
29
+ type DeployedPeer,
30
+ type NpxRunner,
31
+ } from './deploy.ts'
32
+
33
+ /** Injectable npm-version resolver (tests). Default: `npm view <pkg> version`. */
34
+ export type NpmVersionFn = (pkg: string, env: NodeJS.ProcessEnv) => string | null
35
+
36
+ const defaultNpmVersion: NpmVersionFn = (pkg, env) => {
37
+ const r = spawnSync('npm', ['view', pkg, 'version'], { encoding: 'utf8', env: env as Record<string, string>, timeout: 60_000 })
38
+ const v = (r.stdout ?? '').trim()
39
+ return r.status === 0 && v ? v : null
40
+ }
41
+
42
+ export interface RestartedPeer {
43
+ personality: string
44
+ state: 'restarted' | 'refused-foreign-launchd' | 'failed'
45
+ detail?: string
46
+ }
47
+
48
+ export interface UpdateRuntimeResult {
49
+ runtime: Runtime
50
+ package?: string
51
+ state:
52
+ | 'updated' // re-installed + re-provisioned + peers restarted
53
+ | 'already-latest' // version-gate: the manifest stamp equals npm latest
54
+ | 'not-installed' // no manifest — nothing to update (install-runtime first)
55
+ | 'npm-unreachable' // npm view failed — cannot resolve the target version
56
+ | 'install-failed' // the forced re-npx failed — nothing was touched further
57
+ | 'deploy-failed' // re-provision broke (per-peer detail in `peers`)
58
+ from?: string
59
+ to?: string
60
+ peers: DeployedPeer[]
61
+ restarted: RestartedPeer[]
62
+ detail?: string
63
+ }
64
+
65
+ export interface UpdateRuntimeOptions {
66
+ runtime: Runtime
67
+ /** Re-install even when the version-gate says already-latest. */
68
+ force?: boolean
69
+ env?: NodeJS.ProcessEnv
70
+ runNpx?: NpxRunner
71
+ npmVersion?: NpmVersionFn
72
+ /** Injectable restart (tests). Default: the regular stop→start verbs, strictly
73
+ * sequential per peer. */
74
+ restartPeer?: (personality: string, runtime: Runtime, env: NodeJS.ProcessEnv) => RestartedPeer
75
+ warn?: (message: string) => void
76
+ }
77
+
78
+ /** Default per-peer restart: the REGULAR stop→start verbs (infra → launchctl
79
+ * bootout/bootstrap), strictly sequential. H4 stays intact: a PP-managed peer is
80
+ * refused by the verbs themselves and reported, never forced. */
81
+ async function defaultRestartPeer(personality: string, runtime: Runtime, env: NodeJS.ProcessEnv): Promise<RestartedPeer> {
82
+ const { stopPeer, startPeer } = await import('../cli/index.ts')
83
+ try {
84
+ const stops = stopPeer(personality, runtime, { env })
85
+ if (stops.some(o => o.action === 'refused-foreign-launchd')) {
86
+ return { personality, state: 'refused-foreign-launchd', detail: 'persistent-peer-managed (H4) — restart it yourself' }
87
+ }
88
+ const starts = startPeer(personality, runtime, { env })
89
+ const bad = starts.find(o => o.action === 'bootstrap' && o.reason)
90
+ return bad ? { personality, state: 'failed', detail: bad.reason } : { personality, state: 'restarted' }
91
+ } catch (e) {
92
+ return { personality, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
93
+ }
94
+ }
95
+
96
+ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateRuntimeResult> {
97
+ const env = opts.env ?? process.env
98
+ const runtime = opts.runtime
99
+ const manifest = readRuntimeManifest(runtime, { env })
100
+ if (!manifest) {
101
+ return {
102
+ runtime,
103
+ state: 'not-installed',
104
+ peers: [],
105
+ restarted: [],
106
+ detail: `no manifest for "${runtime}" — install first: iapeer install-runtime ${runtime}`,
107
+ }
108
+ }
109
+ const pkg = resolveRuntimePackage(runtime)
110
+ if (!pkg) {
111
+ return { runtime, state: 'not-installed', peers: [], restarted: [], detail: `no package mapping for "${runtime}"` }
112
+ }
113
+
114
+ // Version-gate: npm latest vs the manifest stamp. No stamp → no gate (proceed
115
+ // idempotently — the owners' stamp obligation closes this once shipped).
116
+ const latest = (opts.npmVersion ?? defaultNpmVersion)(pkg, env)
117
+ if (!latest) {
118
+ return { runtime, package: pkg, state: 'npm-unreachable', peers: [], restarted: [], detail: `npm view ${pkg} version failed — cannot resolve the target` }
119
+ }
120
+ const installed = manifest.version
121
+ if (installed && installed === latest && !opts.force) {
122
+ return { runtime, package: pkg, state: 'already-latest', from: installed, to: latest, peers: [], restarted: [] }
123
+ }
124
+
125
+ // Re-install (forced npx — the package self-deploys its new bin + manifest).
126
+ const install = installRuntimePackage({ runtime, force: true, env, runNpx: opts.runNpx })
127
+ if (install.state !== 'ran') {
128
+ return { runtime, package: pkg, state: 'install-failed', from: installed, to: latest, peers: [], restarted: [], detail: install.detail ?? install.state }
129
+ }
130
+
131
+ // IDEMPOTENT re-provision via the SAME deploy path install-runtime uses: blurb
132
+ // sync, re-self-config, no-clobber on live peers. Mode-b (telegram) declares no
133
+ // peers — the deploy is an empty pass, by design.
134
+ let peers: DeployedPeer[]
135
+ try {
136
+ const d = await deployRuntime({ runtime, env, warn: opts.warn })
137
+ peers = d.peers
138
+ } catch (e) {
139
+ return { runtime, package: pkg, state: 'deploy-failed', from: installed, to: latest, peers: [], restarted: [], detail: e instanceof Error ? e.message : String(e) }
140
+ }
141
+ if (peers.some(p => p.selfConfig === 'failed' || p.bootstrap === 'failed')) {
142
+ return { runtime, package: pkg, state: 'deploy-failed', from: installed, to: latest, peers, restarted: [], detail: 'a declared peer failed re-provision — see the per-peer lines' }
143
+ }
144
+
145
+ // Restart THIS runtime's registered peers so the new baked code runs (notifier
146
+ // semantics per the owner: cron wall-clock unaffected, @every re-anchors,
147
+ // watcher children relaunch, heartbeat windows reset). Strictly sequential.
148
+ const registered = readPeersIndex({ env }).peers.filter(
149
+ p => p.runtime === runtime || p.runtimes.includes(runtime),
150
+ )
151
+ const restarted: RestartedPeer[] = []
152
+ for (const p of registered) {
153
+ restarted.push(
154
+ opts.restartPeer
155
+ ? opts.restartPeer(p.personality, runtime, env)
156
+ : await defaultRestartPeer(p.personality, runtime, env),
157
+ )
158
+ }
159
+
160
+ return {
161
+ runtime,
162
+ package: pkg,
163
+ state: 'updated',
164
+ from: installed,
165
+ to: latest,
166
+ peers,
167
+ restarted,
168
+ detail: installed ? undefined : 'no version stamp in the old manifest — gate skipped, re-installed idempotently',
169
+ }
170
+ }
171
+
172
+ /** `--all`: update every KNOWN runtime that is actually installed (manifest present);
173
+ * the rest are reported as not-installed, never an error. */
174
+ export async function updateAllRuntimes(opts: Omit<UpdateRuntimeOptions, 'runtime'> = {}): Promise<UpdateRuntimeResult[]> {
175
+ const env = opts.env ?? process.env
176
+ const out: UpdateRuntimeResult[] = []
177
+ for (const rt of Object.keys(RUNTIME_PACKAGES) as Runtime[]) {
178
+ if (!readRuntimeManifest(rt, { env })) {
179
+ out.push({ runtime: rt, state: 'not-installed', peers: [], restarted: [], detail: 'not installed — skipped' })
180
+ continue
181
+ }
182
+ out.push(await updateRuntime({ ...opts, runtime: rt }))
183
+ }
184
+ return out
185
+ }