@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,230 @@
|
|
|
1
|
+
// Production daemon main — THE composition point. The library `startDaemon`
|
|
2
|
+
// (daemon/index.ts) is transport-only (route / deliver / liveness) and takes the
|
|
3
|
+
// lifecycle primitives as INJECTED hooks; this module wires them:
|
|
4
|
+
// • wake-on-miss → lifecycle.wakeOrSpawn (a send to a dead, non-launchd peer
|
|
5
|
+
// spawns it and delivers the envelope as its boot first-message). H4 (never
|
|
6
|
+
// wake a launchd-managed peer) lives INSIDE wakeOrSpawn.
|
|
7
|
+
// • supervise → a timer running lifecycle.superviseTick (idle-reap /
|
|
8
|
+
// zombie-sweep), H4-guarded per session inside.
|
|
9
|
+
// This is where Ф1 (transport) meets Ф2 (lifecycle). It also installs the daemon's
|
|
10
|
+
// OWN always-on launchd plist (label com.agfpd.iapeer — a SEPARATE namespace from
|
|
11
|
+
// the com.iapeer.* peer fleet, so it never collides with persistent-peer plists).
|
|
12
|
+
//
|
|
13
|
+
// The CLI at the bottom is the launchd-held process (`bun main.ts`) and the
|
|
14
|
+
// foreground entrypoint for acceptance. Installing the plist is a SEPARATE explicit
|
|
15
|
+
// action (`--install-plist`); it writes the file but does NOT load it — a live
|
|
16
|
+
// `launchctl bootstrap` stays a deliberate operator step.
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
|
19
|
+
import { homedir } from 'os'
|
|
20
|
+
import { join } from 'path'
|
|
21
|
+
import { fileURLToPath } from 'url'
|
|
22
|
+
import { DAEMON_PLIST_LABEL } from '../core/constants.ts'
|
|
23
|
+
import { IapError } from '../core/errors.ts'
|
|
24
|
+
import { pluginLogsDir } from '../storage/index.ts'
|
|
25
|
+
import {
|
|
26
|
+
isFoundationOwnedPlist,
|
|
27
|
+
launchAgentsDir,
|
|
28
|
+
renderLaunchdPlist,
|
|
29
|
+
} from '../launch/launchd.ts'
|
|
30
|
+
import type { LaunchdPlistSpec } from '../launch/launchd.ts'
|
|
31
|
+
import {
|
|
32
|
+
loadLifecycleConfig,
|
|
33
|
+
processEagerRelaunches,
|
|
34
|
+
superviseTick,
|
|
35
|
+
wakeOrSpawn,
|
|
36
|
+
type LifecycleConfig,
|
|
37
|
+
} from '../lifecycle/index.ts'
|
|
38
|
+
import type { WakeFn, WakeOutcome, WakeRequest } from '../transport/index.ts'
|
|
39
|
+
import { iapeerBinPath } from '../install/index.ts'
|
|
40
|
+
import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
|
|
41
|
+
|
|
42
|
+
/** Default TCP loopback port for the always-on router. Real http MCP clients
|
|
43
|
+
* (claude/codex `--transport http <url>`) bind to a fixed URL, so production needs
|
|
44
|
+
* a STABLE port, not an ephemeral one. Override with IAPEER_PORT. */
|
|
45
|
+
export const DEFAULT_DAEMON_PORT = 8765
|
|
46
|
+
|
|
47
|
+
/** Default supervise-tick cadence (idle-reap / zombie-sweep). idleSecs (1h default)
|
|
48
|
+
* is the reap threshold; this is just how often the timer checks. */
|
|
49
|
+
export const DEFAULT_SUPERVISE_INTERVAL_MS = 60_000
|
|
50
|
+
|
|
51
|
+
// This module's own path — the launchd plist runs `bun <this>` as the daemon.
|
|
52
|
+
const DAEMON_MAIN_PATH = fileURLToPath(import.meta.url)
|
|
53
|
+
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// Composition: wire startDaemon ⇆ lifecycle (wake + supervise)
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Adapt the transport WakeFn contract to lifecycle.wakeOrSpawn. The two shapes are
|
|
60
|
+
* structurally identical (WakeRequest ⊆ WakeArgs, WakeResult ⊆ WakeOutcome); this
|
|
61
|
+
* is the one place transport's injected-wake meets the lifecycle implementation.
|
|
62
|
+
*/
|
|
63
|
+
export function makeWakeFn(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): WakeFn {
|
|
64
|
+
return (req: WakeRequest): Promise<WakeOutcome> =>
|
|
65
|
+
wakeOrSpawn(
|
|
66
|
+
{ personality: req.personality, runtime: req.runtime, topic: req.topic, task: req.task },
|
|
67
|
+
{ cfg, env },
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ConfiguredDaemonOptions {
|
|
72
|
+
/** TCP loopback port (default DEFAULT_DAEMON_PORT). */
|
|
73
|
+
port?: number
|
|
74
|
+
host?: string
|
|
75
|
+
/** Unix-socket path (H8 same-uid base; default: defaultDaemonSocketPath). */
|
|
76
|
+
socketPath?: string
|
|
77
|
+
/** H8 bearer token; falls back to env.IAPEER_BEARER_TOKEN. Off when neither set. */
|
|
78
|
+
bearerToken?: string
|
|
79
|
+
superviseIntervalMs?: number
|
|
80
|
+
/** Write the router.json discovery file (default true for production). */
|
|
81
|
+
discovery?: boolean
|
|
82
|
+
rootDir?: string
|
|
83
|
+
env?: NodeJS.ProcessEnv
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Start the fully-composed production daemon: the router (startDaemon) wired to
|
|
88
|
+
* wake-on-miss (wakeOrSpawn) and the supervise timer (superviseTick). DUAL-LISTEN
|
|
89
|
+
* by default — a 0600 unix socket (local same-uid callers: notifier/telegram/CLI)
|
|
90
|
+
* AND TCP loopback (real http MCP agent clients), both over one MCP handler — and
|
|
91
|
+
* the router.json discovery file written for daemon-aware `iap send`. The bearer
|
|
92
|
+
* layer (H8) engages only when a token is configured.
|
|
93
|
+
*/
|
|
94
|
+
export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}): Promise<DaemonHandle> {
|
|
95
|
+
const env = opts.env ?? process.env
|
|
96
|
+
const cfg = loadLifecycleConfig(env)
|
|
97
|
+
const bearerToken = opts.bearerToken ?? (env.IAPEER_BEARER_TOKEN?.trim() || undefined)
|
|
98
|
+
return startDaemon({
|
|
99
|
+
wake: makeWakeFn(cfg, env),
|
|
100
|
+
supervise: {
|
|
101
|
+
intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
|
|
102
|
+
// idle-reap / zombie-sweep, THEN C4b eager fresh re-launch for any peer whose
|
|
103
|
+
// session died carrying a /new graceful mark (async, best-effort).
|
|
104
|
+
tick: async () => {
|
|
105
|
+
const outcomes = superviseTick(cfg, { env })
|
|
106
|
+
await processEagerRelaunches(cfg, outcomes, { env })
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
bearerToken,
|
|
110
|
+
port: opts.port ?? DEFAULT_DAEMON_PORT,
|
|
111
|
+
host: opts.host ?? '127.0.0.1',
|
|
112
|
+
socketPath: opts.socketPath ?? defaultDaemonSocketPath({ env, rootDir: opts.rootDir }),
|
|
113
|
+
discovery: opts.discovery ?? true,
|
|
114
|
+
env,
|
|
115
|
+
rootDir: opts.rootDir,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
// The daemon's OWN launchd plist (com.agfpd.iapeer) — distinct from peer plists
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export function daemonPlistPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
124
|
+
return join(launchAgentsDir(env), `${DAEMON_PLIST_LABEL}.plist`)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface InstallDaemonPlistOptions {
|
|
128
|
+
/** How to launch the daemon (default [bun, main.ts]). */
|
|
129
|
+
programArgv?: string[]
|
|
130
|
+
/** TCP port baked into the plist env (default DEFAULT_DAEMON_PORT). */
|
|
131
|
+
port?: number
|
|
132
|
+
/** H8 bearer token baked into the plist env (only when provided). */
|
|
133
|
+
bearerToken?: string
|
|
134
|
+
/** PATH for the launchd minimal env. */
|
|
135
|
+
path?: string
|
|
136
|
+
workingDirectory?: string
|
|
137
|
+
env?: NodeJS.ProcessEnv
|
|
138
|
+
throttleIntervalSecs?: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Build the daemon's launchd plist spec (PURE — render/lint-testable). */
|
|
142
|
+
export function buildDaemonPlistSpec(opts: InstallDaemonPlistOptions = {}): LaunchdPlistSpec {
|
|
143
|
+
const env = opts.env ?? process.env
|
|
144
|
+
const home = env.HOME?.trim() || homedir()
|
|
145
|
+
const defaultPath = `${home}/.bun/bin:${home}/.local/bin:/opt/homebrew/bin:/usr/bin:/bin`
|
|
146
|
+
const logDir = pluginLogsDir('iapeer', { env })
|
|
147
|
+
const environment: Record<string, string> = {
|
|
148
|
+
PATH: opts.path ?? env.PATH ?? defaultPath,
|
|
149
|
+
IAPEER_PORT: String(opts.port ?? DEFAULT_DAEMON_PORT),
|
|
150
|
+
}
|
|
151
|
+
if (opts.bearerToken) environment.IAPEER_BEARER_TOKEN = opts.bearerToken
|
|
152
|
+
// Propagate the FULL set of non-default path overrides so the launchd-held daemon
|
|
153
|
+
// reads the SAME tree the rest of the fleet uses (audit #26: a sandbox daemon that
|
|
154
|
+
// carried only IAPEER_ROOT would scan the wrong tmux socket dir and key its H4
|
|
155
|
+
// launchd-guard on the wrong LaunchAgents dir — the routing + fleet-guard surfaces).
|
|
156
|
+
for (const key of ['IAPEER_ROOT', 'IAPEER_SOCK_DIR', 'IAPEER_LAUNCHAGENTS_DIR'] as const) {
|
|
157
|
+
if (env[key]?.trim()) environment[key] = env[key]!.trim()
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
label: DAEMON_PLIST_LABEL,
|
|
161
|
+
// Ф-F: run the INSTALLED binary (`iapeer daemon`), NOT `bun <src>/daemon/main.ts`
|
|
162
|
+
// — this is the decoupling of prod from the mutable src tree. opts.programArgv
|
|
163
|
+
// overrides for tests / a non-default layout.
|
|
164
|
+
programArguments: opts.programArgv ?? [iapeerBinPath(env), 'daemon'],
|
|
165
|
+
workingDirectory: opts.workingDirectory ?? home, // daemon is cwd-agnostic (per-request identity)
|
|
166
|
+
environment,
|
|
167
|
+
stdoutPath: join(logDir, 'daemon-stdout.log'),
|
|
168
|
+
stderrPath: join(logDir, 'daemon-stderr.log'),
|
|
169
|
+
throttleIntervalSecs: opts.throttleIntervalSecs,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Generate + install the daemon's always-on launchd plist at
|
|
175
|
+
* ~/Library/LaunchAgents/com.agfpd.iapeer.plist (or IAPEER_LAUNCHAGENTS_DIR),
|
|
176
|
+
* returning the path. Writes the FILE only — does NOT bootstrap it (load is a
|
|
177
|
+
* deliberate operator step). Collision guard: refuses to overwrite an existing
|
|
178
|
+
* com.agfpd.iapeer.plist that is not foundation-owned (lacks the sentinel) — the
|
|
179
|
+
* same ownership proof the peer-plist installer uses. com.agfpd.* is the
|
|
180
|
+
* foundation-exclusive daemon namespace, so this never touches the com.iapeer.*
|
|
181
|
+
* persistent-peer fleet.
|
|
182
|
+
*/
|
|
183
|
+
export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): string {
|
|
184
|
+
const env = opts.env ?? process.env
|
|
185
|
+
const path = daemonPlistPath(env)
|
|
186
|
+
if (existsSync(path) && !isFoundationOwnedPlist(path)) {
|
|
187
|
+
throw new IapError(
|
|
188
|
+
`refusing to overwrite ${path}: ${DAEMON_PLIST_LABEL} exists but is not foundation-managed ` +
|
|
189
|
+
`(no ownership sentinel) — another manager owns it`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
const spec = buildDaemonPlistSpec(opts)
|
|
193
|
+
mkdirSync(launchAgentsDir(env), { recursive: true })
|
|
194
|
+
mkdirSync(pluginLogsDir('iapeer', { env }), { recursive: true, mode: 0o700 })
|
|
195
|
+
writeFileSync(path, renderLaunchdPlist(spec), { mode: 0o644 })
|
|
196
|
+
return path
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
// CLI — launchd-held process / foreground acceptance entrypoint
|
|
201
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
if (import.meta.main) {
|
|
204
|
+
const arg = process.argv[2]
|
|
205
|
+
if (arg === '--print-plist') {
|
|
206
|
+
process.stdout.write(renderLaunchdPlist(buildDaemonPlistSpec()))
|
|
207
|
+
process.exit(0)
|
|
208
|
+
} else if (arg === '--install-plist') {
|
|
209
|
+
const p = installDaemonPlist()
|
|
210
|
+
process.stdout.write(
|
|
211
|
+
`installed daemon plist: ${p}\n` +
|
|
212
|
+
`NOT loaded. To start it live: launchctl bootstrap gui/$(id -u) ${p}\n`,
|
|
213
|
+
)
|
|
214
|
+
process.exit(0)
|
|
215
|
+
} else {
|
|
216
|
+
const env = process.env
|
|
217
|
+
const handle = await startConfiguredDaemon({
|
|
218
|
+
port: Number(env.IAPEER_PORT ?? DEFAULT_DAEMON_PORT),
|
|
219
|
+
socketPath: env.IAPEER_DAEMON_SOCKET?.trim() || undefined,
|
|
220
|
+
env,
|
|
221
|
+
})
|
|
222
|
+
process.stderr.write(`[iapeer-daemon] READY tcp=${handle.url} sock=${handle.socketPath}\n`)
|
|
223
|
+
const shutdown = () => {
|
|
224
|
+
void handle.close().then(() => process.exit(0))
|
|
225
|
+
}
|
|
226
|
+
process.on('SIGTERM', shutdown)
|
|
227
|
+
process.on('SIGINT', shutdown)
|
|
228
|
+
await new Promise(() => {}) // launchd KeepAlive holds this process; block forever
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// enable — the PURE fleet-safety + idempotency logic: parse `claude plugin list
|
|
2
|
+
// --json`, match the entry for THIS peer's projectPath (realpath), and detect a
|
|
3
|
+
// plugin's `setup` from iapeer.json (SIMPLE vs СЛОЖНЫЙ). The spawn/install paths are
|
|
4
|
+
// live-verified on a throwaway test-peer (project-scope is keyed by projectPath, so a
|
|
5
|
+
// test install never touches a live peer's entry).
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test'
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
9
|
+
import { tmpdir } from 'os'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { findPeerScopedEntry, parseCodexPluginStatus, parseInstalledPlugins, readSetupDescriptor } from './index.ts'
|
|
12
|
+
|
|
13
|
+
// a realistic `claude plugin list --json` array (the live shape: id/scope/enabled/
|
|
14
|
+
// projectPath/installPath), two live peers + the same plugin user-scope.
|
|
15
|
+
const LIST = JSON.stringify([
|
|
16
|
+
{ id: 'Inter-Agent-Protocol@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/linus', installPath: '/c/iap/0.7.11' },
|
|
17
|
+
{ id: 'peer-voice@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/boris', installPath: '/c/pv/0.1.8' },
|
|
18
|
+
{ id: 'peer-voice@agfpd', scope: 'user', enabled: false, installPath: '/c/pv/0.1.8' },
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
describe('parseInstalledPlugins', () => {
|
|
22
|
+
test('parses the flat array into typed entries; tolerates junk', () => {
|
|
23
|
+
const e = parseInstalledPlugins(LIST)
|
|
24
|
+
expect(e.length).toBe(3)
|
|
25
|
+
expect(e[0]).toMatchObject({ id: 'Inter-Agent-Protocol@agfpd', scope: 'project', enabled: true, projectPath: '/Users/x/agents/linus' })
|
|
26
|
+
expect(parseInstalledPlugins('not json')).toEqual([])
|
|
27
|
+
expect(parseInstalledPlugins('{"not":"array"}')).toEqual([])
|
|
28
|
+
expect(parseInstalledPlugins('[1, null, {"no":"id"}]')).toEqual([]) // entries without a string id are dropped
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('findPeerScopedEntry (per-peer, fleet-safe)', () => {
|
|
33
|
+
test('matches ONLY the project-scope entry for this peer cwd', () => {
|
|
34
|
+
const e = parseInstalledPlugins(LIST)
|
|
35
|
+
expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/boris')?.enabled).toBe(true)
|
|
36
|
+
// a DIFFERENT peer cwd → no match (never reports another peer's install as ours)
|
|
37
|
+
expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/darwin')).toBeNull()
|
|
38
|
+
// user-scope is not a per-peer match
|
|
39
|
+
expect(findPeerScopedEntry(e, 'peer-voice', 'agfpd', '/Users/x/agents/boris')?.scope).toBe('project')
|
|
40
|
+
})
|
|
41
|
+
test('absent plugin for this peer → null (drives install, not false-skip)', () => {
|
|
42
|
+
const e = parseInstalledPlugins(LIST)
|
|
43
|
+
expect(findPeerScopedEntry(e, 'MergeMind', 'agfpd', '/Users/x/agents/linus')).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// the real `codex plugin list` table shape (whitespace-aligned columns)
|
|
48
|
+
const CODEX_LIST = `Marketplace \`agfpd\`
|
|
49
|
+
|
|
50
|
+
PLUGIN STATUS VERSION PATH
|
|
51
|
+
spawned-peer@agfpd not installed https://github.com/agfpd/Spawned-Peer.git
|
|
52
|
+
peer-voice@agfpd installed, enabled 0.1.8 https://github.com/agfpd/Peer-Voice.git
|
|
53
|
+
MergeMind@agfpd installed, disabled 0.14.70 https://github.com/agfpd/MergeMind.git`
|
|
54
|
+
|
|
55
|
+
describe('parseCodexPluginStatus', () => {
|
|
56
|
+
test('reads the STATUS column per plugin id', () => {
|
|
57
|
+
expect(parseCodexPluginStatus(CODEX_LIST, 'peer-voice@agfpd')).toBe('enabled')
|
|
58
|
+
expect(parseCodexPluginStatus(CODEX_LIST, 'spawned-peer@agfpd')).toBe('absent') // "not installed"
|
|
59
|
+
expect(parseCodexPluginStatus(CODEX_LIST, 'MergeMind@agfpd')).toBe('disabled')
|
|
60
|
+
expect(parseCodexPluginStatus(CODEX_LIST, 'totp-presence@agfpd')).toBe('absent') // not in list
|
|
61
|
+
expect(parseCodexPluginStatus('', 'x@agfpd')).toBe('absent')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('readSetupDescriptor (SIMPLE vs СЛОЖНЫЙ)', () => {
|
|
66
|
+
test('no installPath / no iapeer.json → null (SIMPLE: install+enable only)', () => {
|
|
67
|
+
expect(readSetupDescriptor(undefined)).toBeNull()
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), 'iapeer-enable-simple-'))
|
|
69
|
+
try {
|
|
70
|
+
expect(readSetupDescriptor(dir)).toBeNull() // peer-voice-like: no manifest
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(dir, { recursive: true, force: true })
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
test('iapeer.json with setup string → returns it; with {command,args} → object', () => {
|
|
76
|
+
const dir = mkdtempSync(join(tmpdir(), 'iapeer-enable-complex-'))
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ setup: 'bin/setup' }))
|
|
79
|
+
expect(readSetupDescriptor(dir)).toBe('bin/setup')
|
|
80
|
+
writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ setup: { command: 'node', args: ['setup.js'] } }))
|
|
81
|
+
expect(readSetupDescriptor(dir)).toEqual({ command: 'node', args: ['setup.js'] })
|
|
82
|
+
// requires present but NO setup → still SIMPLE (iapeer does not resolve requires)
|
|
83
|
+
writeFileSync(join(dir, 'iapeer.json'), JSON.stringify({ requires: ['telegram'] }))
|
|
84
|
+
expect(readSetupDescriptor(dir)).toBeNull()
|
|
85
|
+
// malformed manifest → treated as SIMPLE, never throws
|
|
86
|
+
writeFileSync(join(dir, 'iapeer.json'), '{ broken')
|
|
87
|
+
expect(readSetupDescriptor(dir)).toBeNull()
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(dir, { recursive: true, force: true })
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|