@agfpd/iapeer 0.2.4 → 0.2.5

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.4",
3
+ "version": "0.2.5",
4
4
  "description": "Foundation core for the IAPeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@
22
22
  "scripts": {
23
23
  "test": "IAPEER_TEST_SANDBOX=1 bun test",
24
24
  "typecheck": "tsc --noEmit",
25
+ "preversion": "npm run test && npm run typecheck",
25
26
  "release": "npm version patch && npm publish && git push --follow-tags",
26
27
  "release:minor": "npm version minor && npm publish && git push --follow-tags",
27
28
  "release:major": "npm version major && npm publish && git push --follow-tags",
package/src/cli/index.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  type Runtime,
21
21
  } from '../core/constants.ts'
22
22
  import { IAPEER_VERSION } from '../core/version.ts'
23
- import { updateIapeer } from '../update/index.ts'
23
+ import { updateIapeer, waitForDaemonHealthy } from '../update/index.ts'
24
24
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
25
25
  import { ensureGlobalIapScaffold } from '../storage/index.ts'
26
26
  import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
@@ -38,7 +38,7 @@ import {
38
38
  wakeOrSpawn,
39
39
  } from '../lifecycle/index.ts'
40
40
  import { getAdapter } from '../launch/index.ts'
41
- import { isFoundationOwnedPlist, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
41
+ import { isFoundationOwnedPlist, kickstartDaemon, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
42
42
  import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
43
43
  import { runAlwaysOn } from '../launch/launchdRun.ts'
44
44
  import { installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
@@ -348,7 +348,8 @@ export function parseArgs(argv: string[]): { positionals: string[]; flags: Recor
348
348
 
349
349
  const USAGE = `usage: iapeer <verb> [args]
350
350
  install build binary + global scaffold + daemon plist (one bootstrap)
351
- update [--force] pull the latest @agfpd/iapeer from npm (cloud) + restart the daemon
351
+ update [version] [--force] pull latest (or an exact version) of @agfpd/iapeer from npm + restart the daemon
352
+ rollback revert to the previous binary (.prev) + restart the daemon
352
353
  version | --version | -v print the installed binary's version
353
354
  daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
354
355
  onboard [--dry-run] [--infra <csv>] register the agfpd marketplace (+ npx-install & deploy infra runtimes)
@@ -560,31 +561,73 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
560
561
  return 0
561
562
  }
562
563
  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 })
564
+ // The single deploy path: pull a version of the foundation from npm and restart
565
+ // the daemon onto it (cloud-only — never a working-tree build). No arg → latest;
566
+ // an explicit `update <version>` pins to that exact version (downgrade / recover
567
+ // deeper than the single .prev). Foundation ONLY; the plist and other host packages
568
+ // are untouched. --force reinstalls even when already at the desired version. (Run
569
+ // from the installed binary; the first-ever install is `npx @agfpd/iapeer`.)
570
+ const r = updateIapeer({ env, force: flags.force === true, targetVersion: positionals[0] })
569
571
  if (r.status === 'failed') {
570
572
  errOut(`update failed: ${r.reason}\n`)
571
573
  return 1
572
574
  }
573
575
  if (r.status === 'already-latest') {
574
- out(`already at the latest version (${r.from})\n`)
576
+ out(`already at version ${r.from}\n`)
577
+ return 0
578
+ }
579
+ // 'updated': the binary is swapped. If the daemon was restarted, VERIFY it
580
+ // actually came back up before declaring success — a new binary that fails to
581
+ // boot must not read as a healthy deploy (it's the cue to roll back).
582
+ if (r.daemon === 'restarted') {
583
+ const h = await waitForDaemonHealthy({ env })
584
+ if (!h.healthy) {
585
+ errOut(`updated ${r.from} → ${r.to} but the daemon is NOT healthy after restart (${h.detail}).\n` +
586
+ `roll back now: iapeer rollback\n`)
587
+ return 1
588
+ }
589
+ out(`updated ${r.from} → ${r.to}; daemon restarted and healthy\n`)
575
590
  return 0
576
591
  }
577
592
  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)
593
+ r.daemon === 'not-loaded'
594
+ ? 'daemon not loaded new binary will be used on next start'
595
+ : r.daemon === 'failed'
596
+ ? `WARNING${r.reason}; roll back with: iapeer rollback`
597
+ : String(r.daemon)
585
598
  out(`updated ${r.from} → ${r.to}; ${daemonNote}\n`)
586
599
  return r.daemon === 'failed' ? 1 : 0
587
600
  }
601
+ case 'rollback': {
602
+ // Recovery: restore the .prev binary kept by the last install, restart the
603
+ // daemon onto it, and verify health. ONE level deep (single .prev). Cloud is
604
+ // still the source of truth — rollback is the local "undo the last update" while
605
+ // a fixed version is published.
606
+ const { rollbackIapeer } = await import('../install/index.ts')
607
+ const rb = rollbackIapeer(env)
608
+ if (rb.status === 'failed') {
609
+ errOut(`rollback failed: ${rb.reason}\n`)
610
+ return 1
611
+ }
612
+ const restart = kickstartDaemon(env)
613
+ if (restart.state === 'restarted') {
614
+ const h = await waitForDaemonHealthy({ env })
615
+ out(
616
+ h.healthy
617
+ ? `rolled back to the previous binary; daemon restarted and healthy\n`
618
+ : `rolled back, but the daemon is NOT healthy after restart (${h.detail})\n`,
619
+ )
620
+ return h.healthy ? 0 : 1
621
+ }
622
+ out(
623
+ `rolled back to the previous binary; ${
624
+ restart.state === 'not-loaded'
625
+ ? 'daemon not loaded — previous binary will be used on next start'
626
+ : `daemon restart ${restart.state}${restart.detail ? ` (${restart.detail})` : ''}`
627
+ }\n`,
628
+ )
629
+ return restart.state === 'failed' ? 1 : 0
630
+ }
588
631
  case 'install': {
589
632
  // UNIFIED foundation install (contract Установка §1 — "один npx ставит
590
633
  // фундамент"): ONE command does all three install-phase steps that used to be
@@ -82,3 +82,44 @@ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = pr
82
82
  }
83
83
  return { binPath, prevPath, size }
84
84
  }
85
+
86
+ /** The previous-binary path kept by the last install for one-step rollback. */
87
+ export function iapeerPrevBinPath(env: NodeJS.ProcessEnv = process.env): string {
88
+ return `${iapeerBinPath(env)}.prev`
89
+ }
90
+
91
+ export interface RollbackResult {
92
+ status: 'rolled-back' | 'failed'
93
+ binPath: string
94
+ reason?: string
95
+ }
96
+
97
+ /**
98
+ * Roll the installed binary back to the `.prev` kept by the last install — the
99
+ * recovery path when an `iapeer update` ships a bad version. ONE level deep (the
100
+ * foundation keeps a single `.prev`, not a history stack): rollback restores the
101
+ * binary that was live BEFORE the most recent install. Atomic (copy .prev → .tmp →
102
+ * rename over the binary), so the binary is never absent. The CALLER restarts the
103
+ * daemon afterwards (kickstartDaemon) — rollback only swaps the bytes. Sandbox-guarded.
104
+ */
105
+ export function rollbackIapeer(env: NodeJS.ProcessEnv = process.env): RollbackResult {
106
+ const binPath = iapeerBinPath(env)
107
+ assertInstallSandboxIsolated(binPath, env)
108
+ const prev = iapeerPrevBinPath(env)
109
+ if (!existsSync(prev)) {
110
+ return { status: 'failed', binPath, reason: `no previous binary at ${prev} — nothing to roll back to` }
111
+ }
112
+ const tmp = `${binPath}.rollback.tmp`
113
+ try {
114
+ copyFileSync(prev, tmp)
115
+ renameSync(tmp, binPath)
116
+ } catch (e) {
117
+ try {
118
+ if (existsSync(tmp)) renameSync(tmp, `${tmp}.discard`) // never leave a half-written tmp on the path
119
+ } catch {
120
+ /* best-effort */
121
+ }
122
+ return { status: 'failed', binPath, reason: e instanceof Error ? e.message : String(e) }
123
+ }
124
+ return { status: 'rolled-back', binPath }
125
+ }
@@ -3,9 +3,11 @@
3
3
  // file. The full `bun build --compile` is exercised LIVE (it writes a ~60M binary —
4
4
  // too heavy for a unit test); here we pin the path resolution.
5
5
 
6
- import { describe, expect, test } from 'bun:test'
6
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
7
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
8
+ import { tmpdir } from 'os'
7
9
  import { join } from 'path'
8
- import { iapeerBinPath, installIapeer } from './index.ts'
10
+ import { iapeerBinPath, iapeerPrevBinPath, installIapeer, rollbackIapeer } from './index.ts'
9
11
 
10
12
  describe('iapeerBinPath', () => {
11
13
  test('default = <home>/.local/bin/iapeer (stable host-wide path, on $PATH)', () => {
@@ -29,3 +31,36 @@ describe('installIapeer fail-closed sandbox guard', () => {
29
31
  expect(() => installIapeer('/x/entry.ts', env)).toThrow(/refusing to overwrite the REAL prod binary/)
30
32
  })
31
33
  })
34
+
35
+ describe('rollbackIapeer', () => {
36
+ let binDir: string
37
+ let env: NodeJS.ProcessEnv
38
+ beforeEach(() => {
39
+ binDir = mkdtempSync(join(tmpdir(), 'iapeer-rollback-'))
40
+ env = { IAPEER_TEST_SANDBOX: '1', HOME: '/Users/x', IAPEER_BIN_DIR: binDir } as NodeJS.ProcessEnv
41
+ })
42
+ afterEach(() => {
43
+ rmSync(binDir, { recursive: true, force: true })
44
+ })
45
+
46
+ test('no .prev → failed (nothing to roll back to), binary untouched', () => {
47
+ writeFileSync(iapeerBinPath(env), 'CURRENT')
48
+ const r = rollbackIapeer(env)
49
+ expect(r.status).toBe('failed')
50
+ expect(r.reason).toMatch(/nothing to roll back/i)
51
+ expect(readFileSync(iapeerBinPath(env), 'utf8')).toBe('CURRENT') // unchanged
52
+ })
53
+
54
+ test('restores the .prev bytes over the binary', () => {
55
+ writeFileSync(iapeerBinPath(env), 'NEW-BROKEN')
56
+ writeFileSync(iapeerPrevBinPath(env), 'OLD-GOOD')
57
+ const r = rollbackIapeer(env)
58
+ expect(r.status).toBe('rolled-back')
59
+ expect(readFileSync(iapeerBinPath(env), 'utf8')).toBe('OLD-GOOD')
60
+ })
61
+
62
+ test('fail-closed sandbox guard: refuses the REAL prod binary path', () => {
63
+ const realEnv = { IAPEER_TEST_SANDBOX: '1', HOME: '/Users/fake-home' } as NodeJS.ProcessEnv
64
+ expect(() => rollbackIapeer(realEnv)).toThrow(/refusing to overwrite the REAL prod binary/)
65
+ })
66
+ })
@@ -78,17 +78,25 @@ describe('resolveWakeRuntime (H5)', () => {
78
78
  // H4 — isLaunchdManaged on the LIVE fleet (read-only)
79
79
  // ─────────────────────────────────────────────────────────────────────────────
80
80
 
81
- describe('isLaunchdManaged (H4 detector, live read-only)', () => {
82
- test('a launchd-managed peer (timer has com.iapeer.timer.plist) true', () => {
83
- // Post foundation-migration the always-on INFRA peers (timer/watcher/arthur) keep
84
- // their com.iapeer.<p>.plist; the daemon must treat them READ-ONLY (never
85
- // wake/reap). This proves the detector fires on the live fleet. (boris and the
86
- // agent peers became warm-on-demand — plist relocated — so they are NOT managed.)
87
- expect(isLaunchdManaged('timer')).toBe(true)
81
+ describe('isLaunchdManaged (H4 detector)', () => {
82
+ // HERMETIC: point the detector at a TEMP LaunchAgents dir (IAPEER_LAUNCHAGENTS_DIR),
83
+ // never the live ~/Library/LaunchAgents — so the test fires identically on CI and any
84
+ // host, not just one where the timer peer happens to be installed.
85
+ let laDir: string
86
+ beforeEach(() => {
87
+ laDir = mkdtempSync(join(tmpdir(), 'iapeer-h4-'))
88
+ })
89
+ afterEach(() => {
90
+ rmSync(laDir, { recursive: true, force: true })
91
+ })
92
+
93
+ test('a com.iapeer.<p>.plist present in the LaunchAgents dir → true (read-only managed)', () => {
94
+ writeFileSync(join(laDir, 'com.iapeer.timer.plist'), '')
95
+ expect(isLaunchdManaged('timer', { IAPEER_LAUNCHAGENTS_DIR: laDir } as NodeJS.ProcessEnv)).toBe(true)
88
96
  })
89
97
 
90
- test('a made-up daemon-owned name (no plist) → false', () => {
91
- expect(isLaunchdManaged('iapeer-throwaway-no-plist-xyz')).toBe(false)
98
+ test('no plist in the dir → false (daemon-owned, not launchd-managed)', () => {
99
+ expect(isLaunchdManaged('iapeer-throwaway-no-plist-xyz', { IAPEER_LAUNCHAGENTS_DIR: laDir } as NodeJS.ProcessEnv)).toBe(false)
92
100
  })
93
101
  })
94
102
 
@@ -20,22 +20,27 @@
20
20
  // unit-testable with no network and no launchctl; the defaults are the real impls.
21
21
 
22
22
  import { spawnSync } from 'child_process'
23
+ import { connect } from 'net'
23
24
  import { IapError } from '../core/errors.ts'
24
25
  import { IAPEER_VERSION } from '../core/version.ts'
25
26
  import { kickstartDaemon, type DaemonRestartResult } from '../launch/launchd.ts'
27
+ import { defaultDaemonSocketPath } from '../daemon/index.ts'
26
28
 
27
29
  /** The npm package the foundation publishes / updates from. */
28
30
  export const IAPEER_PACKAGE = '@agfpd/iapeer'
29
31
 
30
32
  export interface UpdateDeps {
31
33
  env?: NodeJS.ProcessEnv
32
- /** Reinstall + restart even when already at the latest version. */
34
+ /** Reinstall + restart even when already at the desired version. */
33
35
  force?: boolean
34
36
  /** The currently-installed version (default: this binary's baked IAPEER_VERSION). */
35
37
  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
38
+ /** Install this EXACT version instead of latest (one-shot pin / downgrade /
39
+ * recover-to-a-known-good deeper than the single .prev). Omit latest. */
40
+ targetVersion?: string
41
+ /** Resolve a spec ('latest' or an exact 'X.Y.Z') to the concrete published version it
42
+ * names, or null on a miss / npm error (default: `npm view @agfpd/iapeer@<spec> version`). */
43
+ resolveVersion?: (spec: string, env: NodeJS.ProcessEnv) => string | null
39
44
  /** Pull + rebuild the binary for `version` (default: `npx @agfpd/iapeer@<v> install`).
40
45
  * Returns true on success. */
41
46
  runInstall?: (version: string, env: NodeJS.ProcessEnv) => boolean
@@ -57,11 +62,83 @@ export interface UpdateResult {
57
62
  reason?: string
58
63
  }
59
64
 
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+ // Post-restart health — verify the daemon actually came up, not just that
67
+ // `launchctl kickstart` exited 0. A new binary that fails to boot would otherwise
68
+ // be reported as a successful "restarted". The probe connects to the daemon's unix
69
+ // socket (the always-present same-uid listener it binds right before printing READY);
70
+ // a refused connection = not serving. Requires a short STREAK of successes so a
71
+ // bind-then-crash flap reads as unhealthy, not healthy.
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+
74
+ export interface HealthResult {
75
+ healthy: boolean
76
+ detail?: string
77
+ }
78
+
79
+ export interface HealthOptions {
80
+ env?: NodeJS.ProcessEnv
81
+ timeoutMs?: number
82
+ intervalMs?: number
83
+ /** Consecutive successful probes required to call it healthy (flap guard). */
84
+ needConsecutive?: number
85
+ /** Injectable probe (tests). Default: connect to the daemon's unix socket. */
86
+ probe?: () => Promise<boolean>
87
+ }
88
+
89
+ /** True iff a connection to the daemon's router socket is accepted (it is serving). */
90
+ function probeDaemonSocket(env: NodeJS.ProcessEnv): Promise<boolean> {
91
+ const path = defaultDaemonSocketPath({ env })
92
+ return new Promise(resolve => {
93
+ let settled = false
94
+ const done = (v: boolean): void => {
95
+ if (settled) return
96
+ settled = true
97
+ try {
98
+ sock.destroy()
99
+ } catch {
100
+ /* already gone */
101
+ }
102
+ resolve(v)
103
+ }
104
+ const sock = connect({ path })
105
+ sock.once('connect', () => done(true))
106
+ sock.once('error', () => done(false))
107
+ sock.setTimeout(1000, () => done(false))
108
+ })
109
+ }
110
+
111
+ const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
112
+
113
+ /**
114
+ * Poll the daemon until it is healthy (its socket accepts `needConsecutive`
115
+ * connections in a row) or `timeoutMs` elapses. Under IAPEER_TEST_SANDBOX with no
116
+ * injected probe it short-circuits healthy (never touches a real socket).
117
+ */
118
+ export async function waitForDaemonHealthy(opts: HealthOptions = {}): Promise<HealthResult> {
119
+ const env = opts.env ?? process.env
120
+ if (env.IAPEER_TEST_SANDBOX === '1' && !opts.probe) return { healthy: true, detail: 'skipped-sandbox' }
121
+ const probe = opts.probe ?? (() => probeDaemonSocket(env))
122
+ const timeoutMs = opts.timeoutMs ?? 15_000
123
+ const intervalMs = opts.intervalMs ?? 400
124
+ const need = opts.needConsecutive ?? 2
125
+ const deadline = Date.now() + timeoutMs
126
+ let streak = 0
127
+ for (;;) {
128
+ streak = (await probe()) ? streak + 1 : 0
129
+ if (streak >= need) return { healthy: true }
130
+ if (Date.now() >= deadline) return { healthy: false, detail: `daemon did not become healthy within ${timeoutMs}ms (socket not accepting connections)` }
131
+ await sleep(intervalMs)
132
+ }
133
+ }
134
+
60
135
  const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+].+)?$/
61
136
 
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 })
137
+ /** Default version resolver: `npm view @agfpd/iapeer@<spec> version` (spec = 'latest'
138
+ * or an exact 'X.Y.Z'). Returns the concrete version, or null on a miss / npm error
139
+ * (a non-existent pinned version exits non-zero null a loud "not found"). */
140
+ function defaultResolveVersion(spec: string, env: NodeJS.ProcessEnv): string | null {
141
+ const r = spawnSync('npm', ['view', `${IAPEER_PACKAGE}@${spec}`, 'version'], { encoding: 'utf8', env })
65
142
  if (r.status !== 0) return null
66
143
  const v = (r.stdout ?? '').trim()
67
144
  return SEMVER_RE.test(v) ? v : null
@@ -86,19 +163,29 @@ function defaultRunInstall(version: string, env: NodeJS.ProcessEnv): boolean {
86
163
  export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
87
164
  const env = deps.env ?? process.env
88
165
  const from = deps.currentVersion ?? IAPEER_VERSION
89
- const fetchLatest = deps.fetchLatest ?? defaultFetchLatest
166
+ const resolve = deps.resolveVersion ?? defaultResolveVersion
167
+ const pinned = deps.targetVersion != null && deps.targetVersion !== ''
168
+ const spec = pinned ? deps.targetVersion! : 'latest'
90
169
 
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)` }
170
+ // Resolve the DESIRED version (latest, or the exact pinned version) — and, for a pin,
171
+ // VALIDATE it exists (a non-existent version → null → fail loud, never an npx error).
172
+ const desired = resolve(spec, env)
173
+ if (!desired) {
174
+ return {
175
+ status: 'failed',
176
+ from,
177
+ reason: pinned
178
+ ? `version "${deps.targetVersion}" not found on npm`
179
+ : `could not resolve the latest ${IAPEER_PACKAGE} version from npm (offline / registry error)`,
180
+ }
94
181
  }
95
- if (latest === from && !deps.force) {
96
- return { status: 'already-latest', from, latest }
182
+ if (desired === from && !deps.force) {
183
+ return { status: 'already-latest', from, latest: desired }
97
184
  }
98
185
 
99
186
  const runInstall = deps.runInstall ?? defaultRunInstall
100
- if (!runInstall(latest, env)) {
101
- return { status: 'failed', from, latest, reason: `\`npx ${IAPEER_PACKAGE}@${latest} install\` failed` }
187
+ if (!runInstall(desired, env)) {
188
+ return { status: 'failed', from, latest: desired, reason: `\`npx ${IAPEER_PACKAGE}@${desired} install\` failed` }
102
189
  }
103
190
 
104
191
  const restart = deps.restartDaemon ?? kickstartDaemon
@@ -106,8 +193,8 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
106
193
  return {
107
194
  status: 'updated',
108
195
  from,
109
- to: latest,
110
- latest,
196
+ to: desired,
197
+ latest: desired,
111
198
  daemon: d.state,
112
199
  reason: d.state === 'failed' ? `binary updated but daemon restart failed: ${d.detail ?? ''}`.trim() : undefined,
113
200
  }
@@ -4,25 +4,31 @@
4
4
  // exercised too (a test must never npx-install over the prod binary).
5
5
 
6
6
  import { describe, expect, test } from 'bun:test'
7
- import { IAPEER_VERSION, updateIapeer } from '../index.ts'
7
+ import { IAPEER_VERSION, updateIapeer, waitForDaemonHealthy } from '../index.ts'
8
8
  import type { DaemonRestartResult } from '../launch/launchd.ts'
9
9
 
10
10
  const restarted = (): DaemonRestartResult => ({ state: 'restarted' })
11
11
 
12
- /** A deps bundle with call-tracking spies. */
12
+ /** A deps bundle with call-tracking spies. `latest` is what the resolver returns for
13
+ * the spec (latest OR a pinned `target`); null simulates "not found / unreachable". */
13
14
  function harness(opts: {
14
15
  current: string
15
16
  latest: string | null
17
+ target?: string
16
18
  installOk?: boolean
17
19
  restart?: DaemonRestartResult
18
20
  force?: boolean
19
21
  }) {
20
- const calls = { install: [] as string[], restart: 0 }
22
+ const calls = { install: [] as string[], restart: 0, resolved: [] as string[] }
21
23
  const result = updateIapeer({
22
24
  env: { IAPEER_TEST_SANDBOX: '1' },
23
25
  force: opts.force,
24
26
  currentVersion: opts.current,
25
- fetchLatest: () => opts.latest,
27
+ targetVersion: opts.target,
28
+ resolveVersion: spec => {
29
+ calls.resolved.push(spec)
30
+ return opts.latest
31
+ },
26
32
  runInstall: v => {
27
33
  calls.install.push(v)
28
34
  return opts.installOk ?? true
@@ -62,6 +68,36 @@ describe('updateIapeer — version gate', () => {
62
68
  expect(calls.install).toEqual(['0.3.0']) // installs latest, not current
63
69
  expect(calls.restart).toBe(1)
64
70
  })
71
+
72
+ test('no target → resolves the "latest" spec', () => {
73
+ const { calls } = harness({ current: '0.2.2', latest: '0.3.0' })
74
+ expect(calls.resolved).toEqual(['latest'])
75
+ })
76
+ })
77
+
78
+ describe('updateIapeer — pinned version (one-shot target)', () => {
79
+ test('pin to an exact version → resolves THAT spec and installs it', () => {
80
+ const { result, calls } = harness({ current: '0.2.4', target: '0.2.2', latest: '0.2.2' })
81
+ expect(calls.resolved).toEqual(['0.2.2']) // resolves the pinned spec, not "latest"
82
+ expect(result.status).toBe('updated')
83
+ expect(result.from).toBe('0.2.4')
84
+ expect(result.to).toBe('0.2.2') // a DOWNGRADE — deeper recovery than the single .prev
85
+ expect(calls.install).toEqual(['0.2.2'])
86
+ })
87
+
88
+ test('pinned version already installed → already-at, no install', () => {
89
+ const { result, calls } = harness({ current: '0.2.2', target: '0.2.2', latest: '0.2.2' })
90
+ expect(result.status).toBe('already-latest')
91
+ expect(calls.install).toEqual([])
92
+ })
93
+
94
+ test('pinned version not found on npm → failed (not an npx error)', () => {
95
+ const { result, calls } = harness({ current: '0.2.4', target: '9.9.9', latest: null })
96
+ expect(result.status).toBe('failed')
97
+ expect(result.reason).toMatch(/"9\.9\.9" not found on npm/i)
98
+ expect(calls.install).toEqual([])
99
+ expect(calls.restart).toBe(0)
100
+ })
65
101
  })
66
102
 
67
103
  describe('updateIapeer — failure paths', () => {
@@ -108,7 +144,7 @@ describe('updateIapeer — real-installer sandbox guard', () => {
108
144
  updateIapeer({
109
145
  env: { IAPEER_TEST_SANDBOX: '1' },
110
146
  currentVersion: '0.2.2',
111
- fetchLatest: () => '0.3.0',
147
+ resolveVersion: () => '0.3.0',
112
148
  // runInstall NOT injected → exercises the real defaultRunInstall guard.
113
149
  restartDaemon: restarted,
114
150
  }),
@@ -121,3 +157,35 @@ describe('IAPEER_VERSION', () => {
121
157
  expect(IAPEER_VERSION).toMatch(/^\d+\.\d+\.\d+/)
122
158
  })
123
159
  })
160
+
161
+ /** A probe returning the given sequence (repeats the last element once exhausted). */
162
+ function seqProbe(seq: boolean[]): () => Promise<boolean> {
163
+ let i = 0
164
+ return () => Promise.resolve(seq[Math.min(i++, seq.length - 1)])
165
+ }
166
+
167
+ describe('waitForDaemonHealthy', () => {
168
+ test('two consecutive successes → healthy', async () => {
169
+ const h = await waitForDaemonHealthy({ probe: seqProbe([true, true]), needConsecutive: 2, timeoutMs: 1000, intervalMs: 1 })
170
+ expect(h.healthy).toBe(true)
171
+ })
172
+
173
+ test('a single bind-then-crash flap does NOT read as healthy (streak resets)', async () => {
174
+ // true, false, true, true → first success is wiped by the false; only the trailing
175
+ // pair (true,true) satisfies needConsecutive=2.
176
+ const h = await waitForDaemonHealthy({ probe: seqProbe([true, false, true, true]), needConsecutive: 2, timeoutMs: 1000, intervalMs: 1 })
177
+ expect(h.healthy).toBe(true)
178
+ })
179
+
180
+ test('never responds within the window → unhealthy with a detail', async () => {
181
+ const h = await waitForDaemonHealthy({ probe: seqProbe([false]), needConsecutive: 2, timeoutMs: 40, intervalMs: 5 })
182
+ expect(h.healthy).toBe(false)
183
+ expect(h.detail).toMatch(/did not become healthy/i)
184
+ })
185
+
186
+ test('IAPEER_TEST_SANDBOX with no injected probe → skipped healthy (never hits a real socket)', async () => {
187
+ const h = await waitForDaemonHealthy({ env: { IAPEER_TEST_SANDBOX: '1' } as NodeJS.ProcessEnv })
188
+ expect(h.healthy).toBe(true)
189
+ expect(h.detail).toBe('skipped-sandbox')
190
+ })
191
+ })