@agfpd/iapeer 0.2.4 → 0.2.6

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.6",
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
+ })
@@ -30,15 +30,17 @@ import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, Runt
30
30
  // ─────────────────────────────────────────────────────────────────────────────
31
31
 
32
32
  /**
33
- * The known claude startup dialogs whose default-highlighted option is the
34
- * proceed path, so a single Enter clears each:
33
+ * Markers that a claude startup dialog / picker is on screen — used by isInputReady
34
+ * to GATE delivery (a dialog up not ready). The KEYS that clear each live in
35
+ * bootDialogKeys (NOT uniformly Enter — the resume picker must be NAVIGATED, see there):
35
36
  * - 'trust this folder' — first-run folder-trust modal.
36
37
  * - 'Allow external CLAUDE.md file imports?' — external-import consent.
37
38
  * - 'I am using this for local development' — dev-channels accept
38
39
  * (claude-start.sh:337), shown when PEER_START_ARGS carries
39
40
  * --dangerously-load-development-channels.
40
- * - 'Resume from summary' / 'Resuming the full session' the --resume picker.
41
- * Same set, same order as lifecycle.claudeBootDialog (index.ts:281-289).
41
+ * - 'Resume from summary' the resume compact-picker (default cursor =
42
+ * "summary (recommended)", which compacts; bootDialogKeys picks "full" instead).
43
+ * - 'Resuming the full session' — the post-select load state (still not ready).
42
44
  */
43
45
  const CLAUDE_BOOT_DIALOG_MARKERS = [
44
46
  'trust this folder',
@@ -104,7 +106,7 @@ export const claudeAdapter: RuntimeAdapter = {
104
106
 
105
107
  /**
106
108
  * argv = claudeBin + headless flags + (system-prompt-file when set) +
107
- * (--resume <ref> when resumeRef set) + extraArgs.
109
+ * (--continue when resuming) + extraArgs.
108
110
  *
109
111
  * - '--dangerously-skip-permissions' claude-start.sh:318, spawner.ts:776 —
110
112
  * headless peer has no interactive owner to grant per-tool permission.
@@ -117,8 +119,15 @@ export const claudeAdapter: RuntimeAdapter = {
117
119
  * ONLY when set (a tui runtime that usesDoctrine composes one). Swaps the
118
120
  * CC coding baseline for the merged peer doctrine; plugin/MCP/CLAUDE.md
119
121
  * layers stay intact (claude-start.sh:293-303).
120
- * - '--resume', spec.resumeRef spawner.ts:779-781ONLY when the caller
121
- * pre-resolved a uuid (resolveResume); never a silent fresh fallback.
122
+ * - '--continue' when spec.resumecontinue the cwd's MOST-RECENT session
123
+ * (one session-lineage per peer cwd, so most-recent == the warm peer's
124
+ * session, == the newest transcript resolveResume validated). NOT
125
+ * '--resume <uuid>': in claude 2.1.169 `--resume <arg>` treats arg as a
126
+ * SEARCH QUERY (opens a session-list picker), not a session-id — so a bare
127
+ * uuid no longer resumes directly. `--continue` resumes the last session
128
+ * with no session-list step. The summary-vs-full compact picker still
129
+ * appears (handled in bootDialogKeys: pick "full", never the recommended
130
+ * summary). resolveResume still gates resume-vs-fresh upstream (fail-loud).
122
131
  * - ...extraArgs PEER_START_ARGS passthrough (LaunchSpec.extraArgs).
123
132
  * NO currency — no marketplace/install/update on this path.
124
133
  */
@@ -129,19 +138,37 @@ export const claudeAdapter: RuntimeAdapter = {
129
138
  '--disallowedTools',
130
139
  'AskUserQuestion',
131
140
  ...(spec.systemPromptFile ? ['--system-prompt-file', spec.systemPromptFile] : []),
132
- ...(spec.resumeRef ? ['--resume', spec.resumeRef] : []),
141
+ ...(spec.resume ? ['--continue'] : []),
133
142
  ...(spec.extraArgs ?? []),
134
143
  ]
135
144
  },
136
145
 
137
146
  /**
138
- * A visible startup dialog ['Enter'] to clear it (its default-highlighted
139
- * option is the proceed path), else null. Same marker set as
140
- * lifecycle.claudeBootDialog; claude-start.sh:341 (dev-channels) and the
141
- * folder-trust/import/resume modals all accept a bare Enter.
147
+ * Map a visible startup dialog to the keys that clear it correctly:
148
+ *
149
+ * - RESUME COMPACT-PICKER ('Resume from summary …') ['Down','Enter'].
150
+ * This menu's cursor DEFAULTS to "1. Resume from summary (recommended)",
151
+ * and selecting it COMPACTS the session (claude implements that choice as an
152
+ * internal /compact). A bare Enter would therefore silently compact a warm
153
+ * peer on EVERY idle-reap→resume — losing its full context. Move the cursor
154
+ * DOWN one to "2. Resume full session as-is" and confirm, keeping full
155
+ * context. (Verified against the live picker: ❯ on option 1, ↑↓ navigation,
156
+ * "Enter to confirm".)
157
+ * - OTHER MODALS (folder-trust / external-import / dev-channels) → ['Enter']:
158
+ * their default-highlighted option IS the proceed path, so a bare Enter clears
159
+ * each (claude-start.sh:341).
160
+ * - anything else (incl. the post-select "Resuming…" load state) → null (wait).
142
161
  */
143
162
  bootDialogKeys(pane: string): string[] | null {
144
- return anyBootDialog(pane) ? ['Enter'] : null
163
+ if (pane.includes('Resume from summary')) return ['Down', 'Enter']
164
+ if (
165
+ pane.includes('trust this folder') ||
166
+ pane.includes('Allow external CLAUDE.md file imports?') ||
167
+ pane.includes('I am using this for local development')
168
+ ) {
169
+ return ['Enter']
170
+ }
171
+ return null
145
172
  },
146
173
 
147
174
  /**
@@ -120,9 +120,12 @@ describe('claudeAdapter', () => {
120
120
  ])
121
121
  })
122
122
 
123
- test('buildArgv with system-prompt-file + resume + extras (exact flags/order)', () => {
123
+ test('buildArgv with system-prompt-file + resume + extras → --continue (NOT --resume <uuid>)', () => {
124
+ // resume uses `--continue` (continue the cwd's most-recent session), NOT
125
+ // `--resume <uuid>` — in claude 2.1.169 `--resume <arg>` is a search query, not a
126
+ // session-id. resumeRef is set by the daemon but no longer consumed by the launch.
124
127
  const argv = claudeAdapter.buildArgv(
125
- spec({ systemPromptFile: '/tmp/sp.md', resumeRef: 'uuid-1', extraArgs: ['--foo'] }),
128
+ spec({ systemPromptFile: '/tmp/sp.md', resume: true, resumeRef: 'uuid-1', extraArgs: ['--foo'] }),
126
129
  cfg,
127
130
  )
128
131
  expect(argv).toEqual([
@@ -132,10 +135,17 @@ describe('claudeAdapter', () => {
132
135
  'AskUserQuestion',
133
136
  '--system-prompt-file',
134
137
  '/tmp/sp.md',
135
- '--resume',
136
- 'uuid-1',
138
+ '--continue',
137
139
  '--foo',
138
140
  ])
141
+ expect(argv).not.toContain('--resume')
142
+ expect(argv).not.toContain('uuid-1')
143
+ })
144
+
145
+ test('buildArgv: resume FALSE (fresh) → no --continue / --resume', () => {
146
+ const argv = claudeAdapter.buildArgv(spec({ resume: false, resumeRef: 'uuid-1' }), cfg)
147
+ expect(argv).not.toContain('--continue')
148
+ expect(argv).not.toContain('--resume')
139
149
  })
140
150
 
141
151
  test('buildArgv carries NO currency (no marketplace/plugin tokens)', () => {
@@ -153,12 +163,23 @@ describe('claudeAdapter', () => {
153
163
  expect(claudeAdapter.isInputReady('❯ just a prompt')).toBe(false)
154
164
  })
155
165
 
156
- test('bootDialogKeys: a dialog → [Enter], clean pane → null', () => {
166
+ test('bootDialogKeys: proceed-modals → [Enter]; clean/load pane → null', () => {
157
167
  expect(claudeAdapter.bootDialogKeys('I am using this for local development')).toEqual(['Enter'])
158
- expect(claudeAdapter.bootDialogKeys('Resuming the full session')).toEqual(['Enter'])
168
+ expect(claudeAdapter.bootDialogKeys('trust this folder')).toEqual(['Enter'])
169
+ expect(claudeAdapter.bootDialogKeys('Allow external CLAUDE.md file imports?')).toEqual(['Enter'])
170
+ // the post-select "Resuming…" load state is NOT a modal to Enter — just wait.
171
+ expect(claudeAdapter.bootDialogKeys('Resuming the full session')).toBeNull()
159
172
  expect(claudeAdapter.bootDialogKeys('❯ ready')).toBeNull()
160
173
  })
161
174
 
175
+ test('bootDialogKeys: resume compact-picker → [Down, Enter] (pick "full", NOT the recommended summary)', () => {
176
+ // Default cursor is "1. Resume from summary (recommended)"; bare Enter would compact.
177
+ // Move DOWN to "2. Resume full session as-is" and confirm → full context preserved.
178
+ expect(
179
+ claudeAdapter.bootDialogKeys('❯ 1. Resume from summary (recommended)\n 2. Resume full session as-is'),
180
+ ).toEqual(['Down', 'Enter'])
181
+ })
182
+
162
183
  test('permissionDialog: proceed prompt → active, [Enter]', () => {
163
184
  expect(claudeAdapter.permissionDialogActive('Do you want to proceed?')).toBe(true)
164
185
  expect(claudeAdapter.permissionDialogActive('nothing')).toBe(false)
@@ -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
+ })