@agfpd/iapeer 0.2.3 → 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/bin/iapeer +12 -1
- package/package.json +2 -1
- package/src/cli/index.ts +60 -17
- package/src/install/index.ts +41 -0
- package/src/install/install.test.ts +37 -2
- package/src/lifecycle/lifecycle.test.ts +17 -9
- package/src/update/index.ts +104 -17
- package/src/update/update.test.ts +73 -5
package/bin/iapeer
CHANGED
|
@@ -9,7 +9,18 @@
|
|
|
9
9
|
# to the CLI (`npx @agfpd/iapeer onboard`, `… create <p>`, …).
|
|
10
10
|
set -euo pipefail
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# Resolve the real path of this script BEFORE computing the package root. npm/npx
|
|
13
|
+
# expose the bin as a SYMLINK at node_modules/.bin/iapeer → ../@agfpd/iapeer/bin/iapeer;
|
|
14
|
+
# without resolving it, dirname/.. lands on node_modules (not the package dir) and
|
|
15
|
+
# "$PKG_ROOT/src/cli/index.ts" points at node_modules/src/... → "Module not found".
|
|
16
|
+
# Walk the symlink chain so PKG_ROOT is always the actual package directory.
|
|
17
|
+
SOURCE="${BASH_SOURCE[0]}"
|
|
18
|
+
while [ -L "$SOURCE" ]; do
|
|
19
|
+
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
|
|
20
|
+
SOURCE="$(readlink "$SOURCE")"
|
|
21
|
+
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
|
|
22
|
+
done
|
|
23
|
+
PKG_ROOT="$(cd -P "$(dirname "$SOURCE")/.." && pwd)"
|
|
13
24
|
CLI="$PKG_ROOT/src/cli/index.ts"
|
|
14
25
|
|
|
15
26
|
if ! command -v bun >/dev/null 2>&1; then
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer",
|
|
3
|
-
"version": "0.2.
|
|
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]
|
|
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
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
|
|
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
|
|
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 === '
|
|
579
|
-
? 'daemon
|
|
580
|
-
: r.daemon === '
|
|
581
|
-
?
|
|
582
|
-
: r.daemon
|
|
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
|
package/src/install/index.ts
CHANGED
|
@@ -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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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('
|
|
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
|
|
package/src/update/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
166
|
+
const resolve = deps.resolveVersion ?? defaultResolveVersion
|
|
167
|
+
const pinned = deps.targetVersion != null && deps.targetVersion !== ''
|
|
168
|
+
const spec = pinned ? deps.targetVersion! : 'latest'
|
|
90
169
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 (
|
|
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(
|
|
101
|
-
return { status: 'failed', from, latest, reason: `\`npx ${IAPEER_PACKAGE}@${
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|