@agfpd/iapeer 0.2.16 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/cli.test.ts +3 -0
- package/src/cli/index.ts +69 -4
- package/src/connect/connect.test.ts +144 -0
- package/src/connect/index.ts +212 -0
- package/src/onboard/index.ts +29 -8
- package/src/runtime/index.ts +6 -1
- package/src/runtime/update.test.ts +192 -0
- package/src/runtime/update.ts +185 -0
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -116,6 +116,9 @@ describe('remove (registry record via the locked writer)', () => {
|
|
|
116
116
|
const o = await removePeerCli('zombie', { env: e })
|
|
117
117
|
expect(o.action).toBe('removed')
|
|
118
118
|
expect(findPeer(readPeersIndex({ env: e }), 'zombie')).toBeNull()
|
|
119
|
+
// the folder is deliberately KEPT; the outcome carries the cwd so the verb can
|
|
120
|
+
// say so instead of leaving silent orphans (boris 10.06)
|
|
121
|
+
expect(o.cwd).toBe('/tmp/zombie')
|
|
119
122
|
})
|
|
120
123
|
test('removing an absent peer is an idempotent no-op (not an error)', async () => {
|
|
121
124
|
const o = await removePeerCli('never-existed', { env: env() })
|
package/src/cli/index.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// sentinel-marked always-on plist) are stop/start-able.
|
|
12
12
|
|
|
13
13
|
import { spawnSync } from 'child_process'
|
|
14
|
-
import { readFileSync } from 'fs'
|
|
14
|
+
import { existsSync, readFileSync } from 'fs'
|
|
15
15
|
import { fileURLToPath } from 'url'
|
|
16
16
|
import {
|
|
17
17
|
isInfraRuntime,
|
|
@@ -255,6 +255,10 @@ export interface RemoveOutcome {
|
|
|
255
255
|
personality: string
|
|
256
256
|
action: 'removed' | 'absent' | 'refused-live'
|
|
257
257
|
reason?: string
|
|
258
|
+
/** The removed peer's cwd (registry fact, captured BEFORE the removal). remove
|
|
259
|
+
* deliberately keeps the folder — user data is never deleted by a registry reap
|
|
260
|
+
* (boris's finding 10.06: say so in the output instead of leaving silent orphans). */
|
|
261
|
+
cwd?: string
|
|
258
262
|
}
|
|
259
263
|
|
|
260
264
|
/**
|
|
@@ -285,7 +289,7 @@ export async function removePeerCli(
|
|
|
285
289
|
}
|
|
286
290
|
}
|
|
287
291
|
await removePeer(personality, { env })
|
|
288
|
-
return { personality, action: 'removed' }
|
|
292
|
+
return { personality, action: 'removed', cwd: peer.cwd }
|
|
289
293
|
}
|
|
290
294
|
|
|
291
295
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -372,6 +376,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
372
376
|
backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES)
|
|
373
377
|
status host snapshot: version, daemon health, memory slot (<provider> | none)
|
|
374
378
|
install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
|
|
379
|
+
update-runtime <runtime> | --all [--force] version-gate → re-install + re-provision declared set → restart the runtime's peers
|
|
375
380
|
init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
|
|
376
381
|
create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
|
|
377
382
|
list [--json] registered peers + per-runtime liveness
|
|
@@ -380,6 +385,7 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
380
385
|
remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
|
|
381
386
|
send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>] manual IAP send (fallback)
|
|
382
387
|
<runtime> launch the cwd's peer (ALWAYS fresh)
|
|
388
|
+
connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
|
|
383
389
|
enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
|
|
384
390
|
attach <peer> [runtime] ensure-live + resume, then tmux attach
|
|
385
391
|
interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
|
|
@@ -552,6 +558,29 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
552
558
|
out(`deployed runtime "${d.runtime}" (${d.peers.length} peer(s))\n`)
|
|
553
559
|
return d.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed') ? 1 : 0
|
|
554
560
|
}
|
|
561
|
+
case 'update-runtime': {
|
|
562
|
+
// §(г) runtime-package update: version-gate (npm vs the manifest stamp) →
|
|
563
|
+
// forced re-npx → idempotent re-provision (same path as install-runtime) →
|
|
564
|
+
// restart the runtime's peers via the regular stop/start. The core's own
|
|
565
|
+
// `update` stays foundation-only — this is the runtimes' counterpart.
|
|
566
|
+
const all = flags.all === true
|
|
567
|
+
if (!all && !positionals[0]) return usage(errOut)
|
|
568
|
+
const { updateRuntime, updateAllRuntimes } = await import('../runtime/update.ts')
|
|
569
|
+
const results = all
|
|
570
|
+
? await updateAllRuntimes({ force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) })
|
|
571
|
+
: [await updateRuntime({ runtime: positionals[0] as Runtime, force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) })]
|
|
572
|
+
let failed = false
|
|
573
|
+
for (const r of results) {
|
|
574
|
+
const ver = r.from || r.to ? ` ${r.from ?? '?'} → ${r.to ?? '?'}` : ''
|
|
575
|
+
out(`${r.runtime}: ${r.state}${ver}${r.detail ? ` — ${r.detail}` : ''}\n`)
|
|
576
|
+
for (const p of r.peers) out(` re-provisioned ${p.personality}: self-config ${p.selfConfig ?? 'n/a'}\n`)
|
|
577
|
+
for (const p of r.restarted) out(` restart ${p.personality}: ${p.state}${p.detail ? ` — ${p.detail}` : ''}\n`)
|
|
578
|
+
if (r.state === 'install-failed' || r.state === 'deploy-failed' || r.state === 'npm-unreachable') failed = true
|
|
579
|
+
if (r.restarted.some(p => p.state === 'failed')) failed = true
|
|
580
|
+
if (!all && r.state === 'not-installed') failed = true
|
|
581
|
+
}
|
|
582
|
+
return failed ? 1 : 0
|
|
583
|
+
}
|
|
555
584
|
case 'init': {
|
|
556
585
|
// cwd-DEPENDENT: onboard the CURRENT folder (or positional cwd) as a peer —
|
|
557
586
|
// identity + MCP wiring + doctrine, runtime resolved from the cwd's markers
|
|
@@ -632,8 +661,14 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
632
661
|
// peer unless --force (orphaning a running session from routing is the risk).
|
|
633
662
|
if (!positionals[0]) return usage(errOut)
|
|
634
663
|
const o = await removePeerCli(positionals[0], { force: flags.force === true, env })
|
|
635
|
-
if (o.action === 'removed')
|
|
636
|
-
|
|
664
|
+
if (o.action === 'removed') {
|
|
665
|
+
out(`removed "${o.personality}" from the registry\n`)
|
|
666
|
+
// Deliberate: the registry reap never deletes user data — but SAY so, or
|
|
667
|
+
// the default-location peers leave silent orphan folders (boris 10.06).
|
|
668
|
+
if (o.cwd && existsSync(o.cwd)) {
|
|
669
|
+
out(`folder kept: ${o.cwd} (remove never deletes peer data — \`rm -rf\` it yourself if it was a throwaway)\n`)
|
|
670
|
+
}
|
|
671
|
+
} else if (o.action === 'absent') out(`"${o.personality}" not registered — no-op\n`)
|
|
637
672
|
else errOut(`remove: ${o.reason}\n`)
|
|
638
673
|
return o.action === 'refused-live' ? 1 : 0
|
|
639
674
|
}
|
|
@@ -839,6 +874,36 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
839
874
|
out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
|
|
840
875
|
return 0
|
|
841
876
|
}
|
|
877
|
+
case 'connect': {
|
|
878
|
+
// Per-peer channel attachment in ONE flow (design «Onboard костяка» §(в)):
|
|
879
|
+
// `connect telegram <peer> [--token <t>]`. The human owes only the token;
|
|
880
|
+
// alias/bot-add/interface/router-restart are resolved by the system. The
|
|
881
|
+
// FIRST message from the human to the bot activates the chat (platform rule).
|
|
882
|
+
if (positionals[0] !== 'telegram' || !positionals[1]) return usage(errOut)
|
|
883
|
+
const { connectTelegram } = await import('../connect/index.ts')
|
|
884
|
+
const r = await connectTelegram({
|
|
885
|
+
peer: positionals[1],
|
|
886
|
+
token: typeof flags.token === 'string' ? flags.token : undefined,
|
|
887
|
+
env,
|
|
888
|
+
})
|
|
889
|
+
if (r.state === 'noop-same-token') {
|
|
890
|
+
out(`connect telegram ${r.peer}: ${r.detail}\n`)
|
|
891
|
+
return 0
|
|
892
|
+
}
|
|
893
|
+
if (r.state !== 'connected') {
|
|
894
|
+
errOut(`connect telegram ${r.peer}: ${r.state}${r.detail ? ` — ${r.detail}` : ''}\n`)
|
|
895
|
+
return 1
|
|
896
|
+
}
|
|
897
|
+
const rs = r.restart!
|
|
898
|
+
out(`bot ${r.username ?? `for "${r.peer}"`} added + interfaced to "${r.peer}"\n`)
|
|
899
|
+
out(
|
|
900
|
+
rs.state === 'restarted'
|
|
901
|
+
? `router restarted — credentials loaded\n`
|
|
902
|
+
: `router restart ${rs.state}${rs.detail ? ` — ${rs.detail}` : ''} (the channel stays dead until the router restarts)\n`,
|
|
903
|
+
)
|
|
904
|
+
out(`activation: send the bot ${r.username ?? '(see @BotFather)'} its FIRST message — Telegram does not let a bot start the chat\n`)
|
|
905
|
+
return rs.state === 'restarted' ? 0 : 1
|
|
906
|
+
}
|
|
842
907
|
case 'enable': {
|
|
843
908
|
// Per-peer capability install (contract Установка §3): install <plugin>@agfpd
|
|
844
909
|
// 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
|
+
}
|
package/src/onboard/index.ts
CHANGED
|
@@ -55,7 +55,7 @@ function runtimeBin(runtime: OnboardRuntime, env: NodeJS.ProcessEnv): string {
|
|
|
55
55
|
return env.IAPEER_CODEX_BIN?.trim() || 'codex'
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function isExecutable(binOrName: string): boolean {
|
|
58
|
+
function isExecutable(binOrName: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
59
59
|
if (binOrName.includes('/')) {
|
|
60
60
|
try {
|
|
61
61
|
accessSync(binOrName, FS.X_OK)
|
|
@@ -64,9 +64,22 @@ function isExecutable(binOrName: string): boolean {
|
|
|
64
64
|
return false
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
// bare name →
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
// bare name → PRESENCE probe over PATH (`command -v` semantics), NO spawn.
|
|
68
|
+
// History (both live finds 10.06): the original `--version` ANSWER probe HANGS
|
|
69
|
+
// FOREVER for codex in a non-tty (three stray probes sat 25+ min); the 10 s
|
|
70
|
+
// timeout that replaced it then DEGRADED a LIVE codex to 'runtime-missing' —
|
|
71
|
+
// masking a working runtime (boris's catch). The skip-decision only asks "is
|
|
72
|
+
// the runtime installed", and presence answers that without executing anything.
|
|
73
|
+
for (const dir of (env.PATH ?? '').split(':')) {
|
|
74
|
+
if (!dir) continue
|
|
75
|
+
try {
|
|
76
|
+
accessSync(join(dir, binOrName), FS.X_OK)
|
|
77
|
+
return true
|
|
78
|
+
} catch {
|
|
79
|
+
/* not in this PATH segment */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return false
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
/**
|
|
@@ -77,7 +90,11 @@ function isExecutable(binOrName: string): boolean {
|
|
|
77
90
|
*/
|
|
78
91
|
export function isMarketplaceRegistered(runtime: OnboardRuntime, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
79
92
|
const bin = runtimeBin(runtime, env)
|
|
80
|
-
|
|
93
|
+
// HARD TIMEOUT — the codex CLI hangs FOREVER in a non-tty on ANY subcommand
|
|
94
|
+
// (live 10.06: first `--version`, then `plugin marketplace list` after the
|
|
95
|
+
// presence-probe fix let a live codex through). Timeout → status null →
|
|
96
|
+
// "not registered" → the add (also time-bounded) decides; never a wedge.
|
|
97
|
+
const r = spawnSync(bin, ['plugin', 'marketplace', 'list'], { encoding: 'utf8', timeout: 60_000 })
|
|
81
98
|
if (r.status !== 0) return false
|
|
82
99
|
return isAgfpdInList(`${r.stdout ?? ''}`)
|
|
83
100
|
}
|
|
@@ -98,8 +115,12 @@ export function isAgfpdInList(listOutput: string): boolean {
|
|
|
98
115
|
/** Register OUR marketplace for this runtime (`<runtime> plugin marketplace add <ref>`). */
|
|
99
116
|
function registerMarketplace(runtime: OnboardRuntime, env: NodeJS.ProcessEnv): { ok: boolean; detail?: string } {
|
|
100
117
|
const bin = runtimeBin(runtime, env)
|
|
101
|
-
|
|
102
|
-
|
|
118
|
+
// Same hard timeout as the list probe (codex non-tty hang class) — a wedged add
|
|
119
|
+
// degrades to a loud 'failed' line instead of freezing the host phase.
|
|
120
|
+
const r = spawnSync(bin, ['plugin', 'marketplace', 'add', MARKETPLACE_REF], { encoding: 'utf8', timeout: 120_000 })
|
|
121
|
+
return r.status === 0
|
|
122
|
+
? { ok: true }
|
|
123
|
+
: { ok: false, detail: (r.stderr ?? '').trim() || (r.status === null ? 'timed out (non-tty hang?)' : `exit ${r.status}`) }
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
/**
|
|
@@ -115,7 +136,7 @@ export function onboardHost(opts: OnboardOptions = {}): OnboardResult {
|
|
|
115
136
|
const runtimes = opts.runtimes ?? (['claude', 'codex'] as OnboardRuntime[])
|
|
116
137
|
const marketplaces: OnboardRuntimeResult[] = []
|
|
117
138
|
for (const runtime of runtimes) {
|
|
118
|
-
if (!isExecutable(runtimeBin(runtime, env))) {
|
|
139
|
+
if (!isExecutable(runtimeBin(runtime, env), env)) {
|
|
119
140
|
marketplaces.push({ runtime, state: 'runtime-missing' })
|
|
120
141
|
continue
|
|
121
142
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|