@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,381 @@
|
|
|
1
|
+
// enable — per-peer capability install (contract Установка §3 + Per-рантайм
|
|
2
|
+
// install-асимметрия; verb `enable <capability> [peer]`). The init→enable
|
|
3
|
+
// capability story: a peer gains a capability (peer-voice / MergeMind) FROM our
|
|
4
|
+
// marketplace. iapeer ORCHESTRATES — install + enable + call `setup` ONLY if the
|
|
5
|
+
// plugin declares it — and deliberately does NOT resolve the plugin's dep-graph
|
|
6
|
+
// (`requires`/`setup` internals are the plugin's job, → Стандарт iapeer плагина).
|
|
7
|
+
//
|
|
8
|
+
// Per-рантайм install-асимметрия (contract table):
|
|
9
|
+
// claude → `plugin install <p>@agfpd --scope project` run IN the peer's cwd; the
|
|
10
|
+
// project-scope entry is keyed by projectPath, so it is ISOLATED per peer
|
|
11
|
+
// (a test-peer install never touches a live peer's entry). enable = that
|
|
12
|
+
// install (project-scope is the cold-start MCP enabler) + plugin enable.
|
|
13
|
+
// codex → `plugin add <p>@agfpd` GLOBAL (host-wide, once); enable is per-peer via
|
|
14
|
+
// config. codex has no project scope (a global mutation), so it is NOT run
|
|
15
|
+
// against the real host under fleet-guard without an isolated CODEX_HOME.
|
|
16
|
+
//
|
|
17
|
+
// SIMPLE vs СЛОЖНЫЙ plugin (contract): a plugin MAY ship `iapeer.json` at its root
|
|
18
|
+
// declaring `setup` (a multi-step installer) and `requires`. SIMPLE (peer-voice — no
|
|
19
|
+
// iapeer.json/no setup): install + enable → works. СЛОЖНЫЙ (MergeMind): install +
|
|
20
|
+
// enable → call `setup`. iapeer reads the manifest and calls setup ONLY if declared.
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'child_process'
|
|
23
|
+
import { existsSync, readFileSync, realpathSync } from 'fs'
|
|
24
|
+
import { basename, join } from 'path'
|
|
25
|
+
import { homedir } from 'os'
|
|
26
|
+
import { MARKETPLACE_NAME } from '../onboard/index.ts'
|
|
27
|
+
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
28
|
+
import { IapError } from '../core/errors.ts'
|
|
29
|
+
import { normalizeNameCandidate } from '../core/normalize.ts'
|
|
30
|
+
|
|
31
|
+
export type CapabilityRuntime = 'claude' | 'codex'
|
|
32
|
+
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
// Installed-plugins parse + per-peer match (PURE — the fleet-guard hinges on it)
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** One entry of `claude plugin list --json`. Project-scope entries carry projectPath
|
|
38
|
+
* (the per-peer key); user/local scope do not. */
|
|
39
|
+
export interface InstalledEntry {
|
|
40
|
+
id: string // `<plugin>@<marketplace>`
|
|
41
|
+
scope: string // 'project' | 'user' | 'local'
|
|
42
|
+
enabled: boolean
|
|
43
|
+
projectPath?: string
|
|
44
|
+
installPath?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse `claude plugin list --json` (a flat array) into typed entries; tolerant of
|
|
48
|
+
* unknown extra fields and non-array payloads (→ []). */
|
|
49
|
+
export function parseInstalledPlugins(json: string): InstalledEntry[] {
|
|
50
|
+
let data: unknown
|
|
51
|
+
try {
|
|
52
|
+
data = JSON.parse(json)
|
|
53
|
+
} catch {
|
|
54
|
+
return []
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(data)) return []
|
|
57
|
+
return data.flatMap(raw => {
|
|
58
|
+
if (!raw || typeof raw !== 'object') return []
|
|
59
|
+
const o = raw as Record<string, unknown>
|
|
60
|
+
if (typeof o.id !== 'string') return []
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
id: o.id,
|
|
64
|
+
scope: typeof o.scope === 'string' ? o.scope : 'unknown',
|
|
65
|
+
enabled: o.enabled === true,
|
|
66
|
+
projectPath: typeof o.projectPath === 'string' ? o.projectPath : undefined,
|
|
67
|
+
installPath: typeof o.installPath === 'string' ? o.installPath : undefined,
|
|
68
|
+
},
|
|
69
|
+
]
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** realpath-normalize a path for comparison (/tmp→/private/tmp on macOS); falls back
|
|
74
|
+
* to the raw path when it does not resolve. Mirrors the legacy install-gate match. */
|
|
75
|
+
function canonPath(p: string): string {
|
|
76
|
+
try {
|
|
77
|
+
return realpathSync(p)
|
|
78
|
+
} catch {
|
|
79
|
+
return p
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Find the project-scope entry for `<plugin>@agfpd` installed for THIS peer cwd
|
|
85
|
+
* (realpath-matched projectPath). Returns the entry (so the caller sees enabled +
|
|
86
|
+
* installPath) or null. This is what makes enable idempotent AND fleet-safe: it keys
|
|
87
|
+
* on the peer's OWN projectPath, never another peer's.
|
|
88
|
+
*/
|
|
89
|
+
export function findPeerScopedEntry(
|
|
90
|
+
entries: InstalledEntry[],
|
|
91
|
+
plugin: string,
|
|
92
|
+
marketplace: string,
|
|
93
|
+
peerCwd: string,
|
|
94
|
+
): InstalledEntry | null {
|
|
95
|
+
const id = `${plugin}@${marketplace}`
|
|
96
|
+
const want = canonPath(peerCwd)
|
|
97
|
+
return (
|
|
98
|
+
entries.find(
|
|
99
|
+
e => e.id === id && e.scope === 'project' && e.projectPath !== undefined && canonPath(e.projectPath) === want,
|
|
100
|
+
) ?? null
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
// Manifest — setup detection (PURE)
|
|
106
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** The optional `setup` descriptor in a plugin's `iapeer.json`: a bare command/script
|
|
109
|
+
* string (resolved relative to the plugin root), or {command,args}. */
|
|
110
|
+
export type SetupDescriptor = string | { command: string; args?: string[] }
|
|
111
|
+
export interface IapeerManifest {
|
|
112
|
+
setup?: SetupDescriptor
|
|
113
|
+
requires?: unknown
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read `<installPath>/iapeer.json` and return its `setup` descriptor, or null when
|
|
118
|
+
* the plugin is SIMPLE (no manifest, no `setup`). A malformed manifest is treated as
|
|
119
|
+
* SIMPLE (no setup) rather than failing enable — the manifest is the plugin's contract,
|
|
120
|
+
* not iapeer's; install+enable already succeeded.
|
|
121
|
+
*/
|
|
122
|
+
export function readSetupDescriptor(installPath: string | undefined): SetupDescriptor | null {
|
|
123
|
+
if (!installPath) return null
|
|
124
|
+
const manifestPath = join(installPath, 'iapeer.json')
|
|
125
|
+
if (!existsSync(manifestPath)) return null
|
|
126
|
+
try {
|
|
127
|
+
const m = JSON.parse(readFileSync(manifestPath, 'utf8')) as IapeerManifest
|
|
128
|
+
if (typeof m.setup === 'string' && m.setup.trim()) return m.setup
|
|
129
|
+
if (m.setup && typeof m.setup === 'object' && typeof m.setup.command === 'string' && m.setup.command.trim()) {
|
|
130
|
+
return { command: m.setup.command, args: Array.isArray(m.setup.args) ? m.setup.args.map(String) : undefined }
|
|
131
|
+
}
|
|
132
|
+
return null
|
|
133
|
+
} catch {
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
// Orchestrator
|
|
140
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
export type RuntimeEnableState =
|
|
143
|
+
| 'already-enabled' // present + enabled for this peer → no-op (idempotent, fleet-safe)
|
|
144
|
+
| 'installed' // installed (and enabled) now
|
|
145
|
+
| 'enabled' // was installed-but-disabled → enabled now
|
|
146
|
+
| 'runtime-missing' // the runtime binary is not on the host
|
|
147
|
+
| 'failed' // install/enable command failed
|
|
148
|
+
|
|
149
|
+
export type SetupState = 'absent' | 'called' | 'failed' | 'skipped'
|
|
150
|
+
|
|
151
|
+
export interface RuntimeEnableResult {
|
|
152
|
+
runtime: CapabilityRuntime
|
|
153
|
+
state: RuntimeEnableState
|
|
154
|
+
installPath?: string
|
|
155
|
+
detail?: string
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface EnableResult {
|
|
159
|
+
plugin: string
|
|
160
|
+
personality: string
|
|
161
|
+
cwd: string
|
|
162
|
+
runtimes: RuntimeEnableResult[]
|
|
163
|
+
setup: SetupState
|
|
164
|
+
setupDetail?: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface EnableOptions {
|
|
168
|
+
plugin: string
|
|
169
|
+
/** Target peer by personality; default = the peer of the current cwd. */
|
|
170
|
+
peer?: string
|
|
171
|
+
/** Restrict to these runtimes (default: the peer's agentic runtimes). */
|
|
172
|
+
runtimes?: CapabilityRuntime[]
|
|
173
|
+
/** Skip the plugin's `setup` even if declared (install+enable only). */
|
|
174
|
+
noSetup?: boolean
|
|
175
|
+
env?: NodeJS.ProcessEnv
|
|
176
|
+
cwd?: string
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function claudeBin(env: NodeJS.ProcessEnv): string {
|
|
180
|
+
return env.IAPEER_CLAUDE_BIN?.trim() || join(env.HOME?.trim() || homedir(), '.local', 'bin', 'claude')
|
|
181
|
+
}
|
|
182
|
+
function codexBin(env: NodeJS.ProcessEnv): string {
|
|
183
|
+
return env.IAPEER_CODEX_BIN?.trim() || 'codex'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Resolve the target peer (cwd + agentic runtimes) from a personality arg or the cwd.
|
|
187
|
+
* Without an explicit peer, match the registry by THIS cwd first (robust to a
|
|
188
|
+
* uniqueness-suffixed personality), falling back to normalize(basename(cwd)). */
|
|
189
|
+
function resolvePeer(opts: EnableOptions): { personality: string; cwd: string; runtimes: CapabilityRuntime[] } {
|
|
190
|
+
const env = opts.env ?? process.env
|
|
191
|
+
const index = readPeersIndex({ env })
|
|
192
|
+
const cwd = opts.cwd ?? process.cwd()
|
|
193
|
+
let rec = opts.peer
|
|
194
|
+
? findPeer(index, opts.peer)
|
|
195
|
+
: index.peers.find(p => canonPath(p.cwd) === canonPath(cwd)) ?? findPeer(index, normalizeNameCandidate(basename(cwd)))
|
|
196
|
+
const personality = opts.peer ?? rec?.personality ?? normalizeNameCandidate(basename(cwd))
|
|
197
|
+
if (!rec) {
|
|
198
|
+
throw new IapError(`peer "${personality}" is not registered — run \`iapeer init\` in its folder first`)
|
|
199
|
+
}
|
|
200
|
+
const agentic = rec.runtimes.filter((r): r is CapabilityRuntime => r === 'claude' || r === 'codex')
|
|
201
|
+
if (agentic.length === 0) {
|
|
202
|
+
throw new IapError(`peer "${personality}" has no agentic runtime (claude/codex) — capability plugins need one`)
|
|
203
|
+
}
|
|
204
|
+
return { personality, cwd: rec.cwd, runtimes: agentic }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isExecutable(bin: string, env: NodeJS.ProcessEnv): boolean {
|
|
208
|
+
const r = spawnSync(bin, ['--version'], { stdio: 'ignore', env: env as Record<string, string> })
|
|
209
|
+
return r.error === undefined && r.status !== null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Read the peer-scoped entry for this plugin from a fresh `claude plugin list --json`.
|
|
213
|
+
* CRITICAL: `enabled` is evaluated relative to the CURRENT cwd's project (verified
|
|
214
|
+
* live — the same entry lists enabled=false from another dir, true from its own), so
|
|
215
|
+
* the list MUST run with cwd = the peer cwd to read the authoritative enabled-state.
|
|
216
|
+
* maxBuffer is raised — a configured host's plugin list exceeds the 1 MB default. */
|
|
217
|
+
function claudeEntry(bin: string, plugin: string, peerCwd: string, env: NodeJS.ProcessEnv): InstalledEntry | null {
|
|
218
|
+
const list = spawnSync(bin, ['plugin', 'list', '--json'], {
|
|
219
|
+
cwd: peerCwd,
|
|
220
|
+
encoding: 'utf8',
|
|
221
|
+
env: env as Record<string, string>,
|
|
222
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
223
|
+
})
|
|
224
|
+
return findPeerScopedEntry(parseInstalledPlugins(list.stdout ?? ''), plugin, MARKETPLACE_NAME, peerCwd)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* claude: install `<plugin>@agfpd` project-scope IN the peer cwd. A project-scope
|
|
229
|
+
* install also ENABLES it for that project (verified live: enabled=true when listed
|
|
230
|
+
* from the peer cwd). The explicit `plugin enable` runs ONLY for a pre-existing entry
|
|
231
|
+
* that lists disabled — never right after a fresh install (that errors "already
|
|
232
|
+
* enabled"). Idempotent + fleet-safe: the entry is keyed by THIS peer's projectPath
|
|
233
|
+
* (realpath), so it never reads or mutates another peer's install.
|
|
234
|
+
*/
|
|
235
|
+
function enableClaude(plugin: string, peerCwd: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
|
|
236
|
+
const bin = claudeBin(env)
|
|
237
|
+
if (!isExecutable(bin, env)) return { runtime: 'claude', state: 'runtime-missing' }
|
|
238
|
+
const id = `${plugin}@${MARKETPLACE_NAME}`
|
|
239
|
+
const existing = claudeEntry(bin, plugin, peerCwd, env)
|
|
240
|
+
if (existing?.enabled) return { runtime: 'claude', state: 'already-enabled', installPath: existing.installPath }
|
|
241
|
+
|
|
242
|
+
if (!existing) {
|
|
243
|
+
const inst = spawnSync(bin, ['plugin', 'install', id, '--scope', 'project'], {
|
|
244
|
+
cwd: peerCwd,
|
|
245
|
+
encoding: 'utf8',
|
|
246
|
+
env: env as Record<string, string>,
|
|
247
|
+
})
|
|
248
|
+
if (inst.status !== 0) {
|
|
249
|
+
return { runtime: 'claude', state: 'failed', detail: (inst.stderr || inst.stdout || `exit ${inst.status}`).trim() }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// the entry now exists but is DISABLED (fresh install) or was pre-existing-disabled →
|
|
253
|
+
// enable it (and ONLY then; an already-enabled entry would error).
|
|
254
|
+
const after = claudeEntry(bin, plugin, peerCwd, env)
|
|
255
|
+
if (after && !after.enabled) {
|
|
256
|
+
const en = spawnSync(bin, ['plugin', 'enable', id, '--scope', 'project'], {
|
|
257
|
+
cwd: peerCwd,
|
|
258
|
+
encoding: 'utf8',
|
|
259
|
+
env: env as Record<string, string>,
|
|
260
|
+
})
|
|
261
|
+
const confirmed = claudeEntry(bin, plugin, peerCwd, env)
|
|
262
|
+
if (!confirmed?.enabled) {
|
|
263
|
+
return { runtime: 'claude', state: 'failed', detail: (en.stderr || en.stdout || `exit ${en.status}`).trim() }
|
|
264
|
+
}
|
|
265
|
+
return { runtime: 'claude', state: existing ? 'enabled' : 'installed', installPath: confirmed.installPath }
|
|
266
|
+
}
|
|
267
|
+
if (!after) return { runtime: 'claude', state: 'failed', detail: 'install reported success but no project-scope entry appeared' }
|
|
268
|
+
return { runtime: 'claude', state: existing ? 'enabled' : 'installed', installPath: after.installPath }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Parse a `codex plugin list` STATUS column for one plugin id. The table is
|
|
272
|
+
* whitespace-aligned: `<id> <status> <version> <path>`; status is `not installed`
|
|
273
|
+
* / `installed, enabled` / `installed, disabled`. PURE → unit-testable. */
|
|
274
|
+
export function parseCodexPluginStatus(listOutput: string, id: string): 'enabled' | 'disabled' | 'absent' {
|
|
275
|
+
for (const line of listOutput.split('\n')) {
|
|
276
|
+
const cols = line.split(/\s{2,}/).map(c => c.trim())
|
|
277
|
+
if (cols[0] !== id) continue
|
|
278
|
+
const status = (cols[1] ?? '').toLowerCase()
|
|
279
|
+
if (status.includes('not installed')) return 'absent'
|
|
280
|
+
if (status.includes('enabled')) return 'enabled'
|
|
281
|
+
if (status.includes('installed')) return 'disabled'
|
|
282
|
+
return 'absent'
|
|
283
|
+
}
|
|
284
|
+
return 'absent'
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function codexState(bin: string, id: string, env: NodeJS.ProcessEnv): 'enabled' | 'disabled' | 'absent' {
|
|
288
|
+
const r = spawnSync(bin, ['plugin', 'list'], { encoding: 'utf8', env: env as Record<string, string>, maxBuffer: 64 * 1024 * 1024 })
|
|
289
|
+
return parseCodexPluginStatus(r.stdout ?? '', id)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** codex: add `<plugin>@agfpd` GLOBAL (host-wide; codex has no project scope). The add
|
|
293
|
+
* ENABLES (verified live: config.toml `[plugins."<id>"] enabled = true`) and is
|
|
294
|
+
* idempotent (re-add exits 0). Idempotency: skip when already enabled. installPath is
|
|
295
|
+
* parsed from the add output so setup-detection works for a codex-only peer. */
|
|
296
|
+
function enableCodex(plugin: string, env: NodeJS.ProcessEnv): RuntimeEnableResult {
|
|
297
|
+
const bin = codexBin(env)
|
|
298
|
+
if (!isExecutable(bin, env)) return { runtime: 'codex', state: 'runtime-missing' }
|
|
299
|
+
const id = `${plugin}@${MARKETPLACE_NAME}`
|
|
300
|
+
const before = codexState(bin, id, env)
|
|
301
|
+
if (before === 'enabled') return { runtime: 'codex', state: 'already-enabled' }
|
|
302
|
+
const add = spawnSync(bin, ['plugin', 'add', id], { encoding: 'utf8', env: env as Record<string, string> })
|
|
303
|
+
if (add.status !== 0) {
|
|
304
|
+
return { runtime: 'codex', state: 'failed', detail: (add.stderr || add.stdout || `exit ${add.status}`).trim() }
|
|
305
|
+
}
|
|
306
|
+
if (codexState(bin, id, env) !== 'enabled') {
|
|
307
|
+
return { runtime: 'codex', state: 'failed', detail: (add.stdout || 'plugin add did not enable the plugin').trim() }
|
|
308
|
+
}
|
|
309
|
+
const m = /Installed plugin root:\s*(\S.*)/.exec(add.stdout ?? '')
|
|
310
|
+
return { runtime: 'codex', state: before === 'disabled' ? 'enabled' : 'installed', installPath: m?.[1]?.trim() }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Invoke the plugin's `setup` (СЛОЖНЫЙ class), cwd = plugin root, peer context in env
|
|
314
|
+
* (namespaced IAPEER_PEER_* — NOT the bare PEER_PERSONALITY the identity-gate keys on).
|
|
315
|
+
* Best-effort: a setup failure is reported, it does NOT unwind the completed install. */
|
|
316
|
+
function callSetup(
|
|
317
|
+
setup: SetupDescriptor,
|
|
318
|
+
installPath: string,
|
|
319
|
+
peer: { personality: string; cwd: string },
|
|
320
|
+
env: NodeJS.ProcessEnv,
|
|
321
|
+
): { ok: boolean; detail?: string } {
|
|
322
|
+
const [command, ...preArgs] = typeof setup === 'string' ? [setup] : [setup.command, ...(setup.args ?? [])]
|
|
323
|
+
const setupEnv: NodeJS.ProcessEnv = {
|
|
324
|
+
...env,
|
|
325
|
+
IAPEER_PEER_PERSONALITY: peer.personality,
|
|
326
|
+
IAPEER_PEER_CWD: peer.cwd,
|
|
327
|
+
}
|
|
328
|
+
// a bare string resolved relative to the plugin root when it points at a file there
|
|
329
|
+
const resolved = typeof setup === 'string' && existsSync(join(installPath, command)) ? join(installPath, command) : command
|
|
330
|
+
const r = spawnSync(resolved, preArgs, {
|
|
331
|
+
cwd: installPath,
|
|
332
|
+
encoding: 'utf8',
|
|
333
|
+
env: setupEnv as Record<string, string>,
|
|
334
|
+
})
|
|
335
|
+
if (r.error || (r.status ?? 1) !== 0) {
|
|
336
|
+
return { ok: false, detail: (r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim() }
|
|
337
|
+
}
|
|
338
|
+
return { ok: true }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Enable a capability plugin on a peer (contract Установка §3): install per-runtime
|
|
343
|
+
* (claude project-scope / codex global) + enable + call `setup` ONLY if the plugin
|
|
344
|
+
* declares it. Idempotent and fleet-safe — a peer already enabled is a no-op; the
|
|
345
|
+
* claude path is keyed by the peer's projectPath so it never touches another peer.
|
|
346
|
+
*/
|
|
347
|
+
export function enableCapability(opts: EnableOptions): EnableResult {
|
|
348
|
+
const env = opts.env ?? process.env
|
|
349
|
+
const peer = resolvePeer(opts)
|
|
350
|
+
const targetRuntimes = opts.runtimes ?? peer.runtimes
|
|
351
|
+
const results: RuntimeEnableResult[] = []
|
|
352
|
+
for (const rt of targetRuntimes) {
|
|
353
|
+
results.push(rt === 'claude' ? enableClaude(opts.plugin, peer.cwd, env) : enableCodex(opts.plugin, env))
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// setup: read the manifest from whichever runtime gave us an installPath (the source
|
|
357
|
+
// is the same plugin regardless of runtime). Called ONLY if declared.
|
|
358
|
+
const installPath = results.find(r => r.installPath)?.installPath
|
|
359
|
+
let setupState: SetupState = 'absent'
|
|
360
|
+
let setupDetail: string | undefined
|
|
361
|
+
const setup = readSetupDescriptor(installPath)
|
|
362
|
+
const anyOk = results.some(r => r.state === 'installed' || r.state === 'enabled' || r.state === 'already-enabled')
|
|
363
|
+
if (setup && anyOk) {
|
|
364
|
+
if (opts.noSetup) {
|
|
365
|
+
setupState = 'skipped'
|
|
366
|
+
} else {
|
|
367
|
+
const s = callSetup(setup, installPath as string, peer, env)
|
|
368
|
+
setupState = s.ok ? 'called' : 'failed'
|
|
369
|
+
setupDetail = s.detail
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
plugin: opts.plugin,
|
|
375
|
+
personality: peer.personality,
|
|
376
|
+
cwd: peer.cwd,
|
|
377
|
+
runtimes: results,
|
|
378
|
+
setup: setupState,
|
|
379
|
+
setupDetail,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
3
|
+
import { tmpdir } from 'os'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import {
|
|
6
|
+
ensurePeerProfile,
|
|
7
|
+
readPeerProfile,
|
|
8
|
+
writePeerProfileAtomic,
|
|
9
|
+
resolveCallerIdentity,
|
|
10
|
+
type PeerProfileWrite,
|
|
11
|
+
} from './index.ts'
|
|
12
|
+
import { peerProfilePath } from '../storage/index.ts'
|
|
13
|
+
import { isFoundationOwnedPlist, launchdPlistPath } from '../launch/index.ts'
|
|
14
|
+
import { defaultIntelligenceForRuntime } from '../core/constants.ts'
|
|
15
|
+
import type { PeersIndex } from '../registry/index.ts'
|
|
16
|
+
|
|
17
|
+
let cwd: string
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
cwd = mkdtempSync(join(tmpdir(), 'iapeer-identity-'))
|
|
20
|
+
})
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
function readDisk(): Record<string, unknown> {
|
|
26
|
+
return JSON.parse(readFileSync(peerProfilePath(cwd), 'utf8'))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// WITNESS: old writePeerProfileAtomic merged-builder (identity.ts:247-263) wrote
|
|
30
|
+
// `intelligence: profile.intelligence` UNCONDITIONALLY. Reproduce the merge so a
|
|
31
|
+
// call site that lost the existing intelligence (passing the runtime default)
|
|
32
|
+
// would clobber a human profile.
|
|
33
|
+
function oldMerged(existing: Record<string, unknown>, profile: {
|
|
34
|
+
personality: string; runtime: string; runtimes: string[]; description: string; intelligence: string
|
|
35
|
+
}): Record<string, unknown> {
|
|
36
|
+
return {
|
|
37
|
+
...existing,
|
|
38
|
+
personality: profile.personality,
|
|
39
|
+
runtime: profile.runtime,
|
|
40
|
+
runtimes: profile.runtimes,
|
|
41
|
+
description: profile.description, // empty wipes
|
|
42
|
+
intelligence: profile.intelligence, // unconditional overwrite
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
// writePeerProfileAtomic — H1 preserve + unknown-field preservation
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe('writePeerProfileAtomic H1 preserve', () => {
|
|
51
|
+
test('write WITHOUT intelligence does NOT downgrade a human profile on disk', () => {
|
|
52
|
+
// seed a human profile with a foreign (persistent-peer) section + interfaces
|
|
53
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
54
|
+
writeFileSync(
|
|
55
|
+
peerProfilePath(cwd),
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
personality: 'arthur',
|
|
58
|
+
runtime: 'telegram',
|
|
59
|
+
runtimes: ['telegram', 'claude'],
|
|
60
|
+
description: 'Артур — владелец.',
|
|
61
|
+
intelligence: 'human',
|
|
62
|
+
'persistent-peer': { initial_prompt: 'hi', aliases: { '/new': 'x' } },
|
|
63
|
+
interfaces: { telegram: { user_id: '409502965' } },
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
// a runtimes-only update built WITHOUT re-asserting intelligence
|
|
68
|
+
const update: PeerProfileWrite = {
|
|
69
|
+
personality: 'arthur',
|
|
70
|
+
runtime: 'claude',
|
|
71
|
+
runtimes: ['telegram', 'claude'],
|
|
72
|
+
}
|
|
73
|
+
writePeerProfileAtomic(cwd, update)
|
|
74
|
+
|
|
75
|
+
const disk = readDisk()
|
|
76
|
+
// H1 + NO-MIGRATION: the legacy on-disk value is preserved VERBATIM (not
|
|
77
|
+
// downgraded, and NOT silently rewritten to 'natural' — that migration is a
|
|
78
|
+
// separate coordinated step that must not happen on a routine write).
|
|
79
|
+
expect(disk.intelligence).toBe('human')
|
|
80
|
+
// READ-COMPAT: but reading it back normalizes legacy human → contract natural
|
|
81
|
+
expect(readPeerProfile(cwd)!.intelligence).toBe('natural')
|
|
82
|
+
// WITNESS: old builder fed the claude runtime-default would write artificial
|
|
83
|
+
expect(oldMerged(disk, {
|
|
84
|
+
personality: 'arthur', runtime: 'claude', runtimes: ['claude'],
|
|
85
|
+
description: '', intelligence: defaultIntelligenceForRuntime('claude'),
|
|
86
|
+
}).intelligence).toBe('artificial')
|
|
87
|
+
// unknown foreign section preserved
|
|
88
|
+
expect(disk['persistent-peer']).toEqual({ initial_prompt: 'hi', aliases: { '/new': 'x' } })
|
|
89
|
+
// interfaces preserved
|
|
90
|
+
expect(disk.interfaces).toEqual({ telegram: { user_id: '409502965' } })
|
|
91
|
+
// empty description not written over the existing one
|
|
92
|
+
expect(disk.description).toBe('Артур — владелец.')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('explicit intelligence in write IS applied', () => {
|
|
96
|
+
writePeerProfileAtomic(cwd, {
|
|
97
|
+
personality: 'p', runtime: 'webhook', runtimes: ['webhook'], intelligence: 'absent',
|
|
98
|
+
})
|
|
99
|
+
expect(readDisk().intelligence).toBe('absent')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('new profile (no disk) without intelligence → runtime default', () => {
|
|
103
|
+
writePeerProfileAtomic(cwd, { personality: 'p', runtime: 'telegram', runtimes: ['telegram'] })
|
|
104
|
+
expect(readDisk().intelligence).toBe('natural') // telegram default
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('round-trips through readPeerProfile preserving foreign fields', () => {
|
|
108
|
+
writePeerProfileAtomic(cwd, {
|
|
109
|
+
personality: 'p', runtime: 'claude', runtimes: ['claude'],
|
|
110
|
+
description: 'desc', intelligence: 'artificial',
|
|
111
|
+
})
|
|
112
|
+
// inject a foreign field, then a second write must keep it
|
|
113
|
+
const disk = readDisk()
|
|
114
|
+
disk['persistent-peer'] = { initial_prompt: 'keep me' }
|
|
115
|
+
writeFileSync(peerProfilePath(cwd), JSON.stringify(disk))
|
|
116
|
+
writePeerProfileAtomic(cwd, { personality: 'p', runtime: 'claude', runtimes: ['claude', 'codex'] })
|
|
117
|
+
const after = readDisk()
|
|
118
|
+
expect(after['persistent-peer']).toEqual({ initial_prompt: 'keep me' })
|
|
119
|
+
expect(after.intelligence).toBe('artificial') // still preserved
|
|
120
|
+
expect(after.runtimes).toEqual(['claude', 'codex'])
|
|
121
|
+
const parsed = readPeerProfile(cwd)!
|
|
122
|
+
expect(parsed.intelligence).toBe('artificial')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
// resolveCallerIdentity — per-request, NO process.cwd()
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
const index: PeersIndex = {
|
|
131
|
+
version: 2,
|
|
132
|
+
peers: [
|
|
133
|
+
{
|
|
134
|
+
personality: 'arthur',
|
|
135
|
+
runtime: 'telegram',
|
|
136
|
+
runtimes: ['telegram', 'claude'],
|
|
137
|
+
description: 'Артур — владелец.',
|
|
138
|
+
intelligence: 'natural',
|
|
139
|
+
cwd: '/Users/macmini/Peers/arthur',
|
|
140
|
+
interfaces: { telegram: { user_id: '409502965' } },
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
personality: 'boris',
|
|
144
|
+
runtime: 'claude',
|
|
145
|
+
runtimes: ['claude'],
|
|
146
|
+
description: 'Напарник.',
|
|
147
|
+
intelligence: 'artificial',
|
|
148
|
+
cwd: '/Users/macmini/Peers/boris',
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe('resolveCallerIdentity (per-request)', () => {
|
|
154
|
+
test('resolves caller from registry record, builds address', () => {
|
|
155
|
+
const r = resolveCallerIdentity({ personality: 'boris', runtime: 'claude' }, index)
|
|
156
|
+
expect(r.address).toBe('claude-boris')
|
|
157
|
+
expect(r.cwd).toBe('/Users/macmini/Peers/boris')
|
|
158
|
+
expect(r.intelligence).toBe('artificial')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('multi-runtime peer resolves each declared runtime to a distinct address', () => {
|
|
162
|
+
expect(resolveCallerIdentity({ personality: 'arthur', runtime: 'telegram' }, index).address).toBe(
|
|
163
|
+
'telegram-arthur',
|
|
164
|
+
)
|
|
165
|
+
expect(resolveCallerIdentity({ personality: 'arthur', runtime: 'claude' }, index).address).toBe(
|
|
166
|
+
'claude-arthur',
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('does NOT depend on process.cwd() — chdir does not change the result', () => {
|
|
171
|
+
const before = resolveCallerIdentity({ personality: 'arthur', runtime: 'claude' }, index)
|
|
172
|
+
const original = process.cwd()
|
|
173
|
+
const elsewhere = mkdtempSync(join(tmpdir(), 'iapeer-chdir-'))
|
|
174
|
+
try {
|
|
175
|
+
process.chdir(elsewhere)
|
|
176
|
+
const after = resolveCallerIdentity({ personality: 'arthur', runtime: 'claude' }, index)
|
|
177
|
+
expect(after).toEqual(before)
|
|
178
|
+
expect(after.cwd).toBe('/Users/macmini/Peers/arthur') // from registry, not process.cwd()
|
|
179
|
+
} finally {
|
|
180
|
+
process.chdir(original)
|
|
181
|
+
rmSync(elsewhere, { recursive: true, force: true })
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('unknown caller → throws (spoofing guard)', () => {
|
|
186
|
+
expect(() => resolveCallerIdentity({ personality: 'ghost', runtime: 'claude' }, index)).toThrow(
|
|
187
|
+
/unknown caller/,
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('undeclared runtime for a known caller → throws', () => {
|
|
192
|
+
// boris declares only claude; codex must be rejected
|
|
193
|
+
expect(() => resolveCallerIdentity({ personality: 'boris', runtime: 'codex' }, index)).toThrow(
|
|
194
|
+
/not declared/,
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('invalid personality format → throws', () => {
|
|
199
|
+
expect(() => resolveCallerIdentity({ personality: 'Bad Name', runtime: 'claude' }, index)).toThrow()
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
204
|
+
// ensurePeerProfile — create-peer path installs the always-on plist for INFRA
|
|
205
|
+
// runtimes (and ONLY those). Always under IAPEER_LAUNCHAGENTS_DIR so the suite
|
|
206
|
+
// never writes into the real ~/Library/LaunchAgents.
|
|
207
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
describe('ensurePeerProfile create-peer → always-on plist (infra only)', () => {
|
|
210
|
+
let root: string
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
root = mkdtempSync(join(tmpdir(), 'iapeer-provision-'))
|
|
213
|
+
})
|
|
214
|
+
afterEach(() => {
|
|
215
|
+
rmSync(root, { recursive: true, force: true })
|
|
216
|
+
})
|
|
217
|
+
const laEnv = () =>
|
|
218
|
+
// IAPEER_ROOT isolates the now-GLOBAL infra log dir (~/.iapeer/logs/<p>, Фаза §8)
|
|
219
|
+
// under the sandbox — without it installAlwaysOnPlist's log mkdir resolves to the
|
|
220
|
+
// real home.
|
|
221
|
+
({ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'), IAPEER_ROOT: join(root, 'iapeer') }) as NodeJS.ProcessEnv
|
|
222
|
+
|
|
223
|
+
test('provisioning a NEW infra (notifier) peer installs a foundation-owned plist', () => {
|
|
224
|
+
const env = laEnv()
|
|
225
|
+
const peerCwd = join(root, 'timer') // basename → personality "timer"
|
|
226
|
+
const profile = ensurePeerProfile({ cwd: peerCwd, env, runtime: 'notifier' })
|
|
227
|
+
expect(profile.personality).toBe('timer')
|
|
228
|
+
expect(profile.intelligence).toBe('absent') // notifier zone default
|
|
229
|
+
|
|
230
|
+
const plist = launchdPlistPath('timer', env)
|
|
231
|
+
expect(existsSync(plist)).toBe(true)
|
|
232
|
+
expect(isFoundationOwnedPlist(plist)).toBe(true)
|
|
233
|
+
// and the profile was written (full provision)
|
|
234
|
+
expect(existsSync(peerProfilePath(peerCwd))).toBe(true)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('provisioning a NEW warm-on-demand (claude) peer installs NO plist (unchanged)', () => {
|
|
238
|
+
const env = laEnv()
|
|
239
|
+
const peerCwd = join(root, 'worker') // personality "worker"
|
|
240
|
+
ensurePeerProfile({ cwd: peerCwd, env, runtime: 'claude' })
|
|
241
|
+
expect(existsSync(launchdPlistPath('worker', env))).toBe(false)
|
|
242
|
+
expect(existsSync(peerProfilePath(peerCwd))).toBe(true) // profile still created
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('infra provision whose Label collides with a FOREIGN plist is refused — no profile written', () => {
|
|
246
|
+
const env = laEnv()
|
|
247
|
+
const laDir = join(root, 'LaunchAgents')
|
|
248
|
+
mkdirSync(laDir, { recursive: true })
|
|
249
|
+
// a live persistent-peer "boris" already owns com.iapeer.boris.plist (no sentinel)
|
|
250
|
+
const foreign = '<?xml version="1.0"?>\n<plist><dict><key>Label</key><string>com.iapeer.boris</string></dict></plist>\n'
|
|
251
|
+
const plist = launchdPlistPath('boris', env)
|
|
252
|
+
writeFileSync(plist, foreign)
|
|
253
|
+
|
|
254
|
+
const peerCwd = join(root, 'boris') // would derive personality "boris" → collision
|
|
255
|
+
expect(() => ensurePeerProfile({ cwd: peerCwd, env, runtime: 'notifier' })).toThrow(
|
|
256
|
+
/foundation-managed|refus/i,
|
|
257
|
+
)
|
|
258
|
+
// the live PP plist is untouched, and the half-provision left NO peer-profile.json
|
|
259
|
+
expect(readFileSync(plist, 'utf8')).toBe(foreign)
|
|
260
|
+
expect(existsSync(peerProfilePath(peerCwd))).toBe(false)
|
|
261
|
+
})
|
|
262
|
+
})
|