@agfpd/iapeer 0.1.0
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/bin/iapeer +25 -0
- package/package.json +37 -0
- package/src/cli/cli.test.ts +130 -0
- package/src/cli/index.ts +608 -0
- package/src/cli/listTui.test.ts +70 -0
- package/src/cli/listTui.ts +165 -0
- package/src/codec/codec.test.ts +271 -0
- package/src/codec/index.ts +217 -0
- package/src/core/constants.test.ts +21 -0
- package/src/core/constants.ts +180 -0
- package/src/core/errors.ts +20 -0
- package/src/core/index.ts +3 -0
- package/src/core/normalize.test.ts +98 -0
- package/src/core/normalize.ts +89 -0
- package/src/core/socket.ts +63 -0
- package/src/create/create.test.ts +143 -0
- package/src/create/index.ts +178 -0
- package/src/daemon/daemon-http.test.ts +114 -0
- package/src/daemon/daemon.test.ts +103 -0
- package/src/daemon/index.ts +439 -0
- package/src/daemon/main.test.ts +194 -0
- package/src/daemon/main.ts +230 -0
- package/src/enable/enable.test.ts +92 -0
- package/src/enable/index.ts +381 -0
- package/src/identity/identity.test.ts +262 -0
- package/src/identity/index.ts +603 -0
- package/src/index.ts +27 -0
- package/src/init/index.ts +408 -0
- package/src/init/init.test.ts +171 -0
- package/src/init/runtime-resolve.test.ts +49 -0
- package/src/install/index.ts +84 -0
- package/src/install/install.test.ts +31 -0
- package/src/launch/adapters/claude.ts +250 -0
- package/src/launch/adapters/codex.ts +329 -0
- package/src/launch/adapters/notifier.ts +90 -0
- package/src/launch/adapters/telegram.ts +130 -0
- package/src/launch/bootstrap.test.ts +56 -0
- package/src/launch/composeSystemPrompt.layers.test.ts +319 -0
- package/src/launch/composeSystemPrompt.test.ts +98 -0
- package/src/launch/composeSystemPrompt.ts +261 -0
- package/src/launch/index.ts +253 -0
- package/src/launch/launch.test.ts +233 -0
- package/src/launch/launchd.test.ts +363 -0
- package/src/launch/launchd.ts +375 -0
- package/src/launch/launchdRun.ts +168 -0
- package/src/launch/sockdir.test.ts +70 -0
- package/src/launch/types.ts +300 -0
- package/src/lifecycle/index.ts +840 -0
- package/src/lifecycle/lifecycle.test.ts +496 -0
- package/src/onboard/index.ts +135 -0
- package/src/onboard/onboard.test.ts +39 -0
- package/src/provision/index.ts +170 -0
- package/src/provision/provision.test.ts +104 -0
- package/src/registry/index.ts +453 -0
- package/src/registry/registry.test.ts +400 -0
- package/src/runtime/deploy.ts +230 -0
- package/src/runtime/index.ts +191 -0
- package/src/runtime/runtime.test.ts +226 -0
- package/src/storage/index.ts +331 -0
- package/src/storage/peers-home.test.ts +34 -0
- package/src/storage/storage.test.ts +65 -0
- package/src/transport/index.ts +522 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// runtime — the PACKAGE-FACING contract for an iapeer runtime package (telegram,
|
|
2
|
+
// notifier, …): the runtime MANIFEST a package declares, and the PER-PEER self-config
|
|
3
|
+
// hook the foundation invokes. This is the Волна-2 gate (contract Протокол iapeer
|
|
4
|
+
// рантайма / Стандарт iapeer плагина): a runtime package implements against THIS
|
|
5
|
+
// surface; the foundation orchestrates without knowing the package's internals.
|
|
6
|
+
//
|
|
7
|
+
// TWO PROVISION MODES, ONE HOOK (the key shape):
|
|
8
|
+
// (a) declared-set — a package whose peers are FIXED FUNCTIONS (notifier: timer +
|
|
9
|
+
// watcher) lists them in manifest.peers; `deployRuntime` provisions the whole
|
|
10
|
+
// declared set. Static.
|
|
11
|
+
// (b) operator-add — a package whose peers are PEOPLE the package can't know ahead
|
|
12
|
+
// (telegram: a human) declares NO peers; the operator adds one dynamically with
|
|
13
|
+
// `iapeer create maria --runtime telegram`. Dynamic.
|
|
14
|
+
// BOTH converge on the SAME per-peer self-config hook ("configure runtime state for
|
|
15
|
+
// peer X", idempotent). The hook is per-peer; only the enumeration/trigger differs.
|
|
16
|
+
// So manifest.peers is OPTIONAL (mode b omits it); manifest.selfConfig is the shared
|
|
17
|
+
// contract both modes call. (Mirror of the capability `setup` descriptor in enable.)
|
|
18
|
+
//
|
|
19
|
+
// The manifest lives at ~/.iapeer/runtimes/<runtime>/runtime.json — the runtime's own
|
|
20
|
+
// namespace (zone Хранение). The package writes it at npx-install (self-deploy); the
|
|
21
|
+
// foundation reads it at create / deploy.
|
|
22
|
+
|
|
23
|
+
import { spawnSync } from 'child_process'
|
|
24
|
+
import { existsSync, readFileSync } from 'fs'
|
|
25
|
+
import { join } from 'path'
|
|
26
|
+
import { isRuntime, type Intelligence, type Runtime } from '../core/constants.ts'
|
|
27
|
+
import { IapError } from '../core/errors.ts'
|
|
28
|
+
import { runtimeRoot, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
|
|
29
|
+
|
|
30
|
+
/** The runtime manifest filename inside ~/.iapeer/runtimes/<runtime>/. */
|
|
31
|
+
export const RUNTIME_MANIFEST_FILE = 'runtime.json'
|
|
32
|
+
|
|
33
|
+
/** A self-config hook descriptor: a bare command (PATH-resolvable or absolute), or
|
|
34
|
+
* {command, args}. Same shape as the capability `setup` descriptor (enable). */
|
|
35
|
+
export type SelfConfigDescriptor = string | { command: string; args?: string[] }
|
|
36
|
+
|
|
37
|
+
/** One declared peer in a package's FIXED set (mode a). personality is required; the
|
|
38
|
+
* rest default (intelligence → the runtime's zone default; cwd → ~/.iapeer/peers/<p>). */
|
|
39
|
+
export interface RuntimePeerDecl {
|
|
40
|
+
personality: string
|
|
41
|
+
intelligence?: Intelligence
|
|
42
|
+
description?: string
|
|
43
|
+
/** Explicit cwd; default ~/.iapeer/peers/<personality>. */
|
|
44
|
+
path?: string
|
|
45
|
+
/** Abs path / PATH name of the runtime launcher for THIS peer's plist (rarely
|
|
46
|
+
* needed — the default `<runtime>-runtime` on PATH resolves it). */
|
|
47
|
+
runtimeBin?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The package-declared runtime manifest (~/.iapeer/runtimes/<runtime>/runtime.json). */
|
|
51
|
+
export interface RuntimeManifest {
|
|
52
|
+
/** The runtime id this manifest describes (must match the folder it lives in). */
|
|
53
|
+
runtime: Runtime
|
|
54
|
+
/** OPTIONAL per-peer self-config hook (the shared contract both modes call). */
|
|
55
|
+
selfConfig?: SelfConfigDescriptor
|
|
56
|
+
/** OPTIONAL fixed peer-set (mode a). Omitted by an operator-add runtime (mode b). */
|
|
57
|
+
peers?: RuntimePeerDecl[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Path to a runtime's manifest: ~/.iapeer/runtimes/<runtime>/runtime.json. */
|
|
61
|
+
export function runtimeManifestPath(runtime: Runtime, options: StorageOptions = {}): string {
|
|
62
|
+
return join(runtimeRoot(runtime, options), RUNTIME_MANIFEST_FILE)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
// Read / write the manifest
|
|
67
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function normalizeManifest(raw: unknown, runtime: Runtime): RuntimeManifest {
|
|
70
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
71
|
+
throw new IapError(`runtime manifest for "${runtime}" is not a JSON object`)
|
|
72
|
+
}
|
|
73
|
+
const obj = raw as Record<string, unknown>
|
|
74
|
+
const declaredRuntime = typeof obj.runtime === 'string' ? obj.runtime : runtime
|
|
75
|
+
if (!isRuntime(declaredRuntime)) {
|
|
76
|
+
throw new IapError(`runtime manifest "runtime" is invalid: "${String(obj.runtime)}"`)
|
|
77
|
+
}
|
|
78
|
+
if (declaredRuntime !== runtime) {
|
|
79
|
+
throw new IapError(
|
|
80
|
+
`runtime manifest at runtimes/${runtime}/ declares runtime "${declaredRuntime}" — mismatch`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
let selfConfig: SelfConfigDescriptor | undefined
|
|
84
|
+
if (typeof obj.selfConfig === 'string' && obj.selfConfig.trim()) {
|
|
85
|
+
selfConfig = obj.selfConfig
|
|
86
|
+
} else if (
|
|
87
|
+
obj.selfConfig &&
|
|
88
|
+
typeof obj.selfConfig === 'object' &&
|
|
89
|
+
typeof (obj.selfConfig as { command?: unknown }).command === 'string'
|
|
90
|
+
) {
|
|
91
|
+
const sc = obj.selfConfig as { command: string; args?: unknown }
|
|
92
|
+
selfConfig = { command: sc.command, args: Array.isArray(sc.args) ? sc.args.map(String) : undefined }
|
|
93
|
+
}
|
|
94
|
+
let peers: RuntimePeerDecl[] | undefined
|
|
95
|
+
if (Array.isArray(obj.peers)) {
|
|
96
|
+
peers = obj.peers.flatMap(p => {
|
|
97
|
+
if (!p || typeof p !== 'object') return []
|
|
98
|
+
const o = p as Record<string, unknown>
|
|
99
|
+
if (typeof o.personality !== 'string' || !o.personality.trim()) return []
|
|
100
|
+
return [
|
|
101
|
+
{
|
|
102
|
+
personality: o.personality,
|
|
103
|
+
intelligence: typeof o.intelligence === 'string' ? (o.intelligence as Intelligence) : undefined,
|
|
104
|
+
description: typeof o.description === 'string' ? o.description : undefined,
|
|
105
|
+
path: typeof o.path === 'string' ? o.path : undefined,
|
|
106
|
+
runtimeBin: typeof o.runtimeBin === 'string' ? o.runtimeBin : undefined,
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
return { runtime: declaredRuntime, ...(selfConfig ? { selfConfig } : {}), ...(peers ? { peers } : {}) }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Read a runtime's manifest (~/.iapeer/runtimes/<runtime>/runtime.json), or null when
|
|
115
|
+
* absent (the package is not installed). Throws on a present-but-malformed manifest —
|
|
116
|
+
* it is the package's declared contract, a corruption should surface, not silently
|
|
117
|
+
* degrade an always-on deploy. */
|
|
118
|
+
export function readRuntimeManifest(runtime: Runtime, options: StorageOptions = {}): RuntimeManifest | null {
|
|
119
|
+
const path = runtimeManifestPath(runtime, options)
|
|
120
|
+
if (!existsSync(path)) return null
|
|
121
|
+
let raw: unknown
|
|
122
|
+
try {
|
|
123
|
+
raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
124
|
+
} catch (e) {
|
|
125
|
+
throw new IapError(`runtime manifest ${path} is invalid JSON: ${e instanceof Error ? e.message : String(e)}`)
|
|
126
|
+
}
|
|
127
|
+
return normalizeManifest(raw, runtime)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Write a runtime's manifest atomically (the helper a package uses at npx-install to
|
|
131
|
+
* self-deploy its declaration; also used by tests/foundation registration). */
|
|
132
|
+
export function writeRuntimeManifest(manifest: RuntimeManifest, options: StorageOptions = {}): string {
|
|
133
|
+
if (!isRuntime(manifest.runtime)) {
|
|
134
|
+
throw new IapError(`writeRuntimeManifest: invalid runtime "${manifest.runtime}"`)
|
|
135
|
+
}
|
|
136
|
+
const path = runtimeManifestPath(manifest.runtime, options)
|
|
137
|
+
writeFileAtomic(path, `${JSON.stringify(manifest, null, 2)}\n`, 0o644)
|
|
138
|
+
return path
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
// Per-peer self-config hook invocation (the shared contract both modes call)
|
|
143
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export type SelfConfigState =
|
|
146
|
+
| 'absent' // no manifest / no selfConfig declared → nothing to run (no-op)
|
|
147
|
+
| 'configured' // the hook ran and exited 0
|
|
148
|
+
| 'failed' // the hook ran and exited non-zero (or could not be spawned)
|
|
149
|
+
|
|
150
|
+
export interface SelfConfigResult {
|
|
151
|
+
state: SelfConfigState
|
|
152
|
+
detail?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface SelfConfigPeer {
|
|
156
|
+
personality: string
|
|
157
|
+
cwd: string
|
|
158
|
+
runtime: Runtime
|
|
159
|
+
intelligence: Intelligence
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Invoke a runtime package's PER-PEER self-config hook (the shared contract for both
|
|
164
|
+
* provision modes). Looks up the runtime's manifest; if it declares `selfConfig`, runs
|
|
165
|
+
* it with cwd = the peer cwd and the peer context in NAMESPACED env (IAPEER_PEER_* —
|
|
166
|
+
* NOT the bare PEER_* the identity gate keys on, same lesson as enable's setup). The
|
|
167
|
+
* package's hook is expected to be IDEMPOTENT ("ensure runtime state for this peer").
|
|
168
|
+
* No manifest / no hook → `absent` (a no-op — e.g. an agentic runtime, or a runtime
|
|
169
|
+
* package not installed). A non-zero exit → `failed` (the caller decides fail-closed).
|
|
170
|
+
*/
|
|
171
|
+
export function runtimeSelfConfig(peer: SelfConfigPeer, options: StorageOptions = {}): SelfConfigResult {
|
|
172
|
+
const env = options.env ?? process.env
|
|
173
|
+
const manifest = readRuntimeManifest(peer.runtime, { env })
|
|
174
|
+
const descriptor = manifest?.selfConfig
|
|
175
|
+
if (!descriptor) return { state: 'absent' }
|
|
176
|
+
|
|
177
|
+
const [command, ...preArgs] =
|
|
178
|
+
typeof descriptor === 'string' ? [descriptor] : [descriptor.command, ...(descriptor.args ?? [])]
|
|
179
|
+
const hookEnv: NodeJS.ProcessEnv = {
|
|
180
|
+
...env,
|
|
181
|
+
IAPEER_PEER_PERSONALITY: peer.personality,
|
|
182
|
+
IAPEER_PEER_CWD: peer.cwd,
|
|
183
|
+
IAPEER_PEER_RUNTIME: peer.runtime,
|
|
184
|
+
IAPEER_PEER_INTELLIGENCE: peer.intelligence,
|
|
185
|
+
}
|
|
186
|
+
const r = spawnSync(command, preArgs, { cwd: peer.cwd, encoding: 'utf8', env: hookEnv as Record<string, string> })
|
|
187
|
+
if (r.error || (r.status ?? 1) !== 0) {
|
|
188
|
+
return { state: 'failed', detail: (r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim() }
|
|
189
|
+
}
|
|
190
|
+
return { state: 'configured' }
|
|
191
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// runtime contract — manifest read/write, the per-peer self-config hook (env
|
|
2
|
+
// passthrough + fail states), and deployRuntime (declared-set provisioning). All under
|
|
3
|
+
// IAPEER_ROOT / IAPEER_LAUNCHAGENTS_DIR temp dirs; IAPEER_TEST_SANDBOX skips the real
|
|
4
|
+
// launchctl, so deploy provisions (folder + registry + plist) without loading a job.
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
7
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import { readRuntimeManifest, runtimeSelfConfig, writeRuntimeManifest, type RuntimeManifest } from './index.ts'
|
|
11
|
+
import {
|
|
12
|
+
deployRuntime,
|
|
13
|
+
installRuntimePackage,
|
|
14
|
+
onboardRuntime,
|
|
15
|
+
resolveRuntimePackage,
|
|
16
|
+
RUNTIME_PACKAGES,
|
|
17
|
+
} from './deploy.ts'
|
|
18
|
+
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
19
|
+
import { launchdPlistPath } from '../launch/index.ts'
|
|
20
|
+
|
|
21
|
+
const roots: string[] = []
|
|
22
|
+
function mkTmp(): string {
|
|
23
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-rtm-'))
|
|
24
|
+
roots.push(d)
|
|
25
|
+
return d
|
|
26
|
+
}
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function envFor(root: string, path?: string): NodeJS.ProcessEnv {
|
|
32
|
+
return {
|
|
33
|
+
IAPEER_ROOT: join(root, 'iapeer'),
|
|
34
|
+
IAPEER_LAUNCHAGENTS_DIR: join(root, 'LA'),
|
|
35
|
+
IAPEER_TEST_SANDBOX: '1',
|
|
36
|
+
HOME: root,
|
|
37
|
+
...(path ? { PATH: path } : {}),
|
|
38
|
+
} as NodeJS.ProcessEnv
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A stub runtime launcher (so provision's resolveExecutable succeeds) + a stub
|
|
42
|
+
* self-config hook that records the per-peer env it was handed. */
|
|
43
|
+
function stubBins(): { dir: string; launcher: string; hook: string; marker: (p: string) => string } {
|
|
44
|
+
const dir = mkTmp()
|
|
45
|
+
const launcher = join(dir, 'notifier-runtime')
|
|
46
|
+
writeFileSync(launcher, '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
|
|
47
|
+
const hook = join(dir, 'self-config.sh')
|
|
48
|
+
// record IAPEER_PEER_* to a marker file named by personality → proves env passthrough
|
|
49
|
+
writeFileSync(
|
|
50
|
+
hook,
|
|
51
|
+
'#!/bin/sh\nprintf "%s|%s|%s" "$IAPEER_PEER_PERSONALITY" "$IAPEER_PEER_RUNTIME" "$IAPEER_PEER_INTELLIGENCE" > "$IAPEER_ROOT/sc-$IAPEER_PEER_PERSONALITY"\nexit 0\n',
|
|
52
|
+
{ mode: 0o755 },
|
|
53
|
+
)
|
|
54
|
+
return { dir, launcher, hook, marker: p => join(dir, p) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('runtime manifest', () => {
|
|
58
|
+
test('write → read round-trip preserves runtime / selfConfig / peers', () => {
|
|
59
|
+
const env = envFor(mkTmp())
|
|
60
|
+
writeRuntimeManifest(
|
|
61
|
+
{ runtime: 'notifier', selfConfig: { command: 'notifier-runtime', args: ['self-config'] }, peers: [{ personality: 'timer', intelligence: 'absent' }] },
|
|
62
|
+
{ env },
|
|
63
|
+
)
|
|
64
|
+
const m = readRuntimeManifest('notifier', { env })!
|
|
65
|
+
expect(m.runtime).toBe('notifier')
|
|
66
|
+
expect(m.selfConfig).toEqual({ command: 'notifier-runtime', args: ['self-config'] })
|
|
67
|
+
expect(m.peers?.[0].personality).toBe('timer')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('absent manifest → null; runtime mismatch → throws', () => {
|
|
71
|
+
const env = envFor(mkTmp())
|
|
72
|
+
expect(readRuntimeManifest('notifier', { env })).toBeNull()
|
|
73
|
+
// hand-write a manifest whose runtime field disagrees with its folder
|
|
74
|
+
const path = join(env.IAPEER_ROOT as string, 'runtimes', 'notifier')
|
|
75
|
+
writeFileSync(join(mkTmp(), 'ignore'), '') // keep tmp tracking happy
|
|
76
|
+
rmSync(path, { recursive: true, force: true })
|
|
77
|
+
writeRuntimeManifest({ runtime: 'notifier' }, { env })
|
|
78
|
+
// corrupt the on-disk runtime field
|
|
79
|
+
const file = join(path, 'runtime.json')
|
|
80
|
+
writeFileSync(file, JSON.stringify({ runtime: 'telegram' }))
|
|
81
|
+
expect(() => readRuntimeManifest('notifier', { env })).toThrow(/mismatch/)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('runtimeSelfConfig (per-peer hook)', () => {
|
|
86
|
+
test('absent when no manifest declares a hook', () => {
|
|
87
|
+
const env = envFor(mkTmp())
|
|
88
|
+
const r = runtimeSelfConfig({ personality: 'timer', cwd: mkTmp(), runtime: 'notifier', intelligence: 'absent' }, { env })
|
|
89
|
+
expect(r.state).toBe('absent')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('configured: hook runs with IAPEER_PEER_* env (NOT bare PEER_*)', () => {
|
|
93
|
+
const root = mkTmp()
|
|
94
|
+
const env = envFor(root)
|
|
95
|
+
const { hook } = stubBins()
|
|
96
|
+
writeRuntimeManifest({ runtime: 'notifier', selfConfig: hook }, { env })
|
|
97
|
+
const cwd = mkTmp()
|
|
98
|
+
const r = runtimeSelfConfig({ personality: 'timer', cwd, runtime: 'notifier', intelligence: 'absent' }, { env })
|
|
99
|
+
expect(r.state).toBe('configured')
|
|
100
|
+
// the hook wrote the per-peer env it received
|
|
101
|
+
const recorded = readFileSync(join(env.IAPEER_ROOT as string, 'sc-timer'), 'utf8')
|
|
102
|
+
expect(recorded).toBe('timer|notifier|absent')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('failed: a non-zero hook is reported (caller decides fail-closed)', () => {
|
|
106
|
+
const root = mkTmp()
|
|
107
|
+
const env = envFor(root)
|
|
108
|
+
const dir = mkTmp()
|
|
109
|
+
const hook = join(dir, 'bad.sh')
|
|
110
|
+
writeFileSync(hook, '#!/bin/sh\necho boom >&2\nexit 3\n', { mode: 0o755 })
|
|
111
|
+
writeRuntimeManifest({ runtime: 'notifier', selfConfig: hook }, { env })
|
|
112
|
+
const r = runtimeSelfConfig({ personality: 'timer', cwd: mkTmp(), runtime: 'notifier', intelligence: 'absent' }, { env })
|
|
113
|
+
expect(r.state).toBe('failed')
|
|
114
|
+
expect(r.detail).toContain('boom')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('deployRuntime (declared-set, mode a)', () => {
|
|
119
|
+
test('provisions the whole declared peer-set (folder + registry + plist + self-config)', async () => {
|
|
120
|
+
const root = mkTmp()
|
|
121
|
+
const { dir: bindir, launcher, hook } = stubBins()
|
|
122
|
+
const env = envFor(root, bindir)
|
|
123
|
+
writeRuntimeManifest(
|
|
124
|
+
{
|
|
125
|
+
runtime: 'notifier',
|
|
126
|
+
selfConfig: hook,
|
|
127
|
+
peers: [
|
|
128
|
+
{ personality: 'timer', intelligence: 'absent' },
|
|
129
|
+
{ personality: 'watcher', intelligence: 'absent' },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
{ env },
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
void launcher // resolved by provision via env.PATH (bindir holds notifier-runtime)
|
|
136
|
+
const r = await deployRuntime({ runtime: 'notifier', env })
|
|
137
|
+
expect(r.operatorAddOnly).toBe(false)
|
|
138
|
+
expect(r.peers.map(p => p.personality).sort()).toEqual(['timer', 'watcher'])
|
|
139
|
+
for (const p of r.peers) {
|
|
140
|
+
expect(p.selfConfig).toBe('configured')
|
|
141
|
+
expect(p.bootstrap).toBe('skipped-sandbox') // sandbox: not loaded
|
|
142
|
+
expect(findPeer(readPeersIndex({ env }), p.personality)).not.toBeNull()
|
|
143
|
+
expect(existsSync(launchdPlistPath(p.personality, env))).toBe(true)
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('a runtime with no declared peers → operatorAddOnly (telegram = mode b)', async () => {
|
|
148
|
+
const root = mkTmp()
|
|
149
|
+
const env = envFor(root)
|
|
150
|
+
writeRuntimeManifest({ runtime: 'telegram', selfConfig: 'telegram-runtime self-config' }, { env })
|
|
151
|
+
const r = await deployRuntime({ runtime: 'telegram', env })
|
|
152
|
+
expect(r.operatorAddOnly).toBe(true)
|
|
153
|
+
expect(r.peers.length).toBe(0)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('§6 — package registry + npx install + onboardRuntime', () => {
|
|
158
|
+
test('resolveRuntimePackage: built-in registry, --package override', () => {
|
|
159
|
+
expect(resolveRuntimePackage('telegram')).toBe(RUNTIME_PACKAGES.telegram)
|
|
160
|
+
expect(resolveRuntimePackage('notifier')).toBe('@agfpd/notifier-runtime')
|
|
161
|
+
expect(resolveRuntimePackage('telegram', '@me/fork')).toBe('@me/fork') // override wins
|
|
162
|
+
expect(resolveRuntimePackage('webhook')).toBeUndefined() // no mapping
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('installRuntimePackage: skipped when manifest present (idempotent)', () => {
|
|
166
|
+
const env = envFor(mkTmp())
|
|
167
|
+
writeRuntimeManifest({ runtime: 'notifier' }, { env })
|
|
168
|
+
let called = false
|
|
169
|
+
const r = installRuntimePackage({ runtime: 'notifier', env, runNpx: () => ((called = true), { ok: true }) })
|
|
170
|
+
expect(r.state).toBe('skipped')
|
|
171
|
+
expect(called).toBe(false) // never ran npx — package already installed
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('installRuntimePackage: ran (npx invoked) when no manifest', () => {
|
|
175
|
+
const env = envFor(mkTmp())
|
|
176
|
+
let gotPkg = ''
|
|
177
|
+
const r = installRuntimePackage({
|
|
178
|
+
runtime: 'notifier',
|
|
179
|
+
env,
|
|
180
|
+
runNpx: pkg => ((gotPkg = pkg), { ok: true }),
|
|
181
|
+
})
|
|
182
|
+
expect(r.state).toBe('ran')
|
|
183
|
+
expect(gotPkg).toBe('@agfpd/notifier-runtime') // resolved from the registry
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('installRuntimePackage: no-package when no mapping + no --package + no manifest', () => {
|
|
187
|
+
const env = envFor(mkTmp())
|
|
188
|
+
const r = installRuntimePackage({ runtime: 'webhook', env, runNpx: () => ({ ok: true }) })
|
|
189
|
+
expect(r.state).toBe('no-package')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('onboardRuntime: npx (self-deploy manifest) → deploy declared set', async () => {
|
|
193
|
+
const root = mkTmp()
|
|
194
|
+
const bindir = mkTmp()
|
|
195
|
+
writeFileSync(join(bindir, 'notifier-runtime'), '#!/bin/sh\nexec sleep 1\n', { mode: 0o755 })
|
|
196
|
+
const hook = join(bindir, 'sc.sh')
|
|
197
|
+
writeFileSync(hook, '#!/bin/sh\nexit 0\n', { mode: 0o755 })
|
|
198
|
+
const env = envFor(root, bindir)
|
|
199
|
+
|
|
200
|
+
// simulate the package self-deploying its manifest on npx (bin pre-staged on PATH)
|
|
201
|
+
const runNpx = (_pkg: string, e: NodeJS.ProcessEnv) => {
|
|
202
|
+
const m: RuntimeManifest = {
|
|
203
|
+
runtime: 'notifier',
|
|
204
|
+
selfConfig: hook,
|
|
205
|
+
peers: [{ personality: 'sbxt', intelligence: 'absent' }],
|
|
206
|
+
}
|
|
207
|
+
writeRuntimeManifest(m, { env: e })
|
|
208
|
+
return { ok: true }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const r = await onboardRuntime({ runtime: 'notifier', env, runNpx })
|
|
212
|
+
expect(r.install.state).toBe('ran')
|
|
213
|
+
expect(r.deploy?.peers.map(p => p.personality)).toEqual(['sbxt'])
|
|
214
|
+
expect(r.deploy?.peers[0].selfConfig).toBe('configured')
|
|
215
|
+
expect(r.deploy?.peers[0].bootstrap).toBe('skipped-sandbox')
|
|
216
|
+
expect(findPeer(readPeersIndex({ env }), 'sbxt')).not.toBeNull()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('onboardRuntime: failed npx aborts before deploy (fail-closed)', async () => {
|
|
220
|
+
const env = envFor(mkTmp())
|
|
221
|
+
await expect(
|
|
222
|
+
onboardRuntime({ runtime: 'notifier', env, runNpx: () => ({ ok: false, detail: 'network' }) }),
|
|
223
|
+
).rejects.toThrow(/npx install.*failed/)
|
|
224
|
+
expect(findPeer(readPeersIndex({ env }), 'sbxt')).toBeNull()
|
|
225
|
+
})
|
|
226
|
+
})
|