@agfpd/iapeer 0.2.14 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/index.ts +35 -1
- package/src/launch/adapters/claude.ts +18 -8
- package/src/launch/launch.test.ts +19 -6
- package/src/onboard/steps.test.ts +201 -0
- package/src/onboard/steps.ts +246 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -359,7 +359,8 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
359
359
|
version | --version | -v print the installed binary's version
|
|
360
360
|
help | --help | -h print this usage (works appended to any verb; executes nothing)
|
|
361
361
|
daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
|
|
362
|
-
onboard [--dry-run] [--
|
|
362
|
+
onboard [--dry-run] [--no-notifier] [--no-telegram] [--telegram-human <p>] [--telegram-user-id <id>] [--no-memory] [--memory <pkg>] [--infra <csv>]
|
|
363
|
+
backbone host-phase: marketplace → notifier → telegram (human peer) → memory (all default YES)
|
|
363
364
|
status host snapshot: version, daemon health, memory slot (<provider> | none)
|
|
364
365
|
install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
|
|
365
366
|
init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
|
|
@@ -410,6 +411,39 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
410
411
|
out(r.noop ? 'onboard: no marketplace changes (already configured / dry-run)\n' : 'onboard: marketplace(s) registered\n')
|
|
411
412
|
let infraFailed = false
|
|
412
413
|
const infra = typeof flags.infra === 'string' ? flags.infra.split(',').map(s => s.trim()).filter(Boolean) : []
|
|
414
|
+
// Backbone default-yes steps (design «Onboard костяка», FINAL 10.06). ORDER is
|
|
415
|
+
// significant: notifier → TELEGRAM (creates the human peer) → memory below
|
|
416
|
+
// (its --human resolves from the natural peer that just appeared). Each step
|
|
417
|
+
// is soft-skip on unavailability; only a REAL deploy/create break fails.
|
|
418
|
+
const { onboardNotifierStep, onboardTelegramStep } = await import('./../onboard/steps.ts')
|
|
419
|
+
// An explicit `--infra notifier` takes the fail-closed explicit path below —
|
|
420
|
+
// the default-yes (soft-skip) step then stands aside to avoid double-deploy.
|
|
421
|
+
const ns = await onboardNotifierStep({
|
|
422
|
+
skip: flags['no-notifier'] === true || infra.includes('notifier'),
|
|
423
|
+
dryRun: flags['dry-run'] === true,
|
|
424
|
+
env,
|
|
425
|
+
warn: m => errOut(`warn: ${m}\n`),
|
|
426
|
+
})
|
|
427
|
+
if (ns.state !== 'skipped-flag') {
|
|
428
|
+
const peersLine = ns.peers.length ? ` — ${ns.peers.map(p => `${p.personality} (bootstrap ${p.bootstrap ?? 'n/a'})`).join(', ')}` : ''
|
|
429
|
+
out(`notifier: ${ns.state}${ns.detail ? ` — ${ns.detail}` : ''}${peersLine}\n`)
|
|
430
|
+
if (ns.state === 'deploy-failed') infraFailed = true
|
|
431
|
+
}
|
|
432
|
+
const ts = await onboardTelegramStep({
|
|
433
|
+
skip: flags['no-telegram'] === true,
|
|
434
|
+
human: typeof flags['telegram-human'] === 'string' ? flags['telegram-human'] : undefined,
|
|
435
|
+
userId: typeof flags['telegram-user-id'] === 'string' ? flags['telegram-user-id'] : undefined,
|
|
436
|
+
dryRun: flags['dry-run'] === true,
|
|
437
|
+
env,
|
|
438
|
+
warn: m => errOut(`warn: ${m}\n`),
|
|
439
|
+
})
|
|
440
|
+
if (ts.state !== 'skipped-flag') {
|
|
441
|
+
const line = `telegram: ${ts.state}${ts.personality ? ` — ${ts.personality}` : ''}${ts.detail ? ` — ${ts.detail}` : ''}\n`
|
|
442
|
+
// a refusal/failure goes to stderr (loud), the rest to stdout
|
|
443
|
+
if (ts.state === 'refused-non-tty' || ts.state === 'invalid-input' || ts.state === 'create-failed') errOut(line)
|
|
444
|
+
else out(line)
|
|
445
|
+
if (ts.state === 'invalid-input' || ts.state === 'create-failed') infraFailed = true
|
|
446
|
+
}
|
|
413
447
|
if (infra.length && flags['dry-run'] !== true) {
|
|
414
448
|
const { onboardRuntime } = await import('../runtime/deploy.ts')
|
|
415
449
|
for (const rt of infra) {
|
|
@@ -39,7 +39,8 @@ import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, Runt
|
|
|
39
39
|
* (claude-start.sh:337), shown when PEER_START_ARGS carries
|
|
40
40
|
* --dangerously-load-development-channels.
|
|
41
41
|
* - 'Resume from summary' — the resume compact-picker (default cursor =
|
|
42
|
-
* "summary (recommended)", which compacts; bootDialogKeys
|
|
42
|
+
* "summary (recommended)", which compacts; bootDialogKeys steps the cursor to
|
|
43
|
+
* "full" ONE key per iteration and confirms only after SEEING it there).
|
|
43
44
|
* - 'Resuming the full session' — the post-select load state (still not ready).
|
|
44
45
|
*/
|
|
45
46
|
const CLAUDE_BOOT_DIALOG_MARKERS = [
|
|
@@ -146,21 +147,30 @@ export const claudeAdapter: RuntimeAdapter = {
|
|
|
146
147
|
/**
|
|
147
148
|
* Map a visible startup dialog to the keys that clear it correctly:
|
|
148
149
|
*
|
|
149
|
-
* - RESUME COMPACT-PICKER ('Resume from summary …') →
|
|
150
|
+
* - RESUME COMPACT-PICKER ('Resume from summary …') → CURSOR-VERIFIED two-step.
|
|
150
151
|
* This menu's cursor DEFAULTS to "1. Resume from summary (recommended)",
|
|
151
152
|
* and selecting it COMPACTS the session (claude implements that choice as an
|
|
152
|
-
* internal /compact)
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
153
|
+
* internal /compact) — the owner's invariant is «на resume не должно быть
|
|
154
|
+
* компакта». The original blind ['Down','Enter'] burst (0e67e1f) put BOTH
|
|
155
|
+
* keys into one pty chunk; under a heavy resume (boris 10.06, 313k-token
|
|
156
|
+
* session) the TUI dropped the Down and the Enter confirmed the DEFAULT →
|
|
157
|
+
* silent /compact (proven: pane log shows the picker frame with ❯ on
|
|
158
|
+
* option 1 followed by "❯ /compact"; transcript compactMetadata
|
|
159
|
+
* trigger=manual). Fix: ONE key per boot-iteration — Down while the cursor
|
|
160
|
+
* is NOT yet seen on "2. Resume full session", Enter ONLY after the
|
|
161
|
+
* captured pane PROVES it ("❯ 2."). A swallowed Down self-heals on the
|
|
162
|
+
* next 2 s iteration; Enter can never hit the compacting default. If the
|
|
163
|
+
* picker layout ever changes, the loop Downs until the boot deadline and
|
|
164
|
+
* the wake fails LOUD — never a silent compact.
|
|
157
165
|
* - OTHER MODALS (folder-trust / external-import / dev-channels) → ['Enter']:
|
|
158
166
|
* their default-highlighted option IS the proceed path, so a bare Enter clears
|
|
159
167
|
* each (claude-start.sh:341).
|
|
160
168
|
* - anything else (incl. the post-select "Resuming…" load state) → null (wait).
|
|
161
169
|
*/
|
|
162
170
|
bootDialogKeys(pane: string): string[] | null {
|
|
163
|
-
if (pane.includes('Resume from summary'))
|
|
171
|
+
if (pane.includes('Resume from summary')) {
|
|
172
|
+
return /❯\s*2\./.test(pane) ? ['Enter'] : ['Down']
|
|
173
|
+
}
|
|
164
174
|
if (
|
|
165
175
|
pane.includes('trust this folder') ||
|
|
166
176
|
pane.includes('Allow external CLAUDE.md file imports?') ||
|
|
@@ -172,12 +172,25 @@ describe('claudeAdapter', () => {
|
|
|
172
172
|
expect(claudeAdapter.bootDialogKeys('❯ ready')).toBeNull()
|
|
173
173
|
})
|
|
174
174
|
|
|
175
|
-
test('bootDialogKeys: resume compact-picker
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
).
|
|
175
|
+
test('bootDialogKeys: resume compact-picker is CURSOR-VERIFIED — Enter ONLY after ❯ is seen on "2. full"', () => {
|
|
176
|
+
// Regression (boris 10.06, 313k resume): the blind ['Down','Enter'] burst lost the
|
|
177
|
+
// Down in one pty chunk and Enter confirmed the DEFAULT "1. Resume from summary"
|
|
178
|
+
// → silent /compact. Owner's invariant: NO compact on resume. One key per boot
|
|
179
|
+
// iteration; confirm only on a PROVEN cursor position.
|
|
180
|
+
const cursorOn1 = '❯ 1. Resume from summary (recommended)\n 2. Resume full session as-is'
|
|
181
|
+
const cursorOn2 = ' 1. Resume from summary (recommended)\n❯ 2. Resume full session as-is'
|
|
182
|
+
// cursor on the compacting default → step down, do NOT confirm
|
|
183
|
+
expect(claudeAdapter.bootDialogKeys(cursorOn1)).toEqual(['Down'])
|
|
184
|
+
// a swallowed Down self-heals: the unchanged pane just gets another Down
|
|
185
|
+
expect(claudeAdapter.bootDialogKeys(cursorOn1)).toEqual(['Down'])
|
|
186
|
+
// cursor PROVEN on "2. Resume full session" → now (and only now) confirm
|
|
187
|
+
expect(claudeAdapter.bootDialogKeys(cursorOn2)).toEqual(['Enter'])
|
|
188
|
+
// Enter can never reach the compacting default: option-1 cursor never maps to Enter
|
|
189
|
+
expect(claudeAdapter.bootDialogKeys(cursorOn1)).not.toContain('Enter')
|
|
190
|
+
// the 2.1.170 live layout (3rd item present) behaves the same
|
|
191
|
+
const live170 =
|
|
192
|
+
"This session is 7h 3m old and 314.2k tokens.\n❯ 1. Resume from summary (recommended)\n 2. Resume full session as-is\n 3. Don't ask me again\nEnter to confirm · Esc to cancel"
|
|
193
|
+
expect(claudeAdapter.bootDialogKeys(live170)).toEqual(['Down'])
|
|
181
194
|
})
|
|
182
195
|
|
|
183
196
|
test('permissionDialog: proceed prompt → active, [Enter]', () => {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Backbone default-yes onboard steps (design «Onboard костяка» FINAL 10.06) —
|
|
2
|
+
// notifier (zero questions, soft-skip on unavailability) and telegram (human peer
|
|
3
|
+
// via TELEGRAM_USER_ID env → the package's self-config hook; idempotent vs an
|
|
4
|
+
// existing natural peer). All sandboxed: IAPEER_ROOT / IAPEER_LAUNCHAGENTS_DIR
|
|
5
|
+
// temp dirs, IAPEER_TEST_SANDBOX skips real launchctl, injected npx/ask.
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
8
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
9
|
+
import { tmpdir } from 'os'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { findNaturalPeer, onboardNotifierStep, onboardTelegramStep } from './steps.ts'
|
|
12
|
+
import { writeRuntimeManifest, type RuntimeManifest } from '../runtime/index.ts'
|
|
13
|
+
import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
14
|
+
|
|
15
|
+
const roots: string[] = []
|
|
16
|
+
function mkTmp(): string {
|
|
17
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-steps-'))
|
|
18
|
+
roots.push(d)
|
|
19
|
+
return d
|
|
20
|
+
}
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
function envFor(root: string, path?: string): NodeJS.ProcessEnv {
|
|
26
|
+
return {
|
|
27
|
+
IAPEER_ROOT: join(root, 'iapeer'),
|
|
28
|
+
IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
|
|
29
|
+
IAPEER_TEST_SANDBOX: '1',
|
|
30
|
+
HOME: root,
|
|
31
|
+
...(path ? { PATH: path } : {}),
|
|
32
|
+
} as NodeJS.ProcessEnv
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Stub launcher + recording self-config hook on a PATH dir. */
|
|
36
|
+
function stubBins(bin: string): { dir: string; hook: string } {
|
|
37
|
+
const dir = mkTmp()
|
|
38
|
+
writeFileSync(join(dir, bin), '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
|
|
39
|
+
const hook = join(dir, 'sc.sh')
|
|
40
|
+
// records personality + the TELEGRAM_USER_ID it received → proves env passthrough
|
|
41
|
+
writeFileSync(
|
|
42
|
+
hook,
|
|
43
|
+
'#!/bin/sh\nprintf "%s|%s" "$IAPEER_PEER_PERSONALITY" "$TELEGRAM_USER_ID" > "$IAPEER_ROOT/sc-$IAPEER_PEER_PERSONALITY"\nexit 0\n',
|
|
44
|
+
{ mode: 0o755 },
|
|
45
|
+
)
|
|
46
|
+
return { dir, hook }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('onboardNotifierStep (а — default-yes, zero questions)', () => {
|
|
50
|
+
test('npx self-deploy → declared set provisioned', async () => {
|
|
51
|
+
const root = mkTmp()
|
|
52
|
+
const { dir, hook } = stubBins('notifier-runtime')
|
|
53
|
+
const env = envFor(root, dir)
|
|
54
|
+
const runNpx = (_pkg: string, e: NodeJS.ProcessEnv) => {
|
|
55
|
+
const m: RuntimeManifest = {
|
|
56
|
+
runtime: 'notifier',
|
|
57
|
+
selfConfig: hook,
|
|
58
|
+
peers: [
|
|
59
|
+
{ personality: 'timer', intelligence: 'absent' },
|
|
60
|
+
{ personality: 'watcher', intelligence: 'absent' },
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
writeRuntimeManifest(m, { env: e })
|
|
64
|
+
return { ok: true }
|
|
65
|
+
}
|
|
66
|
+
const r = await onboardNotifierStep({ env, runNpx })
|
|
67
|
+
expect(r.state).toBe('deployed')
|
|
68
|
+
expect(r.peers.map(p => p.personality).sort()).toEqual(['timer', 'watcher'])
|
|
69
|
+
expect(findPeer(readPeersIndex({ env }), 'timer')).not.toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('npx failure (no bun / unpublished / no network) → SOFT skip, not a failure', async () => {
|
|
73
|
+
const env = envFor(mkTmp())
|
|
74
|
+
const r = await onboardNotifierStep({ env, runNpx: () => ({ ok: false, detail: 'bun: command not found' }) })
|
|
75
|
+
expect(r.state).toBe('skipped-unavailable')
|
|
76
|
+
expect(r.detail).toContain('bun: command not found')
|
|
77
|
+
expect(r.detail).toContain('install later')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('package installed but manifest missing → deploy-failed (REAL break, not unavailability)', async () => {
|
|
81
|
+
const env = envFor(mkTmp())
|
|
82
|
+
// npx "succeeds" but self-deploys nothing — the package broke its contract
|
|
83
|
+
const r = await onboardNotifierStep({ env, runNpx: () => ({ ok: true }) })
|
|
84
|
+
expect(r.state).toBe('deploy-failed')
|
|
85
|
+
expect(r.detail).toContain('manifest')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('--no-notifier → skipped-flag; dry-run reports intent without touching anything', async () => {
|
|
89
|
+
const env = envFor(mkTmp())
|
|
90
|
+
expect((await onboardNotifierStep({ skip: true, env })).state).toBe('skipped-flag')
|
|
91
|
+
const dry = await onboardNotifierStep({ dryRun: true, env })
|
|
92
|
+
expect(dry.state).toBe('dry-run')
|
|
93
|
+
expect(dry.detail).toContain('@agfpd/notifier-runtime')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('idempotent re-run: manifest present → install skipped, deploy no-clobber', async () => {
|
|
97
|
+
const root = mkTmp()
|
|
98
|
+
const { dir, hook } = stubBins('notifier-runtime')
|
|
99
|
+
const env = envFor(root, dir)
|
|
100
|
+
writeRuntimeManifest(
|
|
101
|
+
{ runtime: 'notifier', selfConfig: hook, peers: [{ personality: 'timer', intelligence: 'absent' }] },
|
|
102
|
+
{ env },
|
|
103
|
+
)
|
|
104
|
+
let npxCalled = false
|
|
105
|
+
const first = await onboardNotifierStep({ env, runNpx: () => ((npxCalled = true), { ok: true }) })
|
|
106
|
+
expect(first.state).toBe('deployed')
|
|
107
|
+
expect(npxCalled).toBe(false) // manifest-gated — npx never re-ran
|
|
108
|
+
const second = await onboardNotifierStep({ env, runNpx: () => ({ ok: true }) })
|
|
109
|
+
expect(second.state).toBe('deployed') // no-clobber re-verify
|
|
110
|
+
expect(readPeersIndex({ env }).peers.filter(p => p.personality === 'timer').length).toBe(1)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('onboardTelegramStep (б — human peer, идемпотентность, non-tty)', () => {
|
|
115
|
+
test('flags path: creates the human peer; TELEGRAM_USER_ID reaches the self-config hook', async () => {
|
|
116
|
+
const root = mkTmp()
|
|
117
|
+
const { dir, hook } = stubBins('telegram-runtime')
|
|
118
|
+
const env = envFor(root, dir)
|
|
119
|
+
writeRuntimeManifest({ runtime: 'telegram', selfConfig: hook }, { env }) // installed (mode b — no declared peers)
|
|
120
|
+
const r = await onboardTelegramStep({ human: 'Arthur', userId: '123456789', env })
|
|
121
|
+
expect(r.state).toBe('created')
|
|
122
|
+
expect(r.personality).toBe('arthur') // normalized
|
|
123
|
+
const peer = findPeer(readPeersIndex({ env }), 'arthur')!
|
|
124
|
+
expect(peer.intelligence).toBe('natural')
|
|
125
|
+
expect(peer.runtime).toBe('telegram')
|
|
126
|
+
// the hook saw the user id via env (owner's contract: hook writes interfaces itself)
|
|
127
|
+
expect(readFileSync(join(env.IAPEER_ROOT as string, 'sc-arthur'), 'utf8')).toBe('arthur|123456789')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('IDEMPOTENT: an existing natural peer → already, NEVER a second human (boris check)', async () => {
|
|
131
|
+
const env = envFor(mkTmp())
|
|
132
|
+
await upsertPeer(
|
|
133
|
+
{ personality: 'arthur', runtime: 'telegram', cwd: '/tmp/arthur', intelligence: 'natural' },
|
|
134
|
+
{ env },
|
|
135
|
+
)
|
|
136
|
+
let asked = false
|
|
137
|
+
const r = await onboardTelegramStep({
|
|
138
|
+
env,
|
|
139
|
+
ask: async () => ((asked = true), 'never'),
|
|
140
|
+
isTty: true,
|
|
141
|
+
})
|
|
142
|
+
expect(r.state).toBe('already')
|
|
143
|
+
expect(r.personality).toBe('arthur')
|
|
144
|
+
expect(r.detail).toContain('уже есть')
|
|
145
|
+
expect(asked).toBe(false) // no questions, no install — the WHOLE step no-ops
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('non-tty without flags → refusal of the STEP (with the flag recipe), not of onboard', async () => {
|
|
149
|
+
const root = mkTmp()
|
|
150
|
+
const env = envFor(root)
|
|
151
|
+
writeRuntimeManifest({ runtime: 'telegram' }, { env })
|
|
152
|
+
const r = await onboardTelegramStep({ env, isTty: false })
|
|
153
|
+
expect(r.state).toBe('refused-non-tty')
|
|
154
|
+
expect(r.detail).toContain('--telegram-human')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('tty prompt path: answers flow in; empty answer → soft refusal', async () => {
|
|
158
|
+
const root = mkTmp()
|
|
159
|
+
const { dir, hook } = stubBins('telegram-runtime')
|
|
160
|
+
const env = envFor(root, dir)
|
|
161
|
+
writeRuntimeManifest({ runtime: 'telegram', selfConfig: hook }, { env })
|
|
162
|
+
const answers = ['leo', '42']
|
|
163
|
+
const r = await onboardTelegramStep({ env, isTty: true, ask: async () => answers.shift() ?? '' })
|
|
164
|
+
expect(r.state).toBe('created')
|
|
165
|
+
expect(r.personality).toBe('leo')
|
|
166
|
+
// empty answer → not now
|
|
167
|
+
const env2 = envFor(mkTmp())
|
|
168
|
+
writeRuntimeManifest({ runtime: 'telegram' }, { env: env2 })
|
|
169
|
+
const r2 = await onboardTelegramStep({ env: env2, isTty: true, ask: async () => '' })
|
|
170
|
+
expect(r2.state).toBe('refused-non-tty')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('invalid explicit flags → invalid-input (hard): bad name / non-digit user id', async () => {
|
|
174
|
+
const root = mkTmp()
|
|
175
|
+
const env = envFor(root)
|
|
176
|
+
writeRuntimeManifest({ runtime: 'telegram' }, { env })
|
|
177
|
+
expect((await onboardTelegramStep({ human: '!!!', userId: '42', env })).state).toBe('invalid-input')
|
|
178
|
+
const r = await onboardTelegramStep({ human: 'leo', userId: 'abc', env })
|
|
179
|
+
expect(r.state).toBe('invalid-input')
|
|
180
|
+
expect(r.detail).toContain('@userinfobot')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('package unavailable → soft skip (step), with the install-later recipe', async () => {
|
|
184
|
+
const env = envFor(mkTmp())
|
|
185
|
+
const r = await onboardTelegramStep({
|
|
186
|
+
human: 'leo',
|
|
187
|
+
userId: '42',
|
|
188
|
+
env,
|
|
189
|
+
runNpx: () => ({ ok: false, detail: 'npm ERR 404' }),
|
|
190
|
+
})
|
|
191
|
+
expect(r.state).toBe('skipped-unavailable')
|
|
192
|
+
expect(r.detail).toContain('install-runtime telegram')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('findNaturalPeer: null on empty/no registry, found when present', async () => {
|
|
196
|
+
const env = envFor(mkTmp())
|
|
197
|
+
expect(findNaturalPeer(env)).toBeNull()
|
|
198
|
+
await upsertPeer({ personality: 'h', runtime: 'telegram', cwd: '/tmp/h', intelligence: 'natural' }, { env })
|
|
199
|
+
expect(findNaturalPeer(env)).toBe('h')
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Default-yes onboard steps for the distribution BACKBONE (design doc
|
|
2
|
+
// docs/«Onboard костяка — notifier, telegram, каналы, update» — FINAL 10.06,
|
|
3
|
+
// sanctioned by Артур; owners' facts baked: notifier ack «ноль вопросов»,
|
|
4
|
+
// telegram self-config contract v0.10.0+).
|
|
5
|
+
//
|
|
6
|
+
// Backbone = core + memory slot + notifier-runtime + telegram-runtime. The onboard
|
|
7
|
+
// step ORDER is significant: marketplace → notifier → TELEGRAM (creates the human
|
|
8
|
+
// peer) → memory (its --human resolves from the natural peer that just appeared).
|
|
9
|
+
//
|
|
10
|
+
// UX principle («вопросами владеет рантайм»): the package owns its questions; the
|
|
11
|
+
// core orchestrates the steps, passes only facts IT owns, and inherits stdio for
|
|
12
|
+
// the package's own interactive. Every step is OPTIONAL (default-yes, removed by a
|
|
13
|
+
// flag); unavailability is a SOFT SKIP, never a core failure.
|
|
14
|
+
|
|
15
|
+
import { spawnSync } from 'child_process'
|
|
16
|
+
import { isValidName, normalizeIntelligenceValue, normalizeNameCandidate } from '../core/constants.ts'
|
|
17
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
18
|
+
import { createPeer } from '../create/index.ts'
|
|
19
|
+
import {
|
|
20
|
+
deployRuntime,
|
|
21
|
+
installRuntimePackage,
|
|
22
|
+
RUNTIME_PACKAGES,
|
|
23
|
+
type DeployedPeer,
|
|
24
|
+
type NpxRunner,
|
|
25
|
+
} from '../runtime/deploy.ts'
|
|
26
|
+
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// (а) notifier — default-yes, ZERO questions (owner-confirmed: the whole chain
|
|
29
|
+
// has no prompt/stdin/tty dependency; all failures are fail-closed exit≠0).
|
|
30
|
+
// Prereq (not a question): `bun` on PATH — without it the npx shim exits non-zero
|
|
31
|
+
// → SOFT SKIP with a clear message (design: unavailability is not a core error).
|
|
32
|
+
// Idempotent re-onboard: install is manifest-gated (skipped), deploy is no-clobber
|
|
33
|
+
// («1 плист = 1 infra-пир»).
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export interface NotifierStepOptions {
|
|
37
|
+
/** --no-notifier: remove the default-yes step. */
|
|
38
|
+
skip?: boolean
|
|
39
|
+
dryRun?: boolean
|
|
40
|
+
env?: NodeJS.ProcessEnv
|
|
41
|
+
/** Injected npx runner (tests / sandbox proof). */
|
|
42
|
+
runNpx?: NpxRunner
|
|
43
|
+
warn?: (message: string) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface NotifierStepResult {
|
|
47
|
+
state:
|
|
48
|
+
| 'deployed' // declared set provisioned (or re-verified — idempotent)
|
|
49
|
+
| 'skipped-flag' // --no-notifier
|
|
50
|
+
| 'skipped-unavailable' // npx failed (no bun / not published / no network) — soft skip
|
|
51
|
+
| 'deploy-failed' // the package installed but the declared-set deploy broke — REAL failure
|
|
52
|
+
| 'dry-run'
|
|
53
|
+
peers: DeployedPeer[]
|
|
54
|
+
detail?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function onboardNotifierStep(opts: NotifierStepOptions = {}): Promise<NotifierStepResult> {
|
|
58
|
+
const env = opts.env ?? process.env
|
|
59
|
+
if (opts.skip) return { state: 'skipped-flag', peers: [] }
|
|
60
|
+
if (opts.dryRun) {
|
|
61
|
+
return {
|
|
62
|
+
state: 'dry-run',
|
|
63
|
+
peers: [],
|
|
64
|
+
detail: `would npx-install ${RUNTIME_PACKAGES.notifier} + deploy its declared peer-set (timer, watcher)`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const install = installRuntimePackage({ runtime: 'notifier', env, runNpx: opts.runNpx })
|
|
68
|
+
if (install.state === 'failed' || install.state === 'no-package') {
|
|
69
|
+
// SOFT skip (design (а)): a missing prereq (bun) / unpublished package / no
|
|
70
|
+
// network must not fail the core's onboard — report and move on.
|
|
71
|
+
return {
|
|
72
|
+
state: 'skipped-unavailable',
|
|
73
|
+
peers: [],
|
|
74
|
+
detail: `${install.package ?? 'notifier package'} install failed (${install.detail?.split('\n')[0] ?? 'npx non-zero'}) — install later: iapeer install-runtime notifier`,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const deploy = await deployRuntime({ runtime: 'notifier', env, warn: opts.warn })
|
|
79
|
+
const broken = deploy.peers.some(p => p.bootstrap === 'failed' || p.selfConfig === 'failed')
|
|
80
|
+
return {
|
|
81
|
+
state: broken ? 'deploy-failed' : 'deployed',
|
|
82
|
+
peers: deploy.peers,
|
|
83
|
+
detail: broken ? 'a declared peer failed self-config/bootstrap — see the per-peer lines' : undefined,
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
// The package installed but its manifest/deploy contract broke — a REAL failure
|
|
87
|
+
// (not unavailability), surfaced as such.
|
|
88
|
+
return { state: 'deploy-failed', peers: [], detail: e instanceof Error ? e.message : String(e) }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
// (б) telegram — default-yes, install WITH setup. The human owes exactly two
|
|
94
|
+
// facts: the human-peer name and their telegram user_id (prompt hints at
|
|
95
|
+
// @userinfobot). The core creates the HUMAN peer via createPeer with
|
|
96
|
+
// TELEGRAM_USER_ID in env — the package's self-config hook (contract v0.10.0+)
|
|
97
|
+
// writes interfaces.telegram.user_id itself; no prepare / interface-human calls.
|
|
98
|
+
// Bot tokens are NOT asked here — they are per-channel (`iapeer connect telegram`).
|
|
99
|
+
//
|
|
100
|
+
// IDEMPOTENT re-onboard (boris's check, baked into the design): an EXISTING
|
|
101
|
+
// natural peer in the registry → the step is a no-op/skip with a clear message —
|
|
102
|
+
// NEVER an offer to create a second human peer.
|
|
103
|
+
//
|
|
104
|
+
// Interactive lives INSIDE the step (tty only); non-tty without the flags
|
|
105
|
+
// (--telegram-human + --telegram-user-id) → an explicit refusal of the STEP, not
|
|
106
|
+
// of the whole onboard.
|
|
107
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export interface TelegramStepOptions {
|
|
110
|
+
/** --no-telegram: remove the default-yes step. */
|
|
111
|
+
skip?: boolean
|
|
112
|
+
/** --telegram-human <p>: the human peer's personality (skips the prompt). */
|
|
113
|
+
human?: string
|
|
114
|
+
/** --telegram-user-id <id>: the owner's telegram user id (skips the prompt). */
|
|
115
|
+
userId?: string
|
|
116
|
+
dryRun?: boolean
|
|
117
|
+
env?: NodeJS.ProcessEnv
|
|
118
|
+
runNpx?: NpxRunner
|
|
119
|
+
/** Injectable prompt (tests). Default: readline on the live tty. */
|
|
120
|
+
ask?: (question: string) => Promise<string>
|
|
121
|
+
/** Override tty detection (tests). Default: stdin AND stdout are ttys. */
|
|
122
|
+
isTty?: boolean
|
|
123
|
+
warn?: (message: string) => void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface TelegramStepResult {
|
|
127
|
+
state:
|
|
128
|
+
| 'created' // human peer created; user_id delivered via the self-config hook
|
|
129
|
+
| 'already' // a natural peer already exists — idempotent skip (never a second human)
|
|
130
|
+
| 'skipped-flag' // --no-telegram
|
|
131
|
+
| 'skipped-unavailable' // package install failed — soft skip
|
|
132
|
+
| 'refused-non-tty' // no tty and no flags — the STEP refuses, onboard continues
|
|
133
|
+
| 'invalid-input' // explicit flags carried an invalid name / user id — hard
|
|
134
|
+
| 'create-failed' // createPeer threw — hard
|
|
135
|
+
| 'dry-run'
|
|
136
|
+
personality?: string
|
|
137
|
+
detail?: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function ttyAsk(question: string): Promise<string> {
|
|
141
|
+
const { createInterface } = await import('node:readline/promises')
|
|
142
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
143
|
+
try {
|
|
144
|
+
return (await rl.question(question)).trim()
|
|
145
|
+
} finally {
|
|
146
|
+
rl.close()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The registry's existing natural peer, if any (the idempotency key of the step). */
|
|
151
|
+
export function findNaturalPeer(env: NodeJS.ProcessEnv): string | null {
|
|
152
|
+
try {
|
|
153
|
+
const naturals = readPeersIndex({ env }).peers.filter(
|
|
154
|
+
p => normalizeIntelligenceValue(p.intelligence) === 'natural',
|
|
155
|
+
)
|
|
156
|
+
return naturals[0]?.personality ?? null
|
|
157
|
+
} catch {
|
|
158
|
+
return null // no registry yet → no natural peer
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function onboardTelegramStep(opts: TelegramStepOptions = {}): Promise<TelegramStepResult> {
|
|
163
|
+
const env = opts.env ?? process.env
|
|
164
|
+
if (opts.skip) return { state: 'skipped-flag' }
|
|
165
|
+
|
|
166
|
+
// Idempotency FIRST (before any install/questions): an existing natural peer
|
|
167
|
+
// means the host already has its human — the whole step is a no-op.
|
|
168
|
+
const existing = findNaturalPeer(env)
|
|
169
|
+
if (existing) {
|
|
170
|
+
return { state: 'already', personality: existing, detail: `human-пир "${existing}" уже есть — шаг пропущен` }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (opts.dryRun) {
|
|
174
|
+
return {
|
|
175
|
+
state: 'dry-run',
|
|
176
|
+
detail: `would npx-install ${RUNTIME_PACKAGES.telegram} + create the human peer (asks: name, telegram user_id)`,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const install = installRuntimePackage({ runtime: 'telegram', env, runNpx: opts.runNpx })
|
|
181
|
+
if (install.state === 'failed' || install.state === 'no-package') {
|
|
182
|
+
return {
|
|
183
|
+
state: 'skipped-unavailable',
|
|
184
|
+
detail: `${install.package ?? 'telegram package'} install failed (${install.detail?.split('\n')[0] ?? 'npx non-zero'}) — install later: iapeer install-runtime telegram`,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Resolve the two human-owed facts: flags → prompt (tty) → refuse (non-tty).
|
|
189
|
+
let human = opts.human?.trim()
|
|
190
|
+
let userId = opts.userId?.trim()
|
|
191
|
+
if (!human || !userId) {
|
|
192
|
+
const tty = opts.isTty ?? (process.stdin.isTTY === true && process.stdout.isTTY === true)
|
|
193
|
+
if (!tty) {
|
|
194
|
+
return {
|
|
195
|
+
state: 'refused-non-tty',
|
|
196
|
+
detail:
|
|
197
|
+
'no tty for the telegram questions — re-run with --telegram-human <name> --telegram-user-id <id>, ' +
|
|
198
|
+
'or skip with --no-telegram (add later: iapeer create <name> --runtime telegram)',
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const ask = opts.ask ?? ttyAsk
|
|
202
|
+
if (!human) human = (await ask('telegram step — your human-peer name (short, latin): ')).trim()
|
|
203
|
+
if (human && !userId) {
|
|
204
|
+
userId = (await ask('your telegram user_id (message @userinfobot — it replies with the id): ')).trim()
|
|
205
|
+
}
|
|
206
|
+
if (!human || !userId) {
|
|
207
|
+
// An empty answer on the live tty is a "not now" — soft refusal of the step.
|
|
208
|
+
return {
|
|
209
|
+
state: 'refused-non-tty',
|
|
210
|
+
detail: 'no answer — telegram step skipped (add later: iapeer create <name> --runtime telegram)',
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const personality = normalizeNameCandidate(human)
|
|
216
|
+
if (!isValidName(personality)) {
|
|
217
|
+
return { state: 'invalid-input', detail: `invalid human-peer name "${human}" — must normalize to /^[a-z][a-z0-9-]{0,31}$/` }
|
|
218
|
+
}
|
|
219
|
+
if (!/^\d+$/.test(userId)) {
|
|
220
|
+
return { state: 'invalid-input', detail: `invalid telegram user_id "${userId}" — expected digits (ask @userinfobot)` }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
// TELEGRAM_USER_ID rides the env into createPeer → initPeer → the package's
|
|
225
|
+
// self-config hook, which writes interfaces.telegram.user_id itself (owner's
|
|
226
|
+
// simplification, contract v0.10.0+ — no prepare / interface-human from the core).
|
|
227
|
+
const r = await createPeer({
|
|
228
|
+
personality,
|
|
229
|
+
runtime: 'telegram',
|
|
230
|
+
intelligence: 'natural',
|
|
231
|
+
env: { ...env, TELEGRAM_USER_ID: userId },
|
|
232
|
+
warn: opts.warn,
|
|
233
|
+
})
|
|
234
|
+
const sc = r.selfConfig?.state ?? 'absent'
|
|
235
|
+
if (sc === 'failed') {
|
|
236
|
+
return {
|
|
237
|
+
state: 'create-failed',
|
|
238
|
+
personality,
|
|
239
|
+
detail: `peer created but the telegram self-config hook failed: ${r.selfConfig?.detail ?? ''}`,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { state: 'created', personality, detail: `self-config ${sc}; bootstrap ${r.bootstrapped?.state ?? 'n/a'}` }
|
|
243
|
+
} catch (e) {
|
|
244
|
+
return { state: 'create-failed', personality, detail: e instanceof Error ? e.message : String(e) }
|
|
245
|
+
}
|
|
246
|
+
}
|