@agfpd/iapeer 0.4.54 → 0.4.56
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 +1 -1
- package/src/cli/index.ts +17 -10
- package/src/daemon/index.ts +9 -2
- package/src/install/index.ts +91 -7
- package/src/launch/launchd.ts +20 -2
- package/src/runtime/deploy.ts +31 -8
- package/src/runtime/index.ts +14 -1
- package/src/runtime/update.ts +52 -8
- package/src/tui/dashboard/app.tsx +23 -3
- package/src/tui/dashboard/run.tsx +16 -4
- package/src/uninstall/index.ts +94 -6
- package/src/update/index.ts +91 -15
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -1261,7 +1261,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1261
1261
|
sig: 'default-runtime <runtime> (--peer <p> | --all)',
|
|
1262
1262
|
desc: 'flip the PRIMARY (routing/wake default) — the fleet-switch lever; refuses an undeclared runtime; registry self-healed in the same command',
|
|
1263
1263
|
},
|
|
1264
|
-
{ sig: 'attach <peer> [runtime]', desc: 'ensure-live + resume, then
|
|
1264
|
+
{ sig: 'attach <peer> [runtime]', desc: 'ensure-live + resume, then attach to the pty session (Ctrl-] detaches)' },
|
|
1265
1265
|
{ sig: 'interrupt <peer> [runtime]', desc: 'interrupt the current turn (Escape) — context intact' },
|
|
1266
1266
|
{ sig: 'compact <peer> [runtime]', desc: 'compact the peer\'s dialogue (/compact); resumes clean-asleep first' },
|
|
1267
1267
|
{ sig: 'self-fresh', desc: '(agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh' },
|
|
@@ -2115,7 +2115,12 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2115
2115
|
}
|
|
2116
2116
|
|
|
2117
2117
|
// ── Foundation (the core; a hard failure aborts the cascade) ──
|
|
2118
|
-
|
|
2118
|
+
// updateIapeer owns the whole settle sequence now: restart → health-gate →
|
|
2119
|
+
// (infra recycle + known-good stamp) — В54: infra recycles ONLY after health;
|
|
2120
|
+
// В50: the healthy binary is stamped so the next install may refresh `.prev`;
|
|
2121
|
+
// В53: "already-latest" also verifies the LIVE daemon runs the binary's version
|
|
2122
|
+
// (an interrupted prior update heals with a restart instead of a false no-op).
|
|
2123
|
+
const r = await updateIapeer({ env, force: flags.force === true, targetVersion: pinned })
|
|
2119
2124
|
let foundationHardFail = false
|
|
2120
2125
|
if (r.status === 'failed') {
|
|
2121
2126
|
errOut(`update failed: ${r.reason}\n`)
|
|
@@ -2123,15 +2128,13 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2123
2128
|
} else if (r.status === 'already-latest') {
|
|
2124
2129
|
out(`foundation: already at version ${r.from}\n`)
|
|
2125
2130
|
} else if (r.daemon === 'restarted') {
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
const h = await waitForDaemonHealthy({ env })
|
|
2129
|
-
if (!h.healthy) {
|
|
2130
|
-
errOut(`foundation: updated ${r.from} → ${r.to} but the daemon is NOT healthy after restart (${h.detail}).\nroll back now: iapeer rollback\n`)
|
|
2131
|
+
if (r.healthy !== true) {
|
|
2132
|
+
errOut(`foundation: updated ${r.from} → ${r.to} but ${r.reason ?? 'the daemon is NOT healthy after restart'}.\nroll back now: iapeer rollback\n`)
|
|
2131
2133
|
foundationHardFail = true
|
|
2132
2134
|
} else {
|
|
2133
2135
|
const note = infraRecycleNote(r.infra)
|
|
2134
|
-
|
|
2136
|
+
const healNote = r.healedStaleDaemon ? ` (live daemon was still on ${r.healedStaleDaemon} — healed)` : ''
|
|
2137
|
+
;(infraRecycleFailed(r.infra) ? errOut : out)(`foundation: updated ${r.from} → ${r.to}; daemon restarted and healthy${healNote}${note}\n`)
|
|
2135
2138
|
}
|
|
2136
2139
|
} else {
|
|
2137
2140
|
const daemonNote =
|
|
@@ -2166,7 +2169,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2166
2169
|
// daemon onto it, and verify health. ONE level deep (single .prev). Cloud is
|
|
2167
2170
|
// still the source of truth — rollback is the local "undo the last update" while
|
|
2168
2171
|
// a fixed version is published.
|
|
2169
|
-
const { rollbackIapeer } = await import('../install/index.ts')
|
|
2172
|
+
const { rollbackIapeer, stampBinaryHealthy } = await import('../install/index.ts')
|
|
2170
2173
|
const rb = rollbackIapeer(env)
|
|
2171
2174
|
if (rb.status === 'failed') {
|
|
2172
2175
|
errOut(`rollback failed: ${rb.reason}\n`)
|
|
@@ -2178,6 +2181,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2178
2181
|
const infraFailed = infraRecycleFailed(infra)
|
|
2179
2182
|
if (restart.state === 'restarted') {
|
|
2180
2183
|
const h = await waitForDaemonHealthy({ env })
|
|
2184
|
+
// В50 — a restored binary that just proved healthy is known-good: stamp it so
|
|
2185
|
+
// the NEXT install may refresh `.prev` from it (sandbox never reaches here).
|
|
2186
|
+
if (h.healthy) stampBinaryHealthy(env)
|
|
2181
2187
|
const msg = h.healthy
|
|
2182
2188
|
? `rolled back to the previous binary; daemon restarted and healthy${infraNote}\n`
|
|
2183
2189
|
: `rolled back, but the daemon is NOT healthy after restart (${h.detail})${infraNote}\n`
|
|
@@ -2234,7 +2240,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2234
2240
|
return 1
|
|
2235
2241
|
}
|
|
2236
2242
|
}
|
|
2237
|
-
const res = executeUninstall({ env, removeCodesignIdentity: removeCodesign })
|
|
2243
|
+
const res = await executeUninstall({ env, removeCodesignIdentity: removeCodesign })
|
|
2238
2244
|
for (const r of res.removed) out(` removed: ${r}\n`)
|
|
2239
2245
|
for (const s of res.skipped) out(` skipped (absent): ${s}\n`)
|
|
2240
2246
|
for (const f of res.failed) errOut(` FAILED: ${f.what} — ${f.detail}\n`)
|
|
@@ -2276,6 +2282,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2276
2282
|
out(
|
|
2277
2283
|
`installed iapeer → ${r.binPath}` +
|
|
2278
2284
|
`${r.prevPath ? ` (previous kept: ${r.prevPath})` : ''}` +
|
|
2285
|
+
`${r.prevKept ? ` (.prev NOT overwritten: ${r.prevKept.reason})` : ''}` +
|
|
2279
2286
|
`${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
|
|
2280
2287
|
signingLine +
|
|
2281
2288
|
` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
|
package/src/daemon/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
} from '../core/constants.ts'
|
|
42
42
|
import { parseSessionName } from '../core/socket.ts'
|
|
43
43
|
import { IapError } from '../core/errors.ts'
|
|
44
|
+
import { IAPEER_VERSION } from '../core/version.ts'
|
|
44
45
|
import { pluginStateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
|
|
45
46
|
import { readPeersIndex, type PeersIndex } from '../registry/index.ts'
|
|
46
47
|
import { resolveCallerIdentity, type CallerIdentity, type ResolvedCaller } from '../identity/index.ts'
|
|
@@ -48,7 +49,10 @@ import { routeSend, type ComposerQueueRouteDeps, type EphemeralRouteDeps, type S
|
|
|
48
49
|
import { appendDeliveryEvent } from './deliverylog.ts'
|
|
49
50
|
|
|
50
51
|
export const CALLER_HEADER = 'x-iapeer-identity'
|
|
51
|
-
|
|
52
|
+
// В53 — the LIVE daemon's version must be observable (the update verb's
|
|
53
|
+
// "already-latest" gate compares it against the installed binary to detect an
|
|
54
|
+
// interrupted update: binary swapped, daemon never restarted). Baked at build time.
|
|
55
|
+
const SERVER_INFO = { name: 'iapeer', version: IAPEER_VERSION }
|
|
52
56
|
|
|
53
57
|
/** Does a LIVE listener answer on this unix socket path? (В28) A successful connect ⇒ a daemon is
|
|
54
58
|
* running; ECONNREFUSED ⇒ a stale socket file (safe to unlink). Bounded by a short timeout so a wedged
|
|
@@ -561,7 +565,10 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
561
565
|
let discoveryPath: string | undefined
|
|
562
566
|
if (opts.discovery) {
|
|
563
567
|
discoveryPath = daemonDiscoveryPath(opts)
|
|
564
|
-
|
|
568
|
+
// `version` (В53): the RUNNING daemon's baked version — lets `iapeer update`
|
|
569
|
+
// detect an interrupted update (binary already at latest, live daemon still on
|
|
570
|
+
// the old code) and heal it with a restart instead of a false "already-latest".
|
|
571
|
+
writeFileAtomic(discoveryPath, `${JSON.stringify({ sock: socketPath ?? null, tcp: url ?? null, version: IAPEER_VERSION })}\n`)
|
|
565
572
|
}
|
|
566
573
|
|
|
567
574
|
const removeArtifacts = (): void => {
|
package/src/install/index.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// signInstalledBinary (signing.ts) re-signs each install with the
|
|
14
14
|
// stable local identity so the designated requirement — and the grants — survive.
|
|
15
15
|
|
|
16
|
-
import { copyFileSync, cpSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
|
|
16
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'fs'
|
|
17
|
+
import { createHash } from 'crypto'
|
|
17
18
|
import { homedir } from 'os'
|
|
18
19
|
import { basename, join, relative, sep } from 'path'
|
|
19
20
|
import { spawnSync } from 'child_process'
|
|
@@ -35,6 +36,10 @@ export interface InstallResult {
|
|
|
35
36
|
binPath: string
|
|
36
37
|
/** The previous binary preserved for rollback (when one existed). */
|
|
37
38
|
prevPath?: string
|
|
39
|
+
/** В50 — set when the outgoing binary was NOT copied to `.prev` because the healthy-stamp
|
|
40
|
+
* says it never passed a post-deploy health-check: the existing `.prev` (last KNOWN-GOOD)
|
|
41
|
+
* is preserved so a retry after a broken release cannot destroy the rollback target. */
|
|
42
|
+
prevKept?: { reason: string }
|
|
38
43
|
/** Bytes of the installed binary. */
|
|
39
44
|
size?: number
|
|
40
45
|
/** Stable-identity re-sign outcome (TCC grants survive updates). Soft: a signing
|
|
@@ -42,6 +47,69 @@ export interface InstallResult {
|
|
|
42
47
|
signing?: SigningOutcome
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
// В50 — known-good `.prev`. Without a gate, EVERY install copies the current
|
|
52
|
+
// binary to `.prev`: a series of broken releases overwrites the known-good
|
|
53
|
+
// rollback target with a broken binary (retry of an update destroys the recovery
|
|
54
|
+
// path). The gate: `iapeer update`/`rollback` stamp `<bin>.healthy` with the
|
|
55
|
+
// sha256 of the binary ONLY after the daemon passed its post-deploy health-check.
|
|
56
|
+
// The next install copies the current binary to `.prev` only when the stamp
|
|
57
|
+
// matches it (proven-healthy). No stamp at all → legacy behavior (copy) — first
|
|
58
|
+
// update after this ships, manual `bin/iapeer install` runs, and fresh bootstraps
|
|
59
|
+
// keep working unchanged. NB the stamp is CROSS-VERSION state: install runs from
|
|
60
|
+
// the NEWLY-FETCHED package's source while the stamp was written by the OUTGOING
|
|
61
|
+
// version — keep the format trivial (hex digest + newline).
|
|
62
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** The healthy-stamp path kept next to the binary. */
|
|
65
|
+
export function iapeerHealthyStampPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
66
|
+
return `${iapeerBinPath(env)}.healthy`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sha256File(path: string): string {
|
|
70
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Record the CURRENT installed binary as known-good (call ONLY after a passed
|
|
74
|
+
* post-deploy health-check). Best-effort by design — a stamp hiccup must never
|
|
75
|
+
* fail a successful update; it only means the next install falls back to
|
|
76
|
+
* preserving the older `.prev`. */
|
|
77
|
+
export function stampBinaryHealthy(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
78
|
+
const binPath = iapeerBinPath(env)
|
|
79
|
+
assertInstallSandboxIsolated(binPath, env)
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(iapeerHealthyStampPath(env), `${sha256File(binPath)}\n`, { mode: 0o644 })
|
|
82
|
+
return true
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The В50 decision: should the CURRENT binary become `.prev`? Copy when the
|
|
89
|
+
* healthy-stamp matches it (proven-good) or when no stamp exists (legacy /
|
|
90
|
+
* first stamped update / manual install); preserve the existing `.prev` when
|
|
91
|
+
* the stamp EXISTS but does not match (the current binary never passed a
|
|
92
|
+
* health-check — a broken release being retried). Pure read — exported for
|
|
93
|
+
* hermetic tests (installIapeer itself runs a full compile). */
|
|
94
|
+
export function shouldCopyCurrentToPrev(binPath: string): { copy: boolean; reason?: string } {
|
|
95
|
+
let stamped: string | undefined
|
|
96
|
+
try {
|
|
97
|
+
const stampPath = `${binPath}.healthy`
|
|
98
|
+
stamped = existsSync(stampPath) ? readFileSync(stampPath, 'utf8').trim() : undefined
|
|
99
|
+
} catch {
|
|
100
|
+
stamped = undefined // unreadable stamp → treat as absent (legacy copy)
|
|
101
|
+
}
|
|
102
|
+
if (stamped && stamped !== sha256File(binPath)) {
|
|
103
|
+
return {
|
|
104
|
+
copy: false,
|
|
105
|
+
reason:
|
|
106
|
+
'current binary never passed a post-deploy health-check (healthy-stamp mismatch) — ' +
|
|
107
|
+
'keeping the existing .prev as the known-good rollback target',
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { copy: true }
|
|
111
|
+
}
|
|
112
|
+
|
|
45
113
|
/**
|
|
46
114
|
* Build the standalone `iapeer` binary from the CLI entrypoint and place it at the
|
|
47
115
|
* stable path ATOMICALLY: `bun build --compile <entry> → <bin>.tmp`, then rename over
|
|
@@ -76,14 +144,30 @@ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = pr
|
|
|
76
144
|
throw new Error(`iapeer build failed: ${(build.stderr ?? '').trim() || `exit ${build.status}`}`)
|
|
77
145
|
}
|
|
78
146
|
let prevPath: string | undefined
|
|
147
|
+
let prevKept: InstallResult['prevKept']
|
|
79
148
|
if (existsSync(binPath)) {
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
149
|
+
// В50 — copy to .prev ONLY when shouldCopyCurrentToPrev says the current binary
|
|
150
|
+
// is trustworthy; otherwise preserve the existing .prev (last known-good).
|
|
151
|
+
const verdict = shouldCopyCurrentToPrev(binPath)
|
|
152
|
+
if (!verdict.copy) {
|
|
153
|
+
prevKept = { reason: verdict.reason! }
|
|
154
|
+
} else {
|
|
155
|
+
prevPath = `${binPath}.prev`
|
|
156
|
+
// COPY (not move) the current binary to .prev so binPath is NEVER absent (audit
|
|
157
|
+
// #7): a move-then-move leaves no binary in the window between the two renames —
|
|
158
|
+
// if the second throws, the prod daemon + infra fleet crash-loop with no bin.
|
|
159
|
+
copyFileSync(binPath, prevPath)
|
|
160
|
+
}
|
|
85
161
|
}
|
|
86
162
|
renameSync(tmp, binPath) // atomic replace in place (POSIX rename over an existing file)
|
|
163
|
+
// The stamp described the binary we just REPLACED — it must not vouch for the new
|
|
164
|
+
// bytes. Health of the new binary is proven (and re-stamped) by the update verb
|
|
165
|
+
// AFTER its health-check passes.
|
|
166
|
+
try {
|
|
167
|
+
unlinkSync(`${binPath}.healthy`)
|
|
168
|
+
} catch {
|
|
169
|
+
/* absent — fine */
|
|
170
|
+
}
|
|
87
171
|
// Stable-identity re-sign (TCC grants survive updates). AFTER the rename: the
|
|
88
172
|
// signature belongs to the final inode at the final path. Soft-fail by design.
|
|
89
173
|
const signing = signInstalledBinary(binPath, env)
|
|
@@ -93,7 +177,7 @@ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = pr
|
|
|
93
177
|
} catch {
|
|
94
178
|
/* best-effort */
|
|
95
179
|
}
|
|
96
|
-
return { binPath, prevPath, size, signing }
|
|
180
|
+
return { binPath, prevPath, prevKept, size, signing }
|
|
97
181
|
}
|
|
98
182
|
|
|
99
183
|
/**
|
package/src/launch/launchd.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// here so the crashloop bound is visible and tunable, not an implicit default; raise
|
|
14
14
|
// throttleIntervalSecs for a wider window). RunAtLoad+KeepAlive = always-on.
|
|
15
15
|
|
|
16
|
-
import { accessSync, constants as FS, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
16
|
+
import { accessSync, constants as FS, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
|
17
17
|
import { homedir } from 'os'
|
|
18
18
|
import { join } from 'path'
|
|
19
19
|
import { spawnSync } from 'child_process'
|
|
@@ -631,6 +631,24 @@ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string
|
|
|
631
631
|
if (!(sandbox && logDir.startsWith(`${realRoot}/`))) {
|
|
632
632
|
mkdirSync(logDir, { recursive: true, mode: 0o700 })
|
|
633
633
|
}
|
|
634
|
-
|
|
634
|
+
// В46 — fail-closed sandbox guard on the PLIST WRITE itself (symmetric to the mkdir
|
|
635
|
+
// guard above and the registry/install guards): a sandboxed test that forgot
|
|
636
|
+
// IAPEER_LAUNCHAGENTS_DIR must NEVER write a real ~/Library/LaunchAgents/
|
|
637
|
+
// com.iapeer.<p>.plist — the live daemon reads that file as the H4 launchd-owned
|
|
638
|
+
// marker, so a leaked test plist silently stops the daemon from waking the
|
|
639
|
+
// matching peer (plist present but never loaded → peer reads as dead forever).
|
|
640
|
+
const realAgents = join(homedir(), 'Library', 'LaunchAgents')
|
|
641
|
+
if (sandbox && path.startsWith(`${realAgents}/`)) {
|
|
642
|
+
throw new IapError(
|
|
643
|
+
`refusing to write a REAL LaunchAgents plist (${path}) under IAPEER_TEST_SANDBOX=1 — ` +
|
|
644
|
+
'set IAPEER_LAUNCHAGENTS_DIR to an isolated path',
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
// В46 — atomic write (tmp + rename): an interrupted direct write leaves a TRUNCATED
|
|
648
|
+
// plist without the ownership sentinel, which the collision guard above then reads
|
|
649
|
+
// as foreign — the peer becomes permanently un-provisionable ("refused-foreign").
|
|
650
|
+
const tmp = `${path}.tmp-${process.pid}`
|
|
651
|
+
writeFileSync(tmp, renderLaunchdPlist(spec), { mode: 0o644 })
|
|
652
|
+
renameSync(tmp, path)
|
|
635
653
|
return path
|
|
636
654
|
}
|
package/src/runtime/deploy.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { homedir } from 'os'
|
|
|
21
21
|
import { type Runtime } from '../core/constants.ts'
|
|
22
22
|
import { IapError } from '../core/errors.ts'
|
|
23
23
|
import { createPeer, type CreatePeerResult } from '../create/index.ts'
|
|
24
|
-
import { readRuntimeManifest, type RuntimeManifest, type RuntimePeerDecl } from './index.ts'
|
|
24
|
+
import { readRuntimeManifest, writeRuntimeManifest, type RuntimeManifest, type RuntimePeerDecl } from './index.ts'
|
|
25
25
|
|
|
26
26
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
27
|
// Built-in runtime → npm package registry (§6): onboard AUTO-resolves
|
|
@@ -131,7 +131,8 @@ export interface InstallRuntimePackageResult {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
/** Injectable npx runner (so tests / a sandbox proof can simulate the package's
|
|
134
|
-
* self-deploy without a published npm package). Default: `npx -y <
|
|
134
|
+
* self-deploy without a published npm package). Default: `npx -y <spec>` — the spec
|
|
135
|
+
* may carry a version pin (`pkg@1.2.3`, В51). */
|
|
135
136
|
export type NpxRunner = (pkg: string, env: NodeJS.ProcessEnv) => { ok: boolean; detail?: string }
|
|
136
137
|
|
|
137
138
|
const defaultNpxRunner: NpxRunner = (pkg, env) => {
|
|
@@ -153,6 +154,10 @@ export interface InstallRuntimePackageOptions {
|
|
|
153
154
|
runtime: Runtime
|
|
154
155
|
/** Override the built-in package mapping (--package). */
|
|
155
156
|
package?: string
|
|
157
|
+
/** В51 — pin the delivered version (`npx -y <pkg>@<version>`): without a pin an
|
|
158
|
+
* unpinned npx can serve a STALE cached/propagating version while the caller
|
|
159
|
+
* reports the npm-latest it resolved. Omit → unpinned (bootstrap convenience). */
|
|
160
|
+
version?: string
|
|
156
161
|
/** Re-run npx even when a manifest is already present (force a package update). */
|
|
157
162
|
force?: boolean
|
|
158
163
|
env?: NodeJS.ProcessEnv
|
|
@@ -164,21 +169,39 @@ export interface InstallRuntimePackageOptions {
|
|
|
164
169
|
* Ensure a runtime PACKAGE is installed (the package self-deploys its bin + manifest
|
|
165
170
|
* via `npx`). IDEMPOTENT: when a manifest is already present (package installed) and
|
|
166
171
|
* not forced, it is a no-op (`skipped`). Otherwise resolves the package (override →
|
|
167
|
-
* built-in registry) and runs npx. No mapping + no
|
|
172
|
+
* the manifest's `package` stamp → built-in registry) and runs npx. No mapping + no
|
|
173
|
+
* --package + no manifest → `no-package`.
|
|
174
|
+
*
|
|
175
|
+
* В52 — after a successful run, the resolved package name is stamped into the
|
|
176
|
+
* manifest the package just wrote (when it differs from what the manifest carries):
|
|
177
|
+
* that is what lets a custom-packaged runtime survive the FU12 cascade and direct
|
|
178
|
+
* `update-runtime` without the operator repeating --package every time.
|
|
168
179
|
*/
|
|
169
180
|
export function installRuntimePackage(opts: InstallRuntimePackageOptions): InstallRuntimePackageResult {
|
|
170
181
|
const env = opts.env ?? process.env
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
if (
|
|
182
|
+
const existing = readRuntimeManifest(opts.runtime, { env })
|
|
183
|
+
const pkg = resolveRuntimePackage(opts.runtime, opts.package ?? existing?.package)
|
|
184
|
+
if (existing && !opts.force) {
|
|
174
185
|
return { runtime: opts.runtime, package: pkg, state: 'skipped' }
|
|
175
186
|
}
|
|
176
187
|
if (!pkg) {
|
|
177
188
|
return { runtime: opts.runtime, state: 'no-package' }
|
|
178
189
|
}
|
|
179
190
|
const run = opts.runNpx ?? defaultNpxRunner
|
|
180
|
-
const
|
|
181
|
-
|
|
191
|
+
const spec = opts.version ? `${pkg}@${opts.version}` : pkg
|
|
192
|
+
const r = run(spec, env)
|
|
193
|
+
if (!r.ok) {
|
|
194
|
+
return { runtime: opts.runtime, package: pkg, state: 'failed', detail: r.detail }
|
|
195
|
+
}
|
|
196
|
+
// В52 — stamp the delivering package into the fresh manifest (best-effort: a
|
|
197
|
+
// missing manifest is the package's own contract breach, surfaced elsewhere).
|
|
198
|
+
try {
|
|
199
|
+
const fresh = readRuntimeManifest(opts.runtime, { env })
|
|
200
|
+
if (fresh && fresh.package !== pkg) writeRuntimeManifest({ ...fresh, package: pkg }, { env })
|
|
201
|
+
} catch {
|
|
202
|
+
/* malformed manifest → the read path reports it loudly on the next use */
|
|
203
|
+
}
|
|
204
|
+
return { runtime: opts.runtime, package: pkg, state: 'ran' }
|
|
182
205
|
}
|
|
183
206
|
|
|
184
207
|
export interface OnboardRuntimeOptions extends DeployRuntimeOptions {
|
package/src/runtime/index.ts
CHANGED
|
@@ -55,6 +55,12 @@ export interface RuntimeManifest {
|
|
|
55
55
|
* `update-runtime` version-gates on it; absent → no gate, the update
|
|
56
56
|
* re-installs idempotently. */
|
|
57
57
|
version?: string
|
|
58
|
+
/** OPTIONAL npm package that delivered this runtime (В52). Stamped by the
|
|
59
|
+
* FOUNDATION after a successful install with an explicit --package override, so
|
|
60
|
+
* a custom-packaged runtime survives the FU12 cascade and a direct
|
|
61
|
+
* `update-runtime` (resolution order: explicit override → this stamp → the
|
|
62
|
+
* built-in registry). A package may also self-declare it. */
|
|
63
|
+
package?: string
|
|
58
64
|
/** OPTIONAL per-peer self-config hook (the shared contract both modes call). */
|
|
59
65
|
selfConfig?: SelfConfigDescriptor
|
|
60
66
|
/** OPTIONAL fixed peer-set (mode a). Omitted by an operator-add runtime (mode b). */
|
|
@@ -113,7 +119,14 @@ function normalizeManifest(raw: unknown, runtime: Runtime): RuntimeManifest {
|
|
|
113
119
|
})
|
|
114
120
|
}
|
|
115
121
|
const version = typeof obj.version === 'string' && obj.version.trim() ? obj.version.trim() : undefined
|
|
116
|
-
|
|
122
|
+
const pkg = typeof obj.package === 'string' && obj.package.trim() ? obj.package.trim() : undefined
|
|
123
|
+
return {
|
|
124
|
+
runtime: declaredRuntime,
|
|
125
|
+
...(version ? { version } : {}),
|
|
126
|
+
...(pkg ? { package: pkg } : {}),
|
|
127
|
+
...(selfConfig ? { selfConfig } : {}),
|
|
128
|
+
...(peers ? { peers } : {}),
|
|
129
|
+
}
|
|
117
130
|
}
|
|
118
131
|
|
|
119
132
|
/** Read a runtime's manifest (~/.iapeer/runtimes/<runtime>/runtime.json), or null when
|
package/src/runtime/update.ts
CHANGED
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
// the stamp cannot be gated — the update proceeds idempotently and says so.
|
|
19
19
|
|
|
20
20
|
import { spawnSync } from 'child_process'
|
|
21
|
-
import {
|
|
21
|
+
import { readdirSync } from 'fs'
|
|
22
|
+
import { join } from 'path'
|
|
23
|
+
import { isRuntime, type Runtime } from '../core/constants.ts'
|
|
22
24
|
import { readPeersIndex } from '../registry/index.ts'
|
|
25
|
+
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
23
26
|
import { readRuntimeManifest } from './index.ts'
|
|
24
27
|
import {
|
|
25
28
|
deployRuntime,
|
|
@@ -106,7 +109,9 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
|
|
|
106
109
|
detail: `no manifest for "${runtime}" — install first: iapeer install-runtime ${runtime}`,
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
|
-
|
|
112
|
+
// В52 — the manifest's `package` stamp (a custom --package install) wins over the
|
|
113
|
+
// built-in registry, so a custom-packaged runtime updates from ITS package.
|
|
114
|
+
const pkg = resolveRuntimePackage(runtime, manifest.package)
|
|
110
115
|
if (!pkg) {
|
|
111
116
|
return { runtime, state: 'not-installed', peers: [], restarted: [], detail: `no package mapping for "${runtime}"` }
|
|
112
117
|
}
|
|
@@ -122,12 +127,31 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
|
|
|
122
127
|
return { runtime, package: pkg, state: 'already-latest', from: installed, to: latest, peers: [], restarted: [] }
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
// Re-install
|
|
126
|
-
|
|
130
|
+
// Re-install, PINNED to the version the gate resolved (В51 — an unpinned npx can
|
|
131
|
+
// serve a stale cached/propagating build while we report "updated → latest").
|
|
132
|
+
const install = installRuntimePackage({ runtime, package: manifest.package, version: latest, force: true, env, runNpx: opts.runNpx })
|
|
127
133
|
if (install.state !== 'ran') {
|
|
128
134
|
return { runtime, package: pkg, state: 'install-failed', from: installed, to: latest, peers: [], restarted: [], detail: install.detail ?? install.state }
|
|
129
135
|
}
|
|
130
136
|
|
|
137
|
+
// В51 — verify the DELIVERED version: re-read the manifest the package just wrote
|
|
138
|
+
// and compare its stamp against the gate's target. A mismatch = the npx cache/CDN
|
|
139
|
+
// served old code — fail LOUD, never report 'updated' for a version that did not
|
|
140
|
+
// arrive. (No stamp in the new manifest → unverifiable; reported, not fatal.)
|
|
141
|
+
const delivered = readRuntimeManifest(runtime, { env })
|
|
142
|
+
if (delivered?.version && delivered.version !== latest) {
|
|
143
|
+
return {
|
|
144
|
+
runtime,
|
|
145
|
+
package: pkg,
|
|
146
|
+
state: 'install-failed',
|
|
147
|
+
from: installed,
|
|
148
|
+
to: latest,
|
|
149
|
+
peers: [],
|
|
150
|
+
restarted: [],
|
|
151
|
+
detail: `delivered version ${delivered.version} ≠ target ${latest} (stale npx cache / CDN propagation) — retry in ~1 min`,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
131
155
|
// IDEMPOTENT re-provision via the SAME deploy path install-runtime uses: blurb
|
|
132
156
|
// sync, re-self-config, no-clobber on live peers. Mode-b (telegram) declares no
|
|
133
157
|
// peers — the deploy is an empty pass, by design.
|
|
@@ -157,6 +181,10 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
|
|
|
157
181
|
)
|
|
158
182
|
}
|
|
159
183
|
|
|
184
|
+
const notes = [
|
|
185
|
+
installed ? undefined : 'no version stamp in the old manifest — gate skipped, re-installed idempotently',
|
|
186
|
+
delivered?.version ? undefined : 'no version stamp in the NEW manifest — delivered version unverified',
|
|
187
|
+
].filter(Boolean)
|
|
160
188
|
return {
|
|
161
189
|
runtime,
|
|
162
190
|
package: pkg,
|
|
@@ -165,16 +193,32 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
|
|
|
165
193
|
to: latest,
|
|
166
194
|
peers,
|
|
167
195
|
restarted,
|
|
168
|
-
detail:
|
|
196
|
+
detail: notes.length ? notes.join('; ') : undefined,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Every runtime with a manifest on THIS host (~/.iapeer/runtimes/<r>/runtime.json) —
|
|
201
|
+
* the union the FU12 cascade must cover. В52: scanning the disk (not the frozen
|
|
202
|
+
* built-in registry) is what keeps a custom `--package` runtime in the cascade. */
|
|
203
|
+
function installedRuntimes(env: NodeJS.ProcessEnv): Runtime[] {
|
|
204
|
+
let entries: string[] = []
|
|
205
|
+
try {
|
|
206
|
+
entries = readdirSync(join(resolveGlobalRoot(env), 'runtimes'))
|
|
207
|
+
} catch {
|
|
208
|
+
/* no runtimes dir → nothing installed */
|
|
169
209
|
}
|
|
210
|
+
return entries.filter(e => isRuntime(e) && readRuntimeManifest(e as Runtime, { env }) !== null) as Runtime[]
|
|
170
211
|
}
|
|
171
212
|
|
|
172
|
-
/** `--all`: update every
|
|
173
|
-
* the
|
|
213
|
+
/** `--all`: update every runtime that is actually installed — the disk scan (manifest
|
|
214
|
+
* present) UNION the built-in registry (В52: a custom --package runtime has no
|
|
215
|
+
* built-in mapping but IS installed; it must not fall out of the cascade). Known
|
|
216
|
+
* runtimes without a manifest are reported as not-installed, never an error. */
|
|
174
217
|
export async function updateAllRuntimes(opts: Omit<UpdateRuntimeOptions, 'runtime'> = {}): Promise<UpdateRuntimeResult[]> {
|
|
175
218
|
const env = opts.env ?? process.env
|
|
176
219
|
const out: UpdateRuntimeResult[] = []
|
|
177
|
-
|
|
220
|
+
const known = new Set<Runtime>([...(Object.keys(RUNTIME_PACKAGES) as Runtime[]), ...installedRuntimes(env)])
|
|
221
|
+
for (const rt of known) {
|
|
178
222
|
if (!readRuntimeManifest(rt, { env })) {
|
|
179
223
|
out.push({ runtime: rt, state: 'not-installed', peers: [], restarted: [], detail: 'not installed — skipped' })
|
|
180
224
|
continue
|
|
@@ -41,6 +41,12 @@ function RuntimeCells({ row }: { row: PeerListing }): React.ReactElement {
|
|
|
41
41
|
)
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Absolute screen reset written on every terminal resize: erase-all + cursor-home.
|
|
45
|
+
* Goes through Ink's OWN write path (useStdout().write → writeToStdout), which
|
|
46
|
+
* erases Ink's frame, writes these bytes, then REPLAYS the last frame with reset
|
|
47
|
+
* erase bookkeeping — so the replay lands at a KNOWN absolute origin (row 1). */
|
|
48
|
+
const RESIZE_SCREEN_RESET = '\x1b[2J\x1b[H'
|
|
49
|
+
|
|
44
50
|
export function DashboardApp({
|
|
45
51
|
env,
|
|
46
52
|
onAction,
|
|
@@ -49,7 +55,7 @@ export function DashboardApp({
|
|
|
49
55
|
onAction: (a: DashAction) => void
|
|
50
56
|
}): React.ReactElement {
|
|
51
57
|
const { exit } = useApp()
|
|
52
|
-
const { stdout } = useStdout()
|
|
58
|
+
const { stdout, write } = useStdout()
|
|
53
59
|
const [rows, setRows] = useState<PeerListing[]>(() => takePeersSnapshot(env))
|
|
54
60
|
const [host, setHost] = useState<HostHeader | null>(null)
|
|
55
61
|
const [now, setNow] = useState<number>(() => Date.now())
|
|
@@ -95,12 +101,26 @@ export function DashboardApp({
|
|
|
95
101
|
}, [env])
|
|
96
102
|
useEffect(() => {
|
|
97
103
|
if (!stdout) return
|
|
98
|
-
const onResize = (): void =>
|
|
104
|
+
const onResize = (): void => {
|
|
105
|
+
// Ink erases frames by RELATIVE cursor math (cursor-up × line-count), and a real
|
|
106
|
+
// terminal displaces alt-screen content on window resize (Apple Terminal shifts it
|
|
107
|
+
// on height changes; a width change can re-wrap) — after which that relative erase
|
|
108
|
+
// misses rows and stale frame fragments survive FOREVER (live incident: a drag-
|
|
109
|
+
// resize stacked 9 footers; Ink itself full-clears only on width SHRINK). So on
|
|
110
|
+
// EVERY resize force an ABSOLUTE reset: wipe the screen + home the cursor, then let
|
|
111
|
+
// Ink replay the frame from that known origin. Written through Ink's write API —
|
|
112
|
+
// never raw to stdout behind its back (the 0.4.51 lesson): writeToStdout keeps its
|
|
113
|
+
// erase model in sync (clear → our bytes → replay with reset line-counts). This
|
|
114
|
+
// listener registers AFTER Ink's own resize handler (mount effect vs render), so
|
|
115
|
+
// the absolute reset is always the LAST word on the resize tick.
|
|
116
|
+
write(RESIZE_SCREEN_RESET)
|
|
117
|
+
setSize({ cols: dim(stdout.columns, 80), rows: dim(stdout.rows, 24) })
|
|
118
|
+
}
|
|
99
119
|
stdout.on('resize', onResize)
|
|
100
120
|
return () => {
|
|
101
121
|
stdout.off('resize', onResize)
|
|
102
122
|
}
|
|
103
|
-
}, [stdout])
|
|
123
|
+
}, [stdout, write])
|
|
104
124
|
|
|
105
125
|
const visible = filterRows(rows, filter)
|
|
106
126
|
const cur = clampCursor(cursor, visible.length)
|
|
@@ -38,13 +38,25 @@ export const DASHBOARD_NOT_INTERACTIVE = -1
|
|
|
38
38
|
/** argv prefix that re-invokes THIS package's CLI (compiled binary by verb / src via
|
|
39
39
|
* bun). Same discriminator as supervisor/index.ts resolveDaemonSelfArgv: `$bunfs` in
|
|
40
40
|
* the module path marks the compiled standalone (existsSync is NO discriminator —
|
|
41
|
-
* Bun intercepts bunfs reads).
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
* Bun intercepts bunfs reads).
|
|
42
|
+
*
|
|
43
|
+
* Discriminate on the MODULE's OWN url — NEVER on a `../..`-navigated path. The
|
|
44
|
+
* compiled bundle lives at `file:///$bunfs/root/<bundle>` (depth 2), so navigating
|
|
45
|
+
* two levels up ESCAPES /$bunfs and URL-normalization silently drops the marker
|
|
46
|
+
* (proven: compiled probe → `new URL('../../cli/index.ts', import.meta.url)` =
|
|
47
|
+
* `/cli/index.ts`). The prior code passed that navigated path here, so the COMPILED
|
|
48
|
+
* dashboard took the src branch and spawned `<binary> /cli/index.ts attach <peer>`
|
|
49
|
+
* → unknown verb → full usage + exit 2 (live incident: Enter→attach dead in
|
|
50
|
+
* `iapeer list`; from-src runs masked it — the navigated path is real there). The
|
|
51
|
+
* cli path is resolved lazily and ONLY on the src branch. */
|
|
52
|
+
export function resolveSelfCliArgv(execPath: string, moduleUrl: string): string[] {
|
|
53
|
+
return moduleUrl.includes('$bunfs')
|
|
54
|
+
? [execPath]
|
|
55
|
+
: [execPath, fileURLToPath(new URL('../../cli/index.ts', moduleUrl))]
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
function selfCliArgv(): string[] {
|
|
47
|
-
return resolveSelfCliArgv(process.execPath,
|
|
59
|
+
return resolveSelfCliArgv(process.execPath, import.meta.url)
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
export interface DashboardOptions {
|
package/src/uninstall/index.ts
CHANGED
|
@@ -18,10 +18,11 @@ import { spawnSync } from 'child_process'
|
|
|
18
18
|
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
19
19
|
import { homedir } from 'os'
|
|
20
20
|
import { join } from 'path'
|
|
21
|
-
import { DAEMON_PLIST_LABEL, IapError } from '../core/index.ts'
|
|
22
|
-
import { iapeerBinPath } from '../install/index.ts'
|
|
21
|
+
import { buildProcessAddress, DAEMON_PLIST_LABEL, IapError } from '../core/index.ts'
|
|
22
|
+
import { iapeerBinPath, iapeerHealthyStampPath } from '../install/index.ts'
|
|
23
23
|
import { SIGNING_IDENTITY_CN } from '../install/signing.ts'
|
|
24
24
|
import { daemonPlistPath } from '../daemon/main.ts'
|
|
25
|
+
import { hostSessionAlive, killPtyHost, listHostedPeers } from '../launch/ptyHost.ts'
|
|
25
26
|
import { isFoundationOwnedPlist, launchAgentsDir } from '../launch/launchd.ts'
|
|
26
27
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
27
28
|
|
|
@@ -32,6 +33,7 @@ export type UninstallActionKind =
|
|
|
32
33
|
| 'remove-file'
|
|
33
34
|
| 'remove-dir'
|
|
34
35
|
| 'bootout-remove-plist'
|
|
36
|
+
| 'stop-pty-sessions'
|
|
35
37
|
| 'strip-profile-lines'
|
|
36
38
|
| 'remove-keychain-identity'
|
|
37
39
|
|
|
@@ -61,6 +63,11 @@ export interface UninstallOptions {
|
|
|
61
63
|
removeCodesignIdentity?: boolean
|
|
62
64
|
/** Injected sys runner (launchctl / security) for hermetic tests. */
|
|
63
65
|
run?: SysRunner
|
|
66
|
+
/** В56 — how long to wait for a booted-out launchd job to actually unload before
|
|
67
|
+
* declaring it stuck (default 10 s; tests inject a short value). */
|
|
68
|
+
launchdWaitMs?: number
|
|
69
|
+
/** В55 — how long to wait for SIGTERM'd pty sessions to die (default 8 s). */
|
|
70
|
+
sessionWaitMs?: number
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
const defaultRun: SysRunner = (cmd, args) => {
|
|
@@ -153,8 +160,26 @@ export function planUninstall(opts: UninstallOptions = {}): UninstallPlan {
|
|
|
153
160
|
})
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
// В55 — live warm-on-demand pty sessions (detached supervisors, NO plist: the
|
|
164
|
+
// launchd guard above cannot see them). Their run-dir/state live INSIDE ~/.iapeer —
|
|
165
|
+
// removing the tree under ~30 running agent processes leaves an orphaned live
|
|
166
|
+
// fleet working out of deleted directories. Stop them FIRST (before remove-dir).
|
|
167
|
+
const hosted = listHostedPeers(env).map(h => buildProcessAddress(h.runtime, h.personality))
|
|
168
|
+
items.push({
|
|
169
|
+
what: `live pty sessions (${hosted.length ? hosted.join(', ') : 'none'})`,
|
|
170
|
+
action: 'stop-pty-sessions',
|
|
171
|
+
present: hosted.length > 0,
|
|
172
|
+
detail: hosted.length ? 'will be stopped before ~/.iapeer is removed' : undefined,
|
|
173
|
+
})
|
|
174
|
+
|
|
156
175
|
items.push({ what: 'binary ~/.local/bin/iapeer', action: 'remove-file', path: bin, present: existsSync(bin) })
|
|
157
176
|
items.push({ what: 'previous binary (.prev)', action: 'remove-file', path: prev, present: existsSync(prev) })
|
|
177
|
+
items.push({
|
|
178
|
+
what: 'healthy-stamp (.healthy)',
|
|
179
|
+
action: 'remove-file',
|
|
180
|
+
path: iapeerHealthyStampPath(env),
|
|
181
|
+
present: existsSync(iapeerHealthyStampPath(env)),
|
|
182
|
+
})
|
|
158
183
|
items.push({ what: 'state + config ~/.iapeer', action: 'remove-dir', path: root, present: existsSync(root) })
|
|
159
184
|
|
|
160
185
|
for (const pf of profileFiles(env)) {
|
|
@@ -181,8 +206,47 @@ export interface UninstallResult {
|
|
|
181
206
|
refused?: { reason: string; foreignLabels: string[] }
|
|
182
207
|
}
|
|
183
208
|
|
|
184
|
-
|
|
185
|
-
|
|
209
|
+
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
210
|
+
|
|
211
|
+
/** В56 — poll `launchctl print gui/<uid>/<label>` until the job is GONE (print exits
|
|
212
|
+
* non-zero) or the deadline passes. bootout is ASYNC in launchd: firing rm/rmdir the
|
|
213
|
+
* moment it returns races the dying daemon's shutdown writes (recreated fragments)
|
|
214
|
+
* and, on a bootout failure, leaves an orphaned KeepAlive job holding the port while
|
|
215
|
+
* the item reads "removed". Same wait-for-gone contract the update cycle uses. */
|
|
216
|
+
async function waitJobGone(run: SysRunner, uid: number, label: string, timeoutMs = 10_000): Promise<boolean> {
|
|
217
|
+
const deadline = Date.now() + timeoutMs
|
|
218
|
+
for (;;) {
|
|
219
|
+
if (run('launchctl', ['print', `gui/${uid}/${label}`]).status !== 0) return true // not loaded → gone
|
|
220
|
+
if (Date.now() >= deadline) return false
|
|
221
|
+
await sleep(250)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** В55 — stop every live warm-on-demand pty session (SIGTERM the supervisor daemon,
|
|
226
|
+
* wait for its pidfile-liveness to drop). Best-effort per session; reports laggards. */
|
|
227
|
+
async function stopHostedSessions(env: NodeJS.ProcessEnv, timeoutMs = 8000): Promise<{ stopped: string[]; stuck: string[] }> {
|
|
228
|
+
const identities = listHostedPeers(env).map(h => buildProcessAddress(h.runtime, h.personality))
|
|
229
|
+
for (const id of identities) {
|
|
230
|
+
try {
|
|
231
|
+
killPtyHost(id, env)
|
|
232
|
+
} catch {
|
|
233
|
+
/* verified-dead / permission — the wait below reports it */
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const deadline = Date.now() + timeoutMs
|
|
237
|
+
let pending = identities
|
|
238
|
+
while (pending.length > 0 && Date.now() < deadline) {
|
|
239
|
+
await sleep(250)
|
|
240
|
+
pending = pending.filter(id => hostSessionAlive(id, env))
|
|
241
|
+
}
|
|
242
|
+
return { stopped: identities.filter(id => !pending.includes(id)), stuck: pending }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Remove a foundation install per the plan. Refuses if a foreign fleet is present.
|
|
246
|
+
* Ordering contract: live pty sessions are stopped and launchd jobs are FULLY
|
|
247
|
+
* unloaded (wait-for-gone) BEFORE ~/.iapeer is removed — a failed unload FAILS the
|
|
248
|
+
* remove-dir step (never delete the tree out from under a still-running daemon). */
|
|
249
|
+
export async function executeUninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
|
|
186
250
|
const env = opts.env ?? process.env
|
|
187
251
|
assertSandboxSafe(env)
|
|
188
252
|
const run = opts.run ?? defaultRun
|
|
@@ -194,6 +258,9 @@ export function executeUninstall(opts: UninstallOptions = {}): UninstallResult {
|
|
|
194
258
|
}
|
|
195
259
|
|
|
196
260
|
const uid = process.getuid?.() ?? 0
|
|
261
|
+
// Set when a launchd job survived bootout (В56) — ~/.iapeer must NOT be removed
|
|
262
|
+
// from under a job launchd may respawn (KeepAlive) into the deleted tree.
|
|
263
|
+
let jobStillLoaded: string | undefined
|
|
197
264
|
for (const item of plan.items) {
|
|
198
265
|
if (!item.present) {
|
|
199
266
|
res.skipped.push(item.what)
|
|
@@ -203,16 +270,37 @@ export function executeUninstall(opts: UninstallOptions = {}): UninstallResult {
|
|
|
203
270
|
switch (item.action) {
|
|
204
271
|
case 'bootout-remove-plist': {
|
|
205
272
|
const label = (item.path ?? '').replace(/\.plist$/, '').split('/').pop() ?? ''
|
|
206
|
-
run('launchctl', ['bootout', `gui/${uid}/${label}`]) //
|
|
273
|
+
run('launchctl', ['bootout', `gui/${uid}/${label}`]) // not-loaded → non-zero, benign (wait-for-gone verifies)
|
|
274
|
+
if (!(await waitJobGone(run, uid, label, opts.launchdWaitMs))) {
|
|
275
|
+
jobStillLoaded = label
|
|
276
|
+
res.failed.push({ what: item.what, detail: `job ${label} still loaded after bootout (wait-for-gone timeout)` })
|
|
277
|
+
continue // plist kept — the operator can bootout manually and re-run
|
|
278
|
+
}
|
|
207
279
|
if (item.path) rmSync(item.path, { force: true })
|
|
208
280
|
break
|
|
209
281
|
}
|
|
282
|
+
case 'stop-pty-sessions': {
|
|
283
|
+
const r = await stopHostedSessions(env, opts.sessionWaitMs)
|
|
284
|
+
if (r.stuck.length > 0) {
|
|
285
|
+
res.failed.push({ what: item.what, detail: `still alive after SIGTERM+wait: ${r.stuck.join(', ')}` })
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
break
|
|
289
|
+
}
|
|
210
290
|
case 'remove-file':
|
|
211
291
|
if (item.path) rmSync(item.path, { force: true })
|
|
212
292
|
break
|
|
213
|
-
case 'remove-dir':
|
|
293
|
+
case 'remove-dir': {
|
|
294
|
+
if (jobStillLoaded) {
|
|
295
|
+
res.failed.push({
|
|
296
|
+
what: item.what,
|
|
297
|
+
detail: `NOT removed — launchd job ${jobStillLoaded} is still loaded (KeepAlive would respawn into a deleted tree); bootout it manually, then re-run uninstall`,
|
|
298
|
+
})
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
214
301
|
if (item.path) rmSync(item.path, { recursive: true, force: true })
|
|
215
302
|
break
|
|
303
|
+
}
|
|
216
304
|
case 'strip-profile-lines':
|
|
217
305
|
if (item.path) stripInstallerLines(item.path)
|
|
218
306
|
break
|
package/src/update/index.ts
CHANGED
|
@@ -29,13 +29,14 @@
|
|
|
29
29
|
// unit-testable with no network and no launchctl; the defaults are the real impls.
|
|
30
30
|
|
|
31
31
|
import { spawnSync } from 'child_process'
|
|
32
|
-
import { mkdtempSync, readdirSync, rmSync } from 'fs'
|
|
32
|
+
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs'
|
|
33
33
|
import { connect } from 'net'
|
|
34
34
|
import { tmpdir } from 'os'
|
|
35
35
|
import { join } from 'path'
|
|
36
36
|
import { isInfraRuntime } from '../core/constants.ts'
|
|
37
37
|
import { IapError } from '../core/errors.ts'
|
|
38
38
|
import { IAPEER_VERSION } from '../core/version.ts'
|
|
39
|
+
import { stampBinaryHealthy } from '../install/index.ts'
|
|
39
40
|
import { readPeersIndex } from '../registry/index.ts'
|
|
40
41
|
import {
|
|
41
42
|
cycleDaemon,
|
|
@@ -46,7 +47,7 @@ import {
|
|
|
46
47
|
type DaemonRestartResult,
|
|
47
48
|
type LaunchdJobCycleResult,
|
|
48
49
|
} from '../launch/launchd.ts'
|
|
49
|
-
import { defaultDaemonSocketPath } from '../daemon/index.ts'
|
|
50
|
+
import { daemonDiscoveryPath, defaultDaemonSocketPath } from '../daemon/index.ts'
|
|
50
51
|
// Type-only (erased at runtime → no import cycle): the cascade tail renders these
|
|
51
52
|
// shapes but receives the component-updaters as injected deps.
|
|
52
53
|
import type { UpdateRuntimeResult } from '../runtime/update.ts'
|
|
@@ -76,6 +77,15 @@ export interface UpdateDeps {
|
|
|
76
77
|
/** Re-register loaded foundation-owned infra launchd jobs whose plists run the
|
|
77
78
|
* installed iapeer binary (default: registry scan + sentinel guard). */
|
|
78
79
|
recycleInfraJobs?: (env: NodeJS.ProcessEnv) => InfraJobRecycleResult[]
|
|
80
|
+
/** В54 — post-restart health gate (default: waitForDaemonHealthy). Infra jobs are
|
|
81
|
+
* recycled ONLY after this passes — never onto an unproven binary. */
|
|
82
|
+
waitHealthy?: (env: NodeJS.ProcessEnv) => Promise<HealthResult>
|
|
83
|
+
/** В53 — the LIVE daemon's version (default: read router.json's `version`), or null
|
|
84
|
+
* when no live daemon / unreadable / pre-В53 daemon (no version field). */
|
|
85
|
+
liveDaemonVersion?: (env: NodeJS.ProcessEnv) => string | null
|
|
86
|
+
/** В50 — record the installed binary as known-good after a passed health-check
|
|
87
|
+
* (default: stampBinaryHealthy; skipped under the test sandbox). */
|
|
88
|
+
stampHealthy?: (env: NodeJS.ProcessEnv) => boolean
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
export interface InfraJobRecycleResult {
|
|
@@ -96,8 +106,15 @@ export interface UpdateResult {
|
|
|
96
106
|
to?: string
|
|
97
107
|
/** What happened to the live daemon (only on 'updated'). */
|
|
98
108
|
daemon?: DaemonRestartResult['state']
|
|
109
|
+
/** Post-restart health verdict (set only when the daemon was restarted): true =
|
|
110
|
+
* socket accepting (infra recycled + binary stamped known-good), false = did NOT
|
|
111
|
+
* come up (the rollback cue). Undefined when there was nothing to health-check. */
|
|
112
|
+
healthy?: boolean
|
|
99
113
|
/** Foundation-owned infra jobs recycled after the binary swap (only on 'updated'). */
|
|
100
114
|
infra?: InfraJobRecycleResult[]
|
|
115
|
+
/** В53 — set when the "already-latest" gate found the LIVE daemon running an older
|
|
116
|
+
* version (interrupted prior update) and healed it with a restart. */
|
|
117
|
+
healedStaleDaemon?: string
|
|
101
118
|
/** Human reason on 'failed' / extra detail on a daemon restart hiccup. */
|
|
102
119
|
reason?: string
|
|
103
120
|
}
|
|
@@ -255,13 +272,57 @@ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process
|
|
|
255
272
|
return out
|
|
256
273
|
}
|
|
257
274
|
|
|
275
|
+
/** В53 default — the live daemon's version from the discovery file (router.json).
|
|
276
|
+
* Null when there is no live daemon (file absent — it is removed on clean
|
|
277
|
+
* shutdown), the file is unreadable, or the daemon predates the version field. */
|
|
278
|
+
function defaultLiveDaemonVersion(env: NodeJS.ProcessEnv): string | null {
|
|
279
|
+
try {
|
|
280
|
+
const raw = JSON.parse(readFileSync(daemonDiscoveryPath({ env }), 'utf8')) as { version?: unknown }
|
|
281
|
+
return typeof raw.version === 'string' && SEMVER_RE.test(raw.version) ? raw.version : null
|
|
282
|
+
} catch {
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Restart → health-gate → (recycle infra + stamp known-good) — the SETTLE half every
|
|
288
|
+
* update path shares. В54: infra jobs are recycled ONLY after the daemon proved
|
|
289
|
+
* healthy — a broken release must take down the daemon alone, never also flip
|
|
290
|
+
* telegram/notifier onto the broken binary (rollback re-cycles them after restoring
|
|
291
|
+
* `.prev`). В50: the healthy binary is stamped known-good so the NEXT install may
|
|
292
|
+
* refresh `.prev` from it. 'not-loaded' (no daemon on this host) keeps the legacy
|
|
293
|
+
* behavior — recycle loaded infra jobs, nothing to health-check or stamp. */
|
|
294
|
+
async function settleDaemon(
|
|
295
|
+
d: DaemonRestartResult,
|
|
296
|
+
env: NodeJS.ProcessEnv,
|
|
297
|
+
deps: UpdateDeps,
|
|
298
|
+
): Promise<{ healthy?: boolean; infra: InfraJobRecycleResult[]; reason?: string }> {
|
|
299
|
+
if (d.state === 'failed') {
|
|
300
|
+
return { infra: [], reason: `binary updated but daemon restart failed: ${d.detail ?? ''}`.trim() }
|
|
301
|
+
}
|
|
302
|
+
const recycleInfra = deps.recycleInfraJobs ?? recycleFoundationOwnedInfraJobs
|
|
303
|
+
if (d.state !== 'restarted') {
|
|
304
|
+
return { infra: recycleInfra(env) } // not-loaded: no live daemon → no health verdict, no stamp
|
|
305
|
+
}
|
|
306
|
+
const h = await (deps.waitHealthy ?? ((e: NodeJS.ProcessEnv) => waitForDaemonHealthy({ env: e })))(env)
|
|
307
|
+
if (!h.healthy) {
|
|
308
|
+
return { healthy: false, infra: [], reason: `daemon is NOT healthy after restart (${h.detail ?? 'socket not accepting'})` }
|
|
309
|
+
}
|
|
310
|
+
// Default stamp is skipped under the test sandbox (a real stamp writes next to the
|
|
311
|
+
// REAL binary); tests that care inject stampHealthy.
|
|
312
|
+
const stamp = deps.stampHealthy ?? ((e: NodeJS.ProcessEnv) => (e.IAPEER_TEST_SANDBOX === '1' ? false : stampBinaryHealthy(e)))
|
|
313
|
+
stamp(env)
|
|
314
|
+
return { healthy: true, infra: recycleInfra(env) }
|
|
315
|
+
}
|
|
316
|
+
|
|
258
317
|
/**
|
|
259
318
|
* Update the foundation to the latest published version (cloud-only) and restart
|
|
260
319
|
* the daemon onto it. Idempotent + version-gated: a no-op "already-latest" when the
|
|
261
|
-
* installed version equals the published one (unless `force`)
|
|
262
|
-
*
|
|
320
|
+
* installed version equals the published one (unless `force`) AND the live daemon
|
|
321
|
+
* actually runs it (В53 — an interrupted prior update left the binary swapped but
|
|
322
|
+
* the daemon on old code; that heals with a restart, not a false no-op). Pure-ish —
|
|
323
|
+
* every effect is injected, so this is fully unit-testable.
|
|
263
324
|
*/
|
|
264
|
-
export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
|
|
325
|
+
export async function updateIapeer(deps: UpdateDeps = {}): Promise<UpdateResult> {
|
|
265
326
|
const env = deps.env ?? process.env
|
|
266
327
|
const from = deps.currentVersion ?? IAPEER_VERSION
|
|
267
328
|
const resolve = deps.resolveVersion ?? defaultResolveVersion
|
|
@@ -281,7 +342,26 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
|
|
|
281
342
|
}
|
|
282
343
|
}
|
|
283
344
|
if (desired === from && !deps.force) {
|
|
284
|
-
|
|
345
|
+
// В53 — the binary is at the target, but is the LIVE daemon running it? A prior
|
|
346
|
+
// update that swapped the binary and then died before/inside cycleDaemon leaves
|
|
347
|
+
// the fleet on the old core FOREVER while every retry says "already-latest".
|
|
348
|
+
const live = (deps.liveDaemonVersion ?? defaultLiveDaemonVersion)(env)
|
|
349
|
+
if (!live || live === from) {
|
|
350
|
+
return { status: 'already-latest', from, latest: desired }
|
|
351
|
+
}
|
|
352
|
+
const d = (deps.restartDaemon ?? cycleDaemon)(env)
|
|
353
|
+
const settled = await settleDaemon(d, env, deps)
|
|
354
|
+
return {
|
|
355
|
+
status: 'updated',
|
|
356
|
+
from: live,
|
|
357
|
+
to: desired,
|
|
358
|
+
latest: desired,
|
|
359
|
+
daemon: d.state,
|
|
360
|
+
healthy: settled.healthy,
|
|
361
|
+
infra: settled.infra,
|
|
362
|
+
healedStaleDaemon: live,
|
|
363
|
+
reason: settled.reason,
|
|
364
|
+
}
|
|
285
365
|
}
|
|
286
366
|
|
|
287
367
|
const runInstall = deps.runInstall ?? defaultRunInstall
|
|
@@ -300,21 +380,17 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
|
|
|
300
380
|
}
|
|
301
381
|
}
|
|
302
382
|
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
const recycleInfra = deps.recycleInfraJobs ?? recycleFoundationOwnedInfraJobs
|
|
306
|
-
// If the daemon re-registration itself failed, do NOT widen the blast radius by
|
|
307
|
-
// cycling infra jobs onto a binary whose daemon could not come up. Rollback will
|
|
308
|
-
// re-cycle them after restoring `.prev`.
|
|
309
|
-
const infra = d.state === 'failed' ? [] : recycleInfra(env)
|
|
383
|
+
const d = (deps.restartDaemon ?? cycleDaemon)(env)
|
|
384
|
+
const settled = await settleDaemon(d, env, deps)
|
|
310
385
|
return {
|
|
311
386
|
status: 'updated',
|
|
312
387
|
from,
|
|
313
388
|
to: desired,
|
|
314
389
|
latest: desired,
|
|
315
390
|
daemon: d.state,
|
|
316
|
-
|
|
317
|
-
|
|
391
|
+
healthy: settled.healthy,
|
|
392
|
+
infra: settled.infra,
|
|
393
|
+
reason: settled.reason,
|
|
318
394
|
}
|
|
319
395
|
}
|
|
320
396
|
|