@agfpd/iapeer 0.2.2 → 0.2.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Foundation core for the IAPeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -19,6 +19,8 @@ import {
19
19
  type Intelligence,
20
20
  type Runtime,
21
21
  } from '../core/constants.ts'
22
+ import { IAPEER_VERSION } from '../core/version.ts'
23
+ import { updateIapeer } from '../update/index.ts'
22
24
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
23
25
  import { ensureGlobalIapScaffold } from '../storage/index.ts'
24
26
  import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
@@ -346,6 +348,8 @@ export function parseArgs(argv: string[]): { positionals: string[]; flags: Recor
346
348
 
347
349
  const USAGE = `usage: iapeer <verb> [args]
348
350
  install build binary + global scaffold + daemon plist (one bootstrap)
351
+ update [--force] pull the latest @agfpd/iapeer from npm (cloud) + restart the daemon
352
+ version | --version | -v print the installed binary's version
349
353
  daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
350
354
  onboard [--dry-run] [--infra <csv>] register the agfpd marketplace (+ npx-install & deploy infra runtimes)
351
355
  install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
@@ -549,6 +553,38 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
549
553
  out(`delivered to ${r.delivered_to.personality} (${r.delivered_to.runtime})\n`)
550
554
  return 0
551
555
  }
556
+ case 'version':
557
+ case '--version':
558
+ case '-v': {
559
+ out(`${IAPEER_VERSION}\n`)
560
+ return 0
561
+ }
562
+ case 'update': {
563
+ // The single deploy path: pull the latest published foundation from npm and
564
+ // restart the daemon onto it (cloud-only — never a working-tree build). Foundation
565
+ // ONLY; the plist and other host packages are untouched. --force reinstalls even
566
+ // when already at latest. (Run from the installed binary; the first-ever install is
567
+ // `npx @agfpd/iapeer`.)
568
+ const r = updateIapeer({ env, force: flags.force === true })
569
+ if (r.status === 'failed') {
570
+ errOut(`update failed: ${r.reason}\n`)
571
+ return 1
572
+ }
573
+ if (r.status === 'already-latest') {
574
+ out(`already at the latest version (${r.from})\n`)
575
+ return 0
576
+ }
577
+ const daemonNote =
578
+ r.daemon === 'restarted'
579
+ ? 'daemon restarted onto the new binary'
580
+ : r.daemon === 'not-loaded'
581
+ ? 'daemon not loaded — new binary will be used on next start'
582
+ : r.daemon === 'failed'
583
+ ? `WARNING — ${r.reason}`
584
+ : String(r.daemon)
585
+ out(`updated ${r.from} → ${r.to}; ${daemonNote}\n`)
586
+ return r.daemon === 'failed' ? 1 : 0
587
+ }
552
588
  case 'install': {
553
589
  // UNIFIED foundation install (contract Установка §1 — "один npx ставит
554
590
  // фундамент"): ONE command does all three install-phase steps that used to be
package/src/core/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './constants.ts'
2
2
  export * from './errors.ts'
3
3
  export * from './socket.ts'
4
+ export * from './version.ts'
@@ -0,0 +1,10 @@
1
+ // The installed binary's own version — BAKED at build time. `bun build --compile`
2
+ // inlines this JSON import, so the standalone ~/.local/bin/iapeer reports its
3
+ // version with NO package.json present at runtime. `iapeer update` compares this
4
+ // against `npm view @agfpd/iapeer version` to decide whether a pull is needed.
5
+ //
6
+ // Single source of truth: package.json (bumped by `npm version` in the release
7
+ // flow), so the binary's version and the published npm version never drift.
8
+ import pkg from '../../package.json'
9
+
10
+ export const IAPEER_VERSION: string = (pkg as { version: string }).version
package/src/index.ts CHANGED
@@ -21,6 +21,8 @@ export * from './provision/index.ts'
21
21
  export * from './init/index.ts'
22
22
  // Install — the foundation install-phase (stable ~/.local/bin/iapeer, decoupled from src).
23
23
  export * from './install/index.ts'
24
+ // Update — the cloud-only deploy path (`iapeer update`: npm latest → rebuild → restart).
25
+ export * from './update/index.ts'
24
26
  // Onboard — the host-phase (idempotent marketplace registration in claude + codex).
25
27
  export * from './onboard/index.ts'
26
28
  export { composeSystemPrompt, gatherPromptInput } from './launch/composeSystemPrompt.ts'
@@ -19,6 +19,7 @@ import { join } from 'path'
19
19
  import { spawnSync } from 'child_process'
20
20
  import { iapeerBinPath } from '../install/index.ts'
21
21
  import {
22
+ DAEMON_PLIST_LABEL,
22
23
  IAPEER_DIR,
23
24
  INFRA_RUNTIME_BIN_ENV,
24
25
  INFRA_RUNTIME_DEFAULT_BIN,
@@ -214,6 +215,37 @@ function isLaunchdLoaded(label: string, uid: string): boolean {
214
215
  return spawnSync('launchctl', ['print', `gui/${uid}/${label}`], { stdio: 'ignore' }).status === 0
215
216
  }
216
217
 
218
+ export type DaemonRestartState =
219
+ | 'restarted' // kickstart -k succeeded → the daemon is now on the freshly-installed binary
220
+ | 'not-loaded' // com.agfpd.iapeer is not in the gui domain → nothing to restart (new binary
221
+ // will be used whenever it IS next started; nothing to do here)
222
+ | 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch the real launchd
223
+ | 'failed' // launchctl kickstart exited non-zero
224
+
225
+ export interface DaemonRestartResult {
226
+ state: DaemonRestartState
227
+ detail?: string
228
+ }
229
+
230
+ /**
231
+ * Restart the foundation daemon (`com.agfpd.iapeer`) onto the binary currently on
232
+ * disk — `launchctl kickstart -k gui/<uid>/com.agfpd.iapeer`. This is the "activate
233
+ * the freshly-installed binary" step of `iapeer update`: a compiled daemon holds its
234
+ * old code in memory until restarted, and `-k` SIGKILLs + relaunches it (KeepAlive
235
+ * brings it right back). Only restarts when the service is actually loaded — a
236
+ * not-loaded daemon needs no restart (the new binary is taken on its next start).
237
+ * Never touches the plist (the launch path is version-stable). Sandbox-safe.
238
+ */
239
+ export function kickstartDaemon(env: NodeJS.ProcessEnv = process.env): DaemonRestartResult {
240
+ if (env.IAPEER_TEST_SANDBOX === '1') return { state: 'skipped-sandbox' }
241
+ const uid = currentUid()
242
+ if (!isLaunchdLoaded(DAEMON_PLIST_LABEL, uid)) return { state: 'not-loaded' }
243
+ const r = spawnSync('launchctl', ['kickstart', '-k', `gui/${uid}/${DAEMON_PLIST_LABEL}`], { encoding: 'utf8' })
244
+ return r.status === 0
245
+ ? { state: 'restarted' }
246
+ : { state: 'failed', detail: (r.stderr ?? '').trim() || `launchctl kickstart exit ${r.status}` }
247
+ }
248
+
217
249
  /**
218
250
  * AUTO-bootstrap a freshly-provisioned foundation plist into the gui domain
219
251
  * (`launchctl bootstrap gui/<uid> <plist>`) — the "load it now, don't write-and-wait
@@ -0,0 +1,114 @@
1
+ // `iapeer update` — the SINGLE deploy path: pull the latest published foundation
2
+ // from npm (the cloud) and restart the daemon onto it. There is deliberately NO
3
+ // "deploy from a working tree" — every host, including the dev host, activates a
4
+ // release the same way: `npm run release` (publish) → `iapeer update` (pull + restart).
5
+ //
6
+ // Flow:
7
+ // 1. latest = `npm view @agfpd/iapeer version` (the cloud's truth)
8
+ // 2. installed == latest && !--force → "already latest" (no needless rebuild/restart)
9
+ // 3. `npx @agfpd/iapeer@<latest> install` (fetch + rebuild ~/.local/bin/iapeer
10
+ // atomically; the COMPILED binary can't rebuild itself from source, so we shell to
11
+ // npx, which runs the freshly-fetched package's own install — same path consumers use)
12
+ // 4. kickstart com.agfpd.iapeer IF loaded (activate the new binary)
13
+ //
14
+ // Scope: the foundation ONLY (the @agfpd/iapeer binary + its daemon). It never
15
+ // touches the plist (version-stable launch path) and never touches other packages
16
+ // on the host (telegram-runtime / notifier-runtime / MergeMind / the peer fleet) —
17
+ // each of those has its own version and its own update.
18
+ //
19
+ // updateIapeer takes its three side-effects as INJECTED deps so the version-gate is
20
+ // unit-testable with no network and no launchctl; the defaults are the real impls.
21
+
22
+ import { spawnSync } from 'child_process'
23
+ import { IapError } from '../core/errors.ts'
24
+ import { IAPEER_VERSION } from '../core/version.ts'
25
+ import { kickstartDaemon, type DaemonRestartResult } from '../launch/launchd.ts'
26
+
27
+ /** The npm package the foundation publishes / updates from. */
28
+ export const IAPEER_PACKAGE = '@agfpd/iapeer'
29
+
30
+ export interface UpdateDeps {
31
+ env?: NodeJS.ProcessEnv
32
+ /** Reinstall + restart even when already at the latest version. */
33
+ force?: boolean
34
+ /** The currently-installed version (default: this binary's baked IAPEER_VERSION). */
35
+ currentVersion?: string
36
+ /** Resolve the latest published version (default: `npm view`). Returns null on
37
+ * any failure (offline / registry error / non-semver) — update then fails loud. */
38
+ fetchLatest?: (env: NodeJS.ProcessEnv) => string | null
39
+ /** Pull + rebuild the binary for `version` (default: `npx @agfpd/iapeer@<v> install`).
40
+ * Returns true on success. */
41
+ runInstall?: (version: string, env: NodeJS.ProcessEnv) => boolean
42
+ /** Restart the daemon onto the new binary (default: kickstartDaemon). */
43
+ restartDaemon?: (env: NodeJS.ProcessEnv) => DaemonRestartResult
44
+ }
45
+
46
+ export interface UpdateResult {
47
+ status: 'updated' | 'already-latest' | 'failed'
48
+ /** Version before the update (the running binary). */
49
+ from: string
50
+ /** Latest published version resolved from npm (undefined if it couldn't be resolved). */
51
+ latest?: string
52
+ /** Version installed by this run (set only on status 'updated'). */
53
+ to?: string
54
+ /** What happened to the live daemon (only on 'updated'). */
55
+ daemon?: DaemonRestartResult['state']
56
+ /** Human reason on 'failed' / extra detail on a daemon restart hiccup. */
57
+ reason?: string
58
+ }
59
+
60
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+].+)?$/
61
+
62
+ /** Default latest-resolver: `npm view @agfpd/iapeer version`. */
63
+ function defaultFetchLatest(env: NodeJS.ProcessEnv): string | null {
64
+ const r = spawnSync('npm', ['view', IAPEER_PACKAGE, 'version'], { encoding: 'utf8', env })
65
+ if (r.status !== 0) return null
66
+ const v = (r.stdout ?? '').trim()
67
+ return SEMVER_RE.test(v) ? v : null
68
+ }
69
+
70
+ /** Default installer: `npx -y @agfpd/iapeer@<version> install` (pull from cloud + rebuild). */
71
+ function defaultRunInstall(version: string, env: NodeJS.ProcessEnv): boolean {
72
+ if (env.IAPEER_TEST_SANDBOX === '1') {
73
+ // A real npx install rebuilds the prod ~/.local/bin/iapeer — never under a test.
74
+ throw new IapError('refusing a real `npx install` under IAPEER_TEST_SANDBOX=1 — inject runInstall in tests')
75
+ }
76
+ const r = spawnSync('npx', ['-y', `${IAPEER_PACKAGE}@${version}`, 'install'], { stdio: 'inherit', env })
77
+ return r.status === 0
78
+ }
79
+
80
+ /**
81
+ * Update the foundation to the latest published version (cloud-only) and restart
82
+ * the daemon onto it. Idempotent + version-gated: a no-op "already-latest" when the
83
+ * installed version equals the published one (unless `force`). Pure-ish — the three
84
+ * effects are injected, so this is fully unit-testable.
85
+ */
86
+ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
87
+ const env = deps.env ?? process.env
88
+ const from = deps.currentVersion ?? IAPEER_VERSION
89
+ const fetchLatest = deps.fetchLatest ?? defaultFetchLatest
90
+
91
+ const latest = fetchLatest(env)
92
+ if (!latest) {
93
+ return { status: 'failed', from, reason: `could not resolve the latest ${IAPEER_PACKAGE} version from npm (offline / registry error)` }
94
+ }
95
+ if (latest === from && !deps.force) {
96
+ return { status: 'already-latest', from, latest }
97
+ }
98
+
99
+ const runInstall = deps.runInstall ?? defaultRunInstall
100
+ if (!runInstall(latest, env)) {
101
+ return { status: 'failed', from, latest, reason: `\`npx ${IAPEER_PACKAGE}@${latest} install\` failed` }
102
+ }
103
+
104
+ const restart = deps.restartDaemon ?? kickstartDaemon
105
+ const d = restart(env)
106
+ return {
107
+ status: 'updated',
108
+ from,
109
+ to: latest,
110
+ latest,
111
+ daemon: d.state,
112
+ reason: d.state === 'failed' ? `binary updated but daemon restart failed: ${d.detail ?? ''}`.trim() : undefined,
113
+ }
114
+ }
@@ -0,0 +1,123 @@
1
+ // updateIapeer — the version-gated, cloud-only deploy. The three side-effects
2
+ // (resolve latest / install / restart) are injected, so the gate logic is tested
3
+ // with NO network and NO launchctl. The sandbox guard on the REAL installer is
4
+ // exercised too (a test must never npx-install over the prod binary).
5
+
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { IAPEER_VERSION, updateIapeer } from '../index.ts'
8
+ import type { DaemonRestartResult } from '../launch/launchd.ts'
9
+
10
+ const restarted = (): DaemonRestartResult => ({ state: 'restarted' })
11
+
12
+ /** A deps bundle with call-tracking spies. */
13
+ function harness(opts: {
14
+ current: string
15
+ latest: string | null
16
+ installOk?: boolean
17
+ restart?: DaemonRestartResult
18
+ force?: boolean
19
+ }) {
20
+ const calls = { install: [] as string[], restart: 0 }
21
+ const result = updateIapeer({
22
+ env: { IAPEER_TEST_SANDBOX: '1' },
23
+ force: opts.force,
24
+ currentVersion: opts.current,
25
+ fetchLatest: () => opts.latest,
26
+ runInstall: v => {
27
+ calls.install.push(v)
28
+ return opts.installOk ?? true
29
+ },
30
+ restartDaemon: () => {
31
+ calls.restart++
32
+ return opts.restart ?? restarted()
33
+ },
34
+ })
35
+ return { result, calls }
36
+ }
37
+
38
+ describe('updateIapeer — version gate', () => {
39
+ test('already at latest → no install, no restart', () => {
40
+ const { result, calls } = harness({ current: '0.2.2', latest: '0.2.2' })
41
+ expect(result.status).toBe('already-latest')
42
+ expect(result.from).toBe('0.2.2')
43
+ expect(result.latest).toBe('0.2.2')
44
+ expect(calls.install).toEqual([])
45
+ expect(calls.restart).toBe(0)
46
+ })
47
+
48
+ test('--force reinstalls + restarts even when already latest', () => {
49
+ const { result, calls } = harness({ current: '0.2.2', latest: '0.2.2', force: true })
50
+ expect(result.status).toBe('updated')
51
+ expect(result.to).toBe('0.2.2')
52
+ expect(calls.install).toEqual(['0.2.2'])
53
+ expect(calls.restart).toBe(1)
54
+ })
55
+
56
+ test('newer published version → installs THAT version + restarts', () => {
57
+ const { result, calls } = harness({ current: '0.2.2', latest: '0.3.0' })
58
+ expect(result.status).toBe('updated')
59
+ expect(result.from).toBe('0.2.2')
60
+ expect(result.to).toBe('0.3.0')
61
+ expect(result.daemon).toBe('restarted')
62
+ expect(calls.install).toEqual(['0.3.0']) // installs latest, not current
63
+ expect(calls.restart).toBe(1)
64
+ })
65
+ })
66
+
67
+ describe('updateIapeer — failure paths', () => {
68
+ test('latest unresolved (offline / registry error) → failed, no install', () => {
69
+ const { result, calls } = harness({ current: '0.2.2', latest: null })
70
+ expect(result.status).toBe('failed')
71
+ expect(result.reason).toMatch(/latest.*version.*npm|offline|registry/i)
72
+ expect(calls.install).toEqual([])
73
+ expect(calls.restart).toBe(0)
74
+ })
75
+
76
+ test('install fails → failed, daemon NOT restarted', () => {
77
+ const { result, calls } = harness({ current: '0.2.2', latest: '0.3.0', installOk: false })
78
+ expect(result.status).toBe('failed')
79
+ expect(result.latest).toBe('0.3.0')
80
+ expect(result.reason).toMatch(/install.*failed/i)
81
+ expect(calls.restart).toBe(0)
82
+ })
83
+
84
+ test('daemon not loaded → updated, daemon=not-loaded (no error)', () => {
85
+ const { result } = harness({ current: '0.2.2', latest: '0.3.0', restart: { state: 'not-loaded' } })
86
+ expect(result.status).toBe('updated')
87
+ expect(result.daemon).toBe('not-loaded')
88
+ expect(result.reason).toBeUndefined()
89
+ })
90
+
91
+ test('binary updated but restart failed → updated with a warning reason', () => {
92
+ const { result } = harness({
93
+ current: '0.2.2',
94
+ latest: '0.3.0',
95
+ restart: { state: 'failed', detail: 'kickstart exit 1' },
96
+ })
97
+ expect(result.status).toBe('updated')
98
+ expect(result.daemon).toBe('failed')
99
+ expect(result.reason).toMatch(/restart failed.*kickstart exit 1/i)
100
+ })
101
+ })
102
+
103
+ describe('updateIapeer — real-installer sandbox guard', () => {
104
+ test('default runInstall refuses a real npx install under IAPEER_TEST_SANDBOX', () => {
105
+ // fetchLatest injected (newer) so the gate proceeds to the DEFAULT installer,
106
+ // which must refuse rather than npx-install over the prod ~/.local/bin/iapeer.
107
+ expect(() =>
108
+ updateIapeer({
109
+ env: { IAPEER_TEST_SANDBOX: '1' },
110
+ currentVersion: '0.2.2',
111
+ fetchLatest: () => '0.3.0',
112
+ // runInstall NOT injected → exercises the real defaultRunInstall guard.
113
+ restartDaemon: restarted,
114
+ }),
115
+ ).toThrow(/IAPEER_TEST_SANDBOX/)
116
+ })
117
+ })
118
+
119
+ describe('IAPEER_VERSION', () => {
120
+ test('is a baked semver string', () => {
121
+ expect(IAPEER_VERSION).toMatch(/^\d+\.\d+\.\d+/)
122
+ })
123
+ })
package/tsconfig.json CHANGED
@@ -9,6 +9,7 @@
9
9
  "strict": true,
10
10
  "skipLibCheck": true,
11
11
  "esModuleInterop": true,
12
+ "resolveJsonModule": true,
12
13
  "forceConsistentCasingInFileNames": true,
13
14
  "types": ["bun", "node"],
14
15
  "lib": ["ESNext"]