@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 +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/launch/adapters/claude.ts +40 -13
- package/src/launch/launch.test.ts +27 -6
- package/src/lifecycle/lifecycle.test.ts +17 -9
- package/src/update/index.ts +104 -17
- package/src/update/update.test.ts +73 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer",
|
|
3
|
-
"version": "0.2.
|
|
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]
|
|
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
|
+
})
|
|
@@ -30,15 +30,17 @@ import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, Runt
|
|
|
30
30
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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'
|
|
41
|
-
*
|
|
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
|
-
* (--
|
|
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
|
-
* - '--
|
|
121
|
-
*
|
|
122
|
+
* - '--continue' when spec.resume — continue 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.
|
|
141
|
+
...(spec.resume ? ['--continue'] : []),
|
|
133
142
|
...(spec.extraArgs ?? []),
|
|
134
143
|
]
|
|
135
144
|
},
|
|
136
145
|
|
|
137
146
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
'--
|
|
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:
|
|
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('
|
|
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
|
|
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
|
+
})
|