borgmcp 2.3.0 → 2.4.1
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/README.md +3 -0
- package/dist/assimilate-cmd.d.ts +6 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +31 -7
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +2 -0
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +10 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +2 -0
- package/dist/cli-help.js.map +1 -1
- package/dist/cli-platform.d.ts +1 -0
- package/dist/cli-platform.d.ts.map +1 -1
- package/dist/cli-platform.js +5 -1
- package/dist/cli-platform.js.map +1 -1
- package/dist/console-prefix.d.ts +9 -10
- package/dist/console-prefix.d.ts.map +1 -1
- package/dist/console-prefix.js +23 -20
- package/dist/console-prefix.js.map +1 -1
- package/dist/cubes.d.ts +11 -0
- package/dist/cubes.d.ts.map +1 -1
- package/dist/cubes.js +10 -0
- package/dist/cubes.js.map +1 -1
- package/dist/display-identity.d.ts +34 -0
- package/dist/display-identity.d.ts.map +1 -0
- package/dist/display-identity.js +88 -0
- package/dist/display-identity.js.map +1 -0
- package/dist/docs-sections.d.ts.map +1 -1
- package/dist/docs-sections.js +9 -0
- package/dist/docs-sections.js.map +1 -1
- package/dist/drone-management.d.ts.map +1 -1
- package/dist/drone-management.js +6 -0
- package/dist/drone-management.js.map +1 -1
- package/dist/inbox-monitor.d.ts.map +1 -1
- package/dist/inbox-monitor.js +12 -0
- package/dist/inbox-monitor.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +58 -16
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +41 -15
- package/dist/log-stream.js.map +1 -1
- package/dist/parse-assimilate-args.d.ts +1 -1
- package/dist/parse-assimilate-args.d.ts.map +1 -1
- package/dist/parse-assimilate-args.js +5 -2
- package/dist/parse-assimilate-args.js.map +1 -1
- package/dist/regen-format.d.ts +2 -2
- package/dist/regen-format.d.ts.map +1 -1
- package/dist/regen-format.js +6 -5
- package/dist/regen-format.js.map +1 -1
- package/dist/regen.js +6 -2
- package/dist/regen.js.map +1 -1
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +8 -1
- package/dist/remote-client.js.map +1 -1
- package/dist/reset-local-seat-cmd.d.ts +8 -0
- package/dist/reset-local-seat-cmd.d.ts.map +1 -1
- package/dist/reset-local-seat-cmd.js +21 -2
- package/dist/reset-local-seat-cmd.js.map +1 -1
- package/dist/seat-reattach-guard.d.ts +21 -0
- package/dist/seat-reattach-guard.d.ts.map +1 -0
- package/dist/seat-reattach-guard.js +83 -0
- package/dist/seat-reattach-guard.js.map +1 -0
- package/dist/seats.d.ts +6 -2
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +37 -14
- package/dist/seats.js.map +1 -1
- package/dist/update-cmd.d.ts.map +1 -1
- package/dist/update-cmd.js +38 -61
- package/dist/update-cmd.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +3 -3
- package/docs/LOCAL_SERVER.md +4 -0
- package/docs/RELEASING.md +28 -4
- package/docs/SEAT_LIFECYCLE.md +242 -0
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +45 -7
- package/src/assimilate-deps.ts +2 -0
- package/src/claude.ts +11 -0
- package/src/cli-help.ts +2 -0
- package/src/cli-platform.ts +5 -2
- package/src/console-prefix.ts +25 -18
- package/src/cubes.ts +16 -0
- package/src/display-identity.ts +130 -0
- package/src/docs-sections.ts +9 -0
- package/src/drone-management.ts +6 -0
- package/src/inbox-monitor.ts +19 -0
- package/src/index.ts +66 -20
- package/src/log-stream.ts +38 -14
- package/src/parse-assimilate-args.ts +4 -2
- package/src/regen-format.ts +6 -5
- package/src/regen.ts +11 -2
- package/src/remote-client.ts +8 -0
- package/src/reset-local-seat-cmd.ts +26 -1
- package/src/seat-reattach-guard.ts +113 -0
- package/src/seats.ts +36 -14
- package/src/update-cmd.ts +36 -58
|
@@ -25,6 +25,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
25
25
|
import {
|
|
26
26
|
snapshotLocalSeat as cubesSnapshotLocalSeat,
|
|
27
27
|
resetLocalSeatBinding as cubesResetLocalSeatBinding,
|
|
28
|
+
findRemainingActiveSeatForWorktree as cubesFindRemainingActiveSeatForWorktree,
|
|
28
29
|
findProjectRoot as cubesFindProjectRoot,
|
|
29
30
|
type LocalSeatSnapshot,
|
|
30
31
|
type ResetLocalSeatOutcome,
|
|
@@ -39,6 +40,10 @@ export interface ResetLocalSeatFlags {
|
|
|
39
40
|
export interface ResetLocalSeatDeps {
|
|
40
41
|
snapshotLocalSeat: () => Promise<LocalSeatSnapshot | null>;
|
|
41
42
|
resetLocalSeatBinding: (expected: LocalSeatSnapshot) => Promise<ResetLocalSeatOutcome>;
|
|
43
|
+
findRemainingActiveSeat: (worktree: string) => Promise<{
|
|
44
|
+
apiUrl: string;
|
|
45
|
+
operation: { projectRoot: string; kind: 'seat' | 'sibling'; operationKey: string };
|
|
46
|
+
} | null>;
|
|
42
47
|
findProjectRoot: (cwd: string) => string;
|
|
43
48
|
normalizeHost: (host: string) => string;
|
|
44
49
|
cwd: () => string;
|
|
@@ -52,6 +57,10 @@ function reenrollCommand(apiUrl: string): string {
|
|
|
52
57
|
return `\`borg assimilate --host ${apiUrl} --enroll\``;
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
function reattachCommand(apiUrl: string): string {
|
|
61
|
+
return `\`borg assimilate --host ${apiUrl} --here\``;
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
/**
|
|
56
65
|
* Recovery guidance shared by the success + honest-no-op copy. Makes NO
|
|
57
66
|
* server-revocation claim: it recommends the operator issue a LIVE scoped
|
|
@@ -149,7 +158,22 @@ export async function runResetLocalSeat(
|
|
|
149
158
|
`audit: this worktree's saved local seat for ${snapshot.apiUrl} (worktree ${worktree}) ` +
|
|
150
159
|
'was cleared; server, trust anchor, cube, and sibling worktrees unchanged.\n',
|
|
151
160
|
);
|
|
152
|
-
|
|
161
|
+
let remaining: Awaited<ReturnType<ResetLocalSeatDeps['findRemainingActiveSeat']>> = null;
|
|
162
|
+
try {
|
|
163
|
+
remaining = await deps.findRemainingActiveSeat(worktree);
|
|
164
|
+
} catch {
|
|
165
|
+
// The reset already committed. A follow-up read must not turn that success
|
|
166
|
+
// into a false failure; fall back to the universally valid enrollment path.
|
|
167
|
+
}
|
|
168
|
+
if (remaining?.apiUrl === snapshot.apiUrl) {
|
|
169
|
+
deps.stdout(
|
|
170
|
+
`Another saved active seat remains for this worktree on ${snapshot.apiUrl}. ` +
|
|
171
|
+
`Re-attach with ${reattachCommand(snapshot.apiUrl)}; Borg will revalidate that ` +
|
|
172
|
+
'seat with the server before launch.\n',
|
|
173
|
+
);
|
|
174
|
+
} else {
|
|
175
|
+
deps.stdout(recoveryGuidance(snapshot.apiUrl));
|
|
176
|
+
}
|
|
153
177
|
return 0;
|
|
154
178
|
}
|
|
155
179
|
|
|
@@ -175,6 +199,7 @@ export function buildDefaultResetLocalSeatDeps(): ResetLocalSeatDeps {
|
|
|
175
199
|
return {
|
|
176
200
|
snapshotLocalSeat: () => cubesSnapshotLocalSeat(),
|
|
177
201
|
resetLocalSeatBinding: (expected) => cubesResetLocalSeatBinding(expected),
|
|
202
|
+
findRemainingActiveSeat: (worktree) => cubesFindRemainingActiveSeatForWorktree(worktree),
|
|
178
203
|
findProjectRoot: (cwd) => cubesFindProjectRoot(cwd),
|
|
179
204
|
normalizeHost: (host) => normalizeServerEndpoint(host),
|
|
180
205
|
cwd: () => process.cwd(),
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import {
|
|
3
|
+
HEARTBEAT_STALE_MS,
|
|
4
|
+
heartbeatPathFor,
|
|
5
|
+
legacyHeartbeatPathFor,
|
|
6
|
+
legacyPidfilePathFor,
|
|
7
|
+
pidfilePathFor,
|
|
8
|
+
} from './inbox-monitor.js';
|
|
9
|
+
|
|
10
|
+
export type MonitorHeartbeatState = 'fresh' | 'stale' | 'missing';
|
|
11
|
+
|
|
12
|
+
export interface LiveInboxMonitor {
|
|
13
|
+
pid: number;
|
|
14
|
+
heartbeat: MonitorHeartbeatState;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SeatReattachGuardDeps {
|
|
18
|
+
readPidfile: (path: string) => string | null;
|
|
19
|
+
readHeartbeatMtimeMs: (path: string) => number | null;
|
|
20
|
+
isAlive: (pid: number) => boolean;
|
|
21
|
+
now: number;
|
|
22
|
+
heartbeatStaleMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseMonitorPid(raw: string | null): number | null {
|
|
26
|
+
if (raw === null) return null;
|
|
27
|
+
const match = /^([1-9]\d*)(?::[^:\s]+)?$/.exec(raw.trim());
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
const pid = Number(match[1]);
|
|
30
|
+
return Number.isSafeInteger(pid) ? pid : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read-only preflight for reusing a worktree seat. A live PID always blocks:
|
|
35
|
+
* a stale heartbeat may mean the holder is wedged, but only an explicit
|
|
36
|
+
* `--force` decision may launch a second session onto that seat.
|
|
37
|
+
*/
|
|
38
|
+
export function inspectLiveInboxMonitor(
|
|
39
|
+
inboxPath: string,
|
|
40
|
+
monitorStateRoot: string,
|
|
41
|
+
deps: SeatReattachGuardDeps = defaultSeatReattachGuardDeps(),
|
|
42
|
+
): LiveInboxMonitor | null {
|
|
43
|
+
const candidates = [
|
|
44
|
+
{
|
|
45
|
+
pidfile: pidfilePathFor(inboxPath, monitorStateRoot),
|
|
46
|
+
heartbeat: heartbeatPathFor(inboxPath, monitorStateRoot),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
pidfile: legacyPidfilePathFor(inboxPath),
|
|
50
|
+
heartbeat: legacyHeartbeatPathFor(inboxPath),
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
const staleMs = deps.heartbeatStaleMs ?? HEARTBEAT_STALE_MS;
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
const pid = parseMonitorPid(deps.readPidfile(candidate.pidfile));
|
|
56
|
+
if (pid === null || !deps.isAlive(pid)) continue;
|
|
57
|
+
const heartbeatMtime = deps.readHeartbeatMtimeMs(candidate.heartbeat);
|
|
58
|
+
return {
|
|
59
|
+
pid,
|
|
60
|
+
heartbeat: heartbeatMtime === null
|
|
61
|
+
? 'missing'
|
|
62
|
+
: deps.now - heartbeatMtime < staleMs
|
|
63
|
+
? 'fresh'
|
|
64
|
+
: 'stale',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatSeatReattachRefusal(
|
|
71
|
+
holder: LiveInboxMonitor,
|
|
72
|
+
forcedCommand: string,
|
|
73
|
+
): string {
|
|
74
|
+
const heartbeat =
|
|
75
|
+
holder.heartbeat === 'fresh'
|
|
76
|
+
? ''
|
|
77
|
+
: ` Its heartbeat is ${holder.heartbeat}, so the process may be wedged.`;
|
|
78
|
+
return (
|
|
79
|
+
`This worktree's Borg seat already has a live session (inbox monitor pid ${holder.pid}).${heartbeat}\n` +
|
|
80
|
+
'No agent was launched. Stop the existing session or use a fresh worktree with `borg assimilate --worktree <name>`. ' +
|
|
81
|
+
`If the live monitor is wedged, override once with \`${forcedCommand}\`.\n`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function defaultSeatReattachGuardDeps(
|
|
86
|
+
now: number = Date.now(),
|
|
87
|
+
): SeatReattachGuardDeps {
|
|
88
|
+
return {
|
|
89
|
+
readPidfile: (path) => {
|
|
90
|
+
try {
|
|
91
|
+
return readFileSync(path, 'utf8');
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
readHeartbeatMtimeMs: (path) => {
|
|
97
|
+
try {
|
|
98
|
+
return statSync(path).mtimeMs;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
isAlive: (pid) => {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0);
|
|
106
|
+
return true;
|
|
107
|
+
} catch (error: any) {
|
|
108
|
+
return error?.code === 'EPERM';
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
now,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/src/seats.ts
CHANGED
|
@@ -98,6 +98,8 @@ function emptyStore(): SeatsFile {
|
|
|
98
98
|
|
|
99
99
|
const ROLE_CLASSES = new Set(['queen', 'worker']);
|
|
100
100
|
const OPERATION_KINDS = new Set(['seat', 'sibling']);
|
|
101
|
+
const SEAT_BEARER_RE = /^[A-Za-z0-9_-]{43,1024}$/;
|
|
102
|
+
const rejectedSeatRefs = new Set<string>();
|
|
101
103
|
|
|
102
104
|
function isNonEmptyString(value: unknown): value is string {
|
|
103
105
|
return typeof value === 'string' && value.length > 0;
|
|
@@ -117,10 +119,10 @@ function isValidOperation(value: unknown): value is SeatOperation {
|
|
|
117
119
|
/**
|
|
118
120
|
* CR#2: FULL per-entry validation. Every key/value/invariant of a seat record is
|
|
119
121
|
* checked — the ref is well-formed and self-consistent (the map key equals the
|
|
120
|
-
* record's derived ref), state ∈ {pending,active}, the credential is
|
|
121
|
-
*
|
|
122
|
-
* server + binding fields, and a PENDING record carries NO
|
|
123
|
-
* id. A single invalid entry ⇒ the whole store is rejected
|
|
122
|
+
* record's derived ref), state ∈ {pending,active}, the credential is an exact
|
|
123
|
+
* 32-byte base64url bearer, the operation is well-shaped, an ACTIVE record carries
|
|
124
|
+
* ALL its required server + binding fields, and a PENDING record carries NO
|
|
125
|
+
* active-only session id. A single invalid entry ⇒ the whole store is rejected
|
|
124
126
|
* (fail closed at the caller, bytes preserved) — never a silent cast.
|
|
125
127
|
*/
|
|
126
128
|
function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
|
|
@@ -134,7 +136,8 @@ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
|
|
|
134
136
|
!isNonEmptyString(r.cubeId) ||
|
|
135
137
|
!isNonEmptyString(r.roleId) ||
|
|
136
138
|
!isValidOperation(r.operation) ||
|
|
137
|
-
|
|
139
|
+
typeof r.credential !== 'string' ||
|
|
140
|
+
!SEAT_BEARER_RE.test(r.credential)
|
|
138
141
|
) {
|
|
139
142
|
return false;
|
|
140
143
|
}
|
|
@@ -204,7 +207,7 @@ function parseStore(raw: string): SeatsFile | null {
|
|
|
204
207
|
!Array.isArray(replacement) &&
|
|
205
208
|
Object.keys(replacement).length === 1 &&
|
|
206
209
|
typeof (replacement as { credential?: unknown }).credential === 'string' &&
|
|
207
|
-
|
|
210
|
+
SEAT_BEARER_RE.test((replacement as { credential: string }).credential);
|
|
208
211
|
let canonicalOrigin = false;
|
|
209
212
|
try {
|
|
210
213
|
const origin = new URL(String(withoutReplacement.origin));
|
|
@@ -338,6 +341,7 @@ export async function mintPendingSeat(input: {
|
|
|
338
341
|
};
|
|
339
342
|
txn.data.seats[ref] = record;
|
|
340
343
|
await txn.commit();
|
|
344
|
+
rejectedSeatRefs.delete(ref);
|
|
341
345
|
return record;
|
|
342
346
|
});
|
|
343
347
|
}
|
|
@@ -430,6 +434,7 @@ export async function prepareSeat(input: {
|
|
|
430
434
|
};
|
|
431
435
|
txn.data.seats[ref] = record;
|
|
432
436
|
await txn.commit();
|
|
437
|
+
rejectedSeatRefs.delete(ref);
|
|
433
438
|
return { ok: true as const, record };
|
|
434
439
|
});
|
|
435
440
|
}
|
|
@@ -498,6 +503,7 @@ export async function activateAndBindSeat(input: {
|
|
|
498
503
|
...(input.isHumanSeat !== undefined ? { isHumanSeat: input.isHumanSeat } : {}),
|
|
499
504
|
};
|
|
500
505
|
await txn.commit();
|
|
506
|
+
rejectedSeatRefs.delete(ref);
|
|
501
507
|
return 'activated';
|
|
502
508
|
});
|
|
503
509
|
}
|
|
@@ -567,16 +573,28 @@ export async function bindPendingSeatToWorktree(input: {
|
|
|
567
573
|
|
|
568
574
|
// ─── Hydration / enumeration (scan by worktree) ──────────────────────────────
|
|
569
575
|
|
|
570
|
-
/** The
|
|
571
|
-
* worktree, or non-active) is NEVER surfaced as a live binding.
|
|
576
|
+
/** The preferred ACTIVE seat bound to `worktree`, or null. A pending record (no
|
|
577
|
+
* worktree, or non-active) is NEVER surfaced as a live binding. Candidates use
|
|
578
|
+
* one total order across every process: unrejected before rejected, a sibling
|
|
579
|
+
* finalized into this worktree before an older in-place binding, then seat ref. */
|
|
572
580
|
export async function getActiveSeatForWorktree(worktree: string): Promise<SeatRecord | null> {
|
|
573
581
|
const store = await readStore();
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
582
|
+
const candidates = Object.entries(store.seats)
|
|
583
|
+
.filter(([ref, record]) =>
|
|
584
|
+
record.state === 'active' && record.worktree === worktree && seatRef(record) === ref)
|
|
585
|
+
.sort(([leftRef, left], [rightRef, right]) => {
|
|
586
|
+
const rejectionOrder = Number(rejectedSeatRefs.has(leftRef)) - Number(rejectedSeatRefs.has(rightRef));
|
|
587
|
+
if (rejectionOrder !== 0) return rejectionOrder;
|
|
588
|
+
const operationOrder = Number(left.operation.kind === 'seat') - Number(right.operation.kind === 'seat');
|
|
589
|
+
if (operationOrder !== 0) return operationOrder;
|
|
590
|
+
return leftRef < rightRef ? -1 : leftRef > rightRef ? 1 : 0;
|
|
591
|
+
});
|
|
592
|
+
return candidates[0]?.[1] ?? null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Deprioritize an exact seat after a definitive server auth or eviction verdict. */
|
|
596
|
+
export function markSeatRejected(ref: string): void {
|
|
597
|
+
if (REF_RE.test(ref)) rejectedSeatRefs.add(ref);
|
|
580
598
|
}
|
|
581
599
|
|
|
582
600
|
/**
|
|
@@ -624,6 +642,8 @@ export async function findIncompleteSiblingAttempt(binding: {
|
|
|
624
642
|
record.state === 'pending' &&
|
|
625
643
|
record.worktree === undefined &&
|
|
626
644
|
record.operation.kind === 'sibling' &&
|
|
645
|
+
record.operation.operationKey.startsWith('implicit-sibling:') &&
|
|
646
|
+
record.operation.operationKey.length > 'implicit-sibling:'.length &&
|
|
627
647
|
record.origin === binding.origin &&
|
|
628
648
|
record.trustIdentity === binding.trustIdentity &&
|
|
629
649
|
record.cubeId === binding.cubeId &&
|
|
@@ -696,6 +716,7 @@ export async function resetSeatForWorktree(expected: {
|
|
|
696
716
|
}
|
|
697
717
|
delete txn.data.seats[expected.ref];
|
|
698
718
|
await txn.commit();
|
|
719
|
+
rejectedSeatRefs.delete(expected.ref);
|
|
699
720
|
return { outcome: 'reset' as const, ref: expected.ref };
|
|
700
721
|
});
|
|
701
722
|
}
|
|
@@ -726,6 +747,7 @@ export async function clearSeat(ref: string): Promise<void> {
|
|
|
726
747
|
if (txn.data.seats[ref] !== undefined) {
|
|
727
748
|
delete txn.data.seats[ref];
|
|
728
749
|
await txn.commit();
|
|
750
|
+
rejectedSeatRefs.delete(ref);
|
|
729
751
|
}
|
|
730
752
|
});
|
|
731
753
|
}
|
package/src/update-cmd.ts
CHANGED
|
@@ -154,8 +154,8 @@ function isExactSemver(value: string): boolean {
|
|
|
154
154
|
return EXACT_SEMVER.test(value);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
function isCanonicalSha512Integrity(value:
|
|
158
|
-
if (!value.startsWith('sha512-') || value.includes(' ')) return false;
|
|
157
|
+
function isCanonicalSha512Integrity(value: unknown): boolean {
|
|
158
|
+
if (typeof value !== 'string' || !value.startsWith('sha512-') || value.includes(' ')) return false;
|
|
159
159
|
const encoded = value.slice('sha512-'.length);
|
|
160
160
|
try {
|
|
161
161
|
const bytes = Buffer.from(encoded, 'base64');
|
|
@@ -242,7 +242,7 @@ function validatePublishedPackage(
|
|
|
242
242
|
expectedName: typeof CLIENT_PACKAGE | typeof SERVER_PACKAGE,
|
|
243
243
|
): void {
|
|
244
244
|
if (value.name !== expectedName || !isExactSemver(value.version)) {
|
|
245
|
-
throw new Error(`registry returned an invalid ${expectedName} manifest identity`);
|
|
245
|
+
throw new Error(`registry returned an invalid ${expectedName} manifest identity (missing or invalid name/version field)`);
|
|
246
246
|
}
|
|
247
247
|
if (!isCanonicalSha512Integrity(value.integrity)) {
|
|
248
248
|
throw new Error(`registry returned invalid ${expectedName} SHA-512 integrity`);
|
|
@@ -308,23 +308,6 @@ function decodeServerStatus(value: unknown): ServerStatus {
|
|
|
308
308
|
throw new Error('server returned invalid JSON status');
|
|
309
309
|
}
|
|
310
310
|
const record = value as Record<string, unknown>;
|
|
311
|
-
const allowed = new Set([
|
|
312
|
-
'status',
|
|
313
|
-
'installed_controller',
|
|
314
|
-
'prepared_runtime',
|
|
315
|
-
'prepared_integrity',
|
|
316
|
-
'running_runtime',
|
|
317
|
-
'running_integrity',
|
|
318
|
-
'build_identity',
|
|
319
|
-
'endpoint',
|
|
320
|
-
'mode',
|
|
321
|
-
'service_adapter',
|
|
322
|
-
'data_identity',
|
|
323
|
-
'next_action',
|
|
324
|
-
]);
|
|
325
|
-
for (const key of Object.keys(record)) {
|
|
326
|
-
if (!allowed.has(key)) throw new Error(`server status contains unknown field ${key}`);
|
|
327
|
-
}
|
|
328
311
|
if (
|
|
329
312
|
(record.status !== 'running' && record.status !== 'stopped') ||
|
|
330
313
|
typeof record.installed_controller !== 'string' ||
|
|
@@ -382,10 +365,7 @@ function decodeServerUpdate(value: unknown): ServerUpdateResult {
|
|
|
382
365
|
}
|
|
383
366
|
const record = value as Record<string, unknown>;
|
|
384
367
|
if (record.status === 'failed') {
|
|
385
|
-
const failureKeys = Object.keys(record);
|
|
386
368
|
if (
|
|
387
|
-
failureKeys.length !== 4 ||
|
|
388
|
-
!failureKeys.every((key) => ['status', 'error_code', 'recovery', 'data_identity'].includes(key)) ||
|
|
389
369
|
(record.error_code !== 'ARTIFACT_VERIFICATION_FAILED' && record.error_code !== 'ACTIVATION_FAILED') ||
|
|
390
370
|
!['verification_failed', 'restored', 'stopped', 'recovery_failed'].includes(record.recovery as string) ||
|
|
391
371
|
record.data_identity !== 'preserved' ||
|
|
@@ -400,20 +380,6 @@ function decodeServerUpdate(value: unknown): ServerUpdateResult {
|
|
|
400
380
|
recovery: record.recovery as ServerUpdateFailure['recovery'],
|
|
401
381
|
};
|
|
402
382
|
}
|
|
403
|
-
const allowed = new Set([
|
|
404
|
-
'status',
|
|
405
|
-
'installed_controller',
|
|
406
|
-
'artifact',
|
|
407
|
-
'artifact_integrity',
|
|
408
|
-
'running_runtime',
|
|
409
|
-
'build_identity',
|
|
410
|
-
'mode',
|
|
411
|
-
'data_identity',
|
|
412
|
-
'next_action',
|
|
413
|
-
]);
|
|
414
|
-
for (const key of Object.keys(record)) {
|
|
415
|
-
if (!allowed.has(key)) throw new Error(`server update contains unknown field ${key}`);
|
|
416
|
-
}
|
|
417
383
|
if (
|
|
418
384
|
(record.status !== 'prepared' && record.status !== 'updated') ||
|
|
419
385
|
typeof record.installed_controller !== 'string' ||
|
|
@@ -505,11 +471,12 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
505
471
|
: (
|
|
506
472
|
`${errorMessage(error, 'Update preflight failed')}\n` +
|
|
507
473
|
`Observed update state:\n` +
|
|
508
|
-
` client:
|
|
509
|
-
` server controller:
|
|
474
|
+
` client: not inspected (registry preflight incomplete)\n` +
|
|
475
|
+
` server controller: not inspected (registry preflight incomplete)\n` +
|
|
510
476
|
` prepared runtime: not inspected\n` +
|
|
511
477
|
` running runtime: not inspected\n` +
|
|
512
|
-
`No mutation was attempted.\n`
|
|
478
|
+
`No mutation was attempted.\n` +
|
|
479
|
+
`Manual fallback: npm install -g ${CLIENT_PACKAGE} && npm install -g ${SERVER_PACKAGE}\n`
|
|
513
480
|
));
|
|
514
481
|
return interrupted ?? 1;
|
|
515
482
|
}
|
|
@@ -922,24 +889,35 @@ async function defaultPublishedPackage(
|
|
|
922
889
|
context: NpmContext,
|
|
923
890
|
): Promise<PublishedPackage> {
|
|
924
891
|
if (version !== 'latest' && !isExactSemver(version)) throw new Error('invalid registry target version');
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
892
|
+
// Keep npm context validation above, but read the registry's typed manifest
|
|
893
|
+
// contract directly rather than parsing npm CLI presentation output.
|
|
894
|
+
void context;
|
|
895
|
+
const endpoint = new URL(`${encodeURIComponent(name)}/${encodeURIComponent(version)}`, CANONICAL_NPM_REGISTRY);
|
|
896
|
+
let published: PublishedPackage;
|
|
897
|
+
try {
|
|
898
|
+
const response = await fetch(endpoint, {
|
|
899
|
+
headers: { Accept: 'application/json' },
|
|
900
|
+
redirect: 'error',
|
|
901
|
+
});
|
|
902
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
903
|
+
const parsed: unknown = await response.json();
|
|
904
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
905
|
+
throw new Error('response was not a manifest object');
|
|
906
|
+
}
|
|
907
|
+
const manifest = parsed as Record<string, unknown>;
|
|
908
|
+
const dist = manifest.dist as Record<string, unknown> | undefined;
|
|
909
|
+
const dependencies = manifest.dependencies as Record<string, unknown> | undefined;
|
|
910
|
+
published = {
|
|
911
|
+
name: manifest.name as PublishedPackage['name'],
|
|
912
|
+
version: manifest.version as string,
|
|
913
|
+
integrity: dist?.integrity as string,
|
|
914
|
+
sharedVersion: dependencies?.[SHARED_PACKAGE] as string,
|
|
915
|
+
};
|
|
916
|
+
} catch {
|
|
917
|
+
throw new Error(`registry manifest lookup failed for ${name}@${version}`);
|
|
918
|
+
}
|
|
919
|
+
validatePublishedPackage(published, name);
|
|
920
|
+
return published;
|
|
943
921
|
}
|
|
944
922
|
|
|
945
923
|
async function defaultConfirm(message: string): Promise<'yes' | 'no' | 'eof' | 'interrupted'> {
|