borgmcp 4.3.0 → 4.5.0
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/dist/agent-integration-health.d.ts +2 -0
- package/dist/agent-integration-health.d.ts.map +1 -1
- package/dist/agent-integration-health.js +18 -1
- package/dist/agent-integration-health.js.map +1 -1
- package/dist/assimilate-cmd.d.ts +3 -0
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +12 -0
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +4 -2
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/backends/launch-all-terminals.d.ts.map +1 -1
- package/dist/backends/launch-all-terminals.js +6 -3
- package/dist/backends/launch-all-terminals.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +7 -4
- package/dist/cli-help.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -4
- package/dist/index.js.map +1 -1
- package/dist/opencode-drone.d.ts +19 -0
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +374 -67
- package/dist/opencode-drone.js.map +1 -1
- package/dist/opencode-seat-identity.d.ts +1 -1
- package/dist/opencode-seat-identity.d.ts.map +1 -1
- package/dist/opencode-seat-identity.js.map +1 -1
- package/dist/private-root.d.ts +2 -0
- package/dist/private-root.d.ts.map +1 -1
- package/dist/private-root.js +35 -1
- package/dist/private-root.js.map +1 -1
- package/dist/roster-render.d.ts.map +1 -1
- package/dist/roster-render.js +2 -3
- package/dist/roster-render.js.map +1 -1
- package/dist/seats.d.ts +5 -0
- package/dist/seats.d.ts.map +1 -1
- package/dist/seats.js +10 -0
- package/dist/seats.js.map +1 -1
- package/dist/server-errors.d.ts +22 -0
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js +32 -0
- package/dist/server-errors.js.map +1 -1
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +1 -0
- package/dist/server-handshake.js.map +1 -1
- package/dist/stream-status.d.ts.map +1 -1
- package/dist/stream-status.js +7 -2
- package/dist/stream-status.js.map +1 -1
- package/dist/terminal-title.d.ts.map +1 -1
- package/dist/terminal-title.js +3 -2
- package/dist/terminal-title.js.map +1 -1
- package/dist/update-cmd.d.ts +2 -1
- package/dist/update-cmd.d.ts.map +1 -1
- package/dist/update-cmd.js +110 -41
- package/dist/update-cmd.js.map +1 -1
- package/docs/LOCAL_SERVER.md +21 -10
- package/package.json +1 -1
- package/src/agent-integration-health.ts +20 -1
- package/src/assimilate-cmd.ts +17 -0
- package/src/assimilate-deps.ts +4 -1
- package/src/backends/launch-all-terminals.ts +6 -3
- package/src/cli-help.ts +7 -4
- package/src/index.ts +30 -3
- package/src/opencode-drone.ts +461 -73
- package/src/opencode-seat-identity.ts +1 -0
- package/src/private-root.ts +38 -1
- package/src/roster-render.ts +2 -3
- package/src/seats.ts +15 -0
- package/src/server-errors.ts +50 -0
- package/src/server-handshake.ts +1 -0
- package/src/stream-status.ts +7 -2
- package/src/terminal-title.ts +4 -2
- package/src/update-cmd.ts +118 -41
package/src/private-root.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstatSync, realpathSync } from 'node:fs';
|
|
1
|
+
import fs, { lstatSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { chmod, lstat, mkdir } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
@@ -100,3 +100,40 @@ export async function ensurePrivateBorgConfigRoot(root = borgConfigRoot()): Prom
|
|
|
100
100
|
throw new Error('Borg private-state directory is not private');
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
/** Synchronous equivalent for startup-failure paths that must never await. */
|
|
105
|
+
export function ensurePrivateBorgConfigRootSync(root = borgConfigRoot()): void {
|
|
106
|
+
if (!isAbsolute(root) || resolve(root) !== root) {
|
|
107
|
+
throw new Error('Borg private-state directory path is not canonical');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let metadata: fs.Stats;
|
|
111
|
+
try {
|
|
112
|
+
metadata = fs.lstatSync(root);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
115
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
116
|
+
metadata = fs.lstatSync(root);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
120
|
+
throw new Error('Borg private-state directory must be a real directory');
|
|
121
|
+
}
|
|
122
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
123
|
+
if (uid !== null && metadata.uid !== uid) {
|
|
124
|
+
throw new Error('Borg private-state directory is not owned by the current user');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const mode = metadata.mode & 0o777;
|
|
128
|
+
if ((mode & 0o022) !== 0) {
|
|
129
|
+
throw new Error('Borg private-state directory is writable by other users');
|
|
130
|
+
}
|
|
131
|
+
if (mode !== 0o700) {
|
|
132
|
+
fs.chmodSync(root, 0o700);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const final = fs.lstatSync(root);
|
|
136
|
+
if (!final.isDirectory() || (final.mode & 0o777) !== 0o700) {
|
|
137
|
+
throw new Error('Borg private-state directory is not private');
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/roster-render.ts
CHANGED
|
@@ -217,8 +217,7 @@ export function renderRoster(inputs: RenderRosterInputs): string {
|
|
|
217
217
|
const regenCountMarker =
|
|
218
218
|
typeof d.regen_count === 'number' ? ` · \`regen-count:${d.regen_count}\`` : '';
|
|
219
219
|
if (resolvedSince) {
|
|
220
|
-
// T2.1 awake/stale column.
|
|
221
|
-
// richer state; older servers fall back to seen_since. `seen_since === true` → drone called a
|
|
220
|
+
// T2.1 awake/stale column. `seen_since === true` means the drone called a
|
|
222
221
|
// tool after the resolved timestamp; treat as awake. False or
|
|
223
222
|
// missing → stale. Missing should not happen when the server
|
|
224
223
|
// echoed a since, but defending against a shape mismatch is
|
|
@@ -232,7 +231,7 @@ export function renderRoster(inputs: RenderRosterInputs): string {
|
|
|
232
231
|
// probe call — pure redundancy. The per-row `last seen X ago`
|
|
233
232
|
// field carries the diagnostic detail for "how stale is this
|
|
234
233
|
// particular drone."
|
|
235
|
-
const marker = d.
|
|
234
|
+
const marker = d.seen_since === true ? 'awake' : 'stale';
|
|
236
235
|
lines.push(
|
|
237
236
|
`- **${d.label}**${addr} (Role: ${roleName}) — last seen ${lastSeen} · \`${marker}\`${regenCountMarker}${wakePathMarker}${wakePathClassMarker}`
|
|
238
237
|
);
|
package/src/seats.ts
CHANGED
|
@@ -50,6 +50,7 @@ export interface SeatRecord {
|
|
|
50
50
|
sessionId?: string;
|
|
51
51
|
// binding + display (set atomically at FINALIZE; absent while pending)
|
|
52
52
|
worktree?: string;
|
|
53
|
+
commonDir?: string;
|
|
53
54
|
name?: string;
|
|
54
55
|
droneLabel?: string;
|
|
55
56
|
roleName?: string;
|
|
@@ -149,6 +150,7 @@ function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
|
|
|
149
150
|
if (r.roleClass !== undefined && (typeof r.roleClass !== 'string' || !ROLE_CLASSES.has(r.roleClass))) return false;
|
|
150
151
|
if (r.isHumanSeat !== undefined && typeof r.isHumanSeat !== 'boolean') return false;
|
|
151
152
|
if (r.worktree !== undefined && typeof r.worktree !== 'string') return false;
|
|
153
|
+
if (r.commonDir !== undefined && !isNonEmptyString(r.commonDir)) return false;
|
|
152
154
|
if (r.droneId !== undefined && (typeof r.droneId !== 'string' || !UUID_RE.test(r.droneId))) return false;
|
|
153
155
|
if (r.sessionId !== undefined && (typeof r.sessionId !== 'string' || !UUID_RE.test(r.sessionId))) return false;
|
|
154
156
|
// State-consistency invariants (no inconsistent active|pending).
|
|
@@ -447,6 +449,7 @@ export type ActivateSeatOutcome = 'activated' | 'missing' | 'replaced';
|
|
|
447
449
|
* worktree is decided). Merged atomically with activation by activateAndBindSeat. */
|
|
448
450
|
export interface SeatBinding {
|
|
449
451
|
worktree: string;
|
|
452
|
+
commonDir?: string;
|
|
450
453
|
name: string;
|
|
451
454
|
droneLabel: string;
|
|
452
455
|
roleName?: string;
|
|
@@ -475,6 +478,7 @@ export async function activateAndBindSeat(input: {
|
|
|
475
478
|
sessionId: string;
|
|
476
479
|
expectedPendingDigest: string;
|
|
477
480
|
worktree: string;
|
|
481
|
+
commonDir?: string;
|
|
478
482
|
name: string;
|
|
479
483
|
droneLabel: string;
|
|
480
484
|
roleName?: string;
|
|
@@ -496,6 +500,7 @@ export async function activateAndBindSeat(input: {
|
|
|
496
500
|
droneId: input.droneId,
|
|
497
501
|
sessionId: input.sessionId,
|
|
498
502
|
worktree: input.worktree,
|
|
503
|
+
...(input.commonDir !== undefined ? { commonDir: input.commonDir } : {}),
|
|
499
504
|
name: input.name,
|
|
500
505
|
droneLabel: input.droneLabel,
|
|
501
506
|
...(input.roleName !== undefined ? { roleName: input.roleName } : {}),
|
|
@@ -675,6 +680,16 @@ export async function readAllActiveSeats(): Promise<Array<{ worktree: string; re
|
|
|
675
680
|
return out;
|
|
676
681
|
}
|
|
677
682
|
|
|
683
|
+
/** Whether this cube has an active seat from another canonical Git clone family. */
|
|
684
|
+
export async function hasActiveSeatInDifferentCloneFamily(cubeId: string, commonDir: string): Promise<boolean> {
|
|
685
|
+
const seats = await readAllActiveSeats();
|
|
686
|
+
return seats.some(({ record }) =>
|
|
687
|
+
record.cubeId === cubeId &&
|
|
688
|
+
record.commonDir !== undefined &&
|
|
689
|
+
record.commonDir !== commonDir
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
678
693
|
/** All valid worktree-bound registry entries, including a PENDING seat whose
|
|
679
694
|
* interrupted finalize preserved its worktree for a later resume. Read-only:
|
|
680
695
|
* pending records remain non-hydratable and getActiveSeatForWorktree stays
|
package/src/server-errors.ts
CHANGED
|
@@ -110,6 +110,56 @@ export class BorgServerUnreachableError extends Error {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
export type OpenCodeFailureCode =
|
|
114
|
+
| 'unauthorized'
|
|
115
|
+
| 'not-found'
|
|
116
|
+
| 'incompatible-api'
|
|
117
|
+
| 'timeout'
|
|
118
|
+
| 'transient';
|
|
119
|
+
|
|
120
|
+
export class OpenCodeAuthenticationError extends Error {
|
|
121
|
+
readonly code = 'unauthorized' as const;
|
|
122
|
+
|
|
123
|
+
constructor(message = 'OpenCode API authentication is unavailable') {
|
|
124
|
+
super(message);
|
|
125
|
+
this.name = 'OpenCodeAuthenticationError';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class OpenCodeHttpError extends Error {
|
|
130
|
+
constructor(
|
|
131
|
+
public readonly status: number,
|
|
132
|
+
public readonly code: OpenCodeFailureCode,
|
|
133
|
+
message: string,
|
|
134
|
+
) {
|
|
135
|
+
super(message);
|
|
136
|
+
this.name = 'OpenCodeHttpError';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class OpenCodeResponseError extends Error {
|
|
141
|
+
readonly code = 'incompatible-api' as const;
|
|
142
|
+
|
|
143
|
+
constructor(
|
|
144
|
+
message = 'OpenCode returned an incompatible API response',
|
|
145
|
+
options?: { cause?: unknown },
|
|
146
|
+
) {
|
|
147
|
+
super(message, options);
|
|
148
|
+
this.name = 'OpenCodeResponseError';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export class OpenCodeUnreachableError extends Error {
|
|
153
|
+
constructor(
|
|
154
|
+
public readonly code: 'timeout' | 'transient',
|
|
155
|
+
message: string,
|
|
156
|
+
options?: { cause?: unknown },
|
|
157
|
+
) {
|
|
158
|
+
super(message, options);
|
|
159
|
+
this.name = 'OpenCodeUnreachableError';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
113
163
|
export class CubeCreationOutcomeUnknownError extends Error {
|
|
114
164
|
constructor() {
|
|
115
165
|
super('Cube creation outcome is unknown.');
|
package/src/server-handshake.ts
CHANGED
|
@@ -410,6 +410,7 @@ export async function sendBorgServerAttach(
|
|
|
410
410
|
sessionId: decoded.session.id,
|
|
411
411
|
expectedPendingDigest: pendingBearerDigest,
|
|
412
412
|
worktree: binding.worktree,
|
|
413
|
+
...(binding.commonDir !== undefined ? { commonDir: binding.commonDir } : {}),
|
|
413
414
|
name: binding.name,
|
|
414
415
|
droneLabel: binding.droneLabel,
|
|
415
416
|
...(binding.roleName !== undefined ? { roleName: binding.roleName } : {}),
|
package/src/stream-status.ts
CHANGED
|
@@ -264,8 +264,13 @@ export function renderStreamStatus(inputs: RenderInputs): string {
|
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
if (wakePath.agentKind === 'opencode' && wakePath.openCode) {
|
|
267
|
-
const
|
|
268
|
-
|
|
267
|
+
const openCode = wakePath.openCode;
|
|
268
|
+
const delivery = openCode.deliveryStates;
|
|
269
|
+
lines.push(`- **OpenCode delivery connected**: ${openCode.connected}`);
|
|
270
|
+
lines.push(`- **OpenCode target session**: ${openCode.sessionId ?? '_(none resolved yet)_'}`);
|
|
271
|
+
lines.push(`- **OpenCode last injection**: ${openCode.lastInjectionResult ?? '_(none yet)_'}${typeof openCode.lastInjectionAt === 'number' ? ` at ${new Date(openCode.lastInjectionAt).toISOString()}` : ''}`);
|
|
272
|
+
lines.push(`- **OpenCode last accepted entry**: ${openCode.lastAcceptedEntryId ?? '_(none)_'}`);
|
|
273
|
+
lines.push(`- **OpenCode last failure code**: ${openCode.lastFailureCode ?? '_(none)_'}`);
|
|
269
274
|
lines.push(`- **OpenCode queued**: ${delivery.queued}`);
|
|
270
275
|
lines.push(
|
|
271
276
|
`- **OpenCode delivered-unconfirmed**: ${delivery['delivered-unconfirmed']}`
|
package/src/terminal-title.ts
CHANGED
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
* populated," so the assimilated path is the common case.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
+
import { escapeSyncDisplay } from './sync-roles-render.js';
|
|
36
|
+
|
|
35
37
|
/**
|
|
36
38
|
* Pure: compose the title string for a session. Exported so tests can
|
|
37
39
|
* exercise every branch without TTY / process / fs dependencies.
|
|
@@ -46,9 +48,9 @@ export function composeTerminalTitle(
|
|
|
46
48
|
repoBasename: string
|
|
47
49
|
): string {
|
|
48
50
|
if (activeDrone) {
|
|
49
|
-
return `borg · ${activeDrone.label} · ${activeDrone.cubeName}`;
|
|
51
|
+
return `borg · ${escapeSyncDisplay(activeDrone.label)} · ${escapeSyncDisplay(activeDrone.cubeName)}`;
|
|
50
52
|
}
|
|
51
|
-
return `borg · ${repoBasename}`;
|
|
53
|
+
return `borg · ${escapeSyncDisplay(repoBasename)}`;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/**
|
package/src/update-cmd.ts
CHANGED
|
@@ -42,6 +42,7 @@ export interface UpdateTarget {
|
|
|
42
42
|
export interface UpdateOptions {
|
|
43
43
|
yes: boolean;
|
|
44
44
|
help?: boolean;
|
|
45
|
+
registry?: string;
|
|
45
46
|
target?: UpdateTarget;
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -129,10 +130,21 @@ type ServerUpdateFailureStage =
|
|
|
129
130
|
interface NpmContext {
|
|
130
131
|
commandPath: string;
|
|
131
132
|
commandIdentity: string;
|
|
133
|
+
registry: string;
|
|
132
134
|
prefix: string;
|
|
133
135
|
root: string;
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
class RegistryChangedDuringUpdateError extends Error {
|
|
139
|
+
constructor(
|
|
140
|
+
readonly expectedRegistry: string,
|
|
141
|
+
readonly observedRegistry: string,
|
|
142
|
+
) {
|
|
143
|
+
super(`npm registry changed during update from ${expectedRegistry} to ${observedRegistry}`);
|
|
144
|
+
this.name = 'RegistryChangedDuringUpdateError';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
136
148
|
function signalExitCode(error: unknown): number | null {
|
|
137
149
|
return error instanceof CommandSignalError ? error.exitCode : null;
|
|
138
150
|
}
|
|
@@ -141,11 +153,26 @@ function errorMessage(error: unknown, fallback: string): string {
|
|
|
141
153
|
return error instanceof Error ? error.message : fallback;
|
|
142
154
|
}
|
|
143
155
|
|
|
156
|
+
function updateRetryCommand(registry?: string): string {
|
|
157
|
+
return `borg update --yes${registry ? ` --registry ${shellEscape(registry)}` : ''}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderUpdateRetry(error: unknown, registry?: string): string {
|
|
161
|
+
if (error instanceof RegistryChangedDuringUpdateError) {
|
|
162
|
+
return (
|
|
163
|
+
`The configured npm registry changed during the update.\n` +
|
|
164
|
+
`Restore ${error.expectedRegistry} and retry with: ${updateRetryCommand(error.expectedRegistry)}\n` +
|
|
165
|
+
`Or deliberately start a new update against the current registry with: ${updateRetryCommand(error.observedRegistry)}\n`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return `Retry with: ${updateRetryCommand(registry)}\n`;
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
function hasErrorCode(error: unknown, code: string): boolean {
|
|
145
172
|
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
|
146
173
|
}
|
|
147
174
|
|
|
148
|
-
function renderReentryPreflightFailure(error: unknown, target: UpdateTarget): string {
|
|
175
|
+
function renderReentryPreflightFailure(error: unknown, target: UpdateTarget, registry?: string): string {
|
|
149
176
|
return (
|
|
150
177
|
`Update preflight failed: ${errorMessage(error, 'unknown failure')}\n` +
|
|
151
178
|
`Observed update state:\n` +
|
|
@@ -154,7 +181,7 @@ function renderReentryPreflightFailure(error: unknown, target: UpdateTarget): st
|
|
|
154
181
|
` prepared runtime: not inspected\n` +
|
|
155
182
|
` running runtime: not inspected\n` +
|
|
156
183
|
`Server mutation was not attempted.\n` +
|
|
157
|
-
|
|
184
|
+
renderUpdateRetry(error, registry)
|
|
158
185
|
);
|
|
159
186
|
}
|
|
160
187
|
|
|
@@ -185,6 +212,27 @@ export function isExactSemver(value: unknown): value is string {
|
|
|
185
212
|
return typeof value === 'string' && EXACT_SEMVER.test(value);
|
|
186
213
|
}
|
|
187
214
|
|
|
215
|
+
function normalizeRegistryUrl(value: string): string {
|
|
216
|
+
if (value !== value.trim()) throw new Error('npm registry URL must not contain surrounding whitespace');
|
|
217
|
+
let url: URL;
|
|
218
|
+
try {
|
|
219
|
+
url = new URL(value);
|
|
220
|
+
} catch {
|
|
221
|
+
throw new Error('npm registry URL is invalid');
|
|
222
|
+
}
|
|
223
|
+
if (
|
|
224
|
+
url.protocol !== 'https:' ||
|
|
225
|
+
url.username !== '' ||
|
|
226
|
+
url.password !== '' ||
|
|
227
|
+
url.search !== '' ||
|
|
228
|
+
url.hash !== ''
|
|
229
|
+
) {
|
|
230
|
+
throw new Error('npm registry URL must be an HTTPS URL without credentials, query, or fragment');
|
|
231
|
+
}
|
|
232
|
+
if (!url.pathname.endsWith('/')) url.pathname += '/';
|
|
233
|
+
return url.href;
|
|
234
|
+
}
|
|
235
|
+
|
|
188
236
|
function isCanonicalSha512Integrity(value: unknown): boolean {
|
|
189
237
|
if (typeof value !== 'string' || !value.startsWith('sha512-') || value.includes(' ')) return false;
|
|
190
238
|
const encoded = value.slice('sha512-'.length);
|
|
@@ -219,6 +267,7 @@ export function parseUpdateArgs(
|
|
|
219
267
|
): ParsedUpdateArgs {
|
|
220
268
|
let yes = false;
|
|
221
269
|
let help = false;
|
|
270
|
+
let registry: string | undefined;
|
|
222
271
|
let clientVersion: string | undefined;
|
|
223
272
|
let serverVersion: string | undefined;
|
|
224
273
|
let serverPresent: boolean | undefined;
|
|
@@ -230,6 +279,16 @@ export function parseUpdateArgs(
|
|
|
230
279
|
yes = true;
|
|
231
280
|
} else if (arg === '--help' || arg === '-h') {
|
|
232
281
|
help = true;
|
|
282
|
+
} else if (arg === '--registry') {
|
|
283
|
+
if (registry !== undefined) return { ok: false, error: '--registry may be specified only once' };
|
|
284
|
+
const value = args[index + 1];
|
|
285
|
+
if (!value) return { ok: false, error: '--registry requires a value' };
|
|
286
|
+
index += 1;
|
|
287
|
+
try {
|
|
288
|
+
registry = normalizeRegistryUrl(value);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return { ok: false, error: errorMessage(error, 'npm registry URL is invalid') };
|
|
291
|
+
}
|
|
233
292
|
} else if (arg === '--target-client' || arg === '--target-server' || arg === '--server-present') {
|
|
234
293
|
hasInternalOption = true;
|
|
235
294
|
const value = args[index + 1];
|
|
@@ -262,10 +321,11 @@ export function parseUpdateArgs(
|
|
|
262
321
|
ok: true,
|
|
263
322
|
yes,
|
|
264
323
|
...(help ? { help: true } : {}),
|
|
324
|
+
...(registry ? { registry } : {}),
|
|
265
325
|
target: { clientVersion, serverVersion, serverPresent },
|
|
266
326
|
};
|
|
267
327
|
}
|
|
268
|
-
return { ok: true, yes, ...(help ? { help: true } : {}) };
|
|
328
|
+
return { ok: true, yes, ...(help ? { help: true } : {}), ...(registry ? { registry } : {}) };
|
|
269
329
|
}
|
|
270
330
|
|
|
271
331
|
function validatePublishedPackage(
|
|
@@ -582,7 +642,9 @@ function verifyServerStatus(status: ServerStatus, target: PublishedPackage): 'ru
|
|
|
582
642
|
function renderServerFailureRecovery(
|
|
583
643
|
status: ServerStatus | null,
|
|
584
644
|
updateAttempted: boolean,
|
|
585
|
-
retryCommand:
|
|
645
|
+
retryCommand: string,
|
|
646
|
+
error: unknown,
|
|
647
|
+
registry?: string,
|
|
586
648
|
): string {
|
|
587
649
|
let text = '';
|
|
588
650
|
if (status?.state === 'stopped') {
|
|
@@ -594,7 +656,9 @@ function renderServerFailureRecovery(
|
|
|
594
656
|
`If it is stopped, run the recovery command reported by borg server status.\n`
|
|
595
657
|
);
|
|
596
658
|
}
|
|
597
|
-
if (
|
|
659
|
+
if (error instanceof RegistryChangedDuringUpdateError) {
|
|
660
|
+
text += renderUpdateRetry(error, registry);
|
|
661
|
+
} else if (retryCommand !== 'borg server start') {
|
|
598
662
|
text += status?.state === 'stopped'
|
|
599
663
|
? `Then retry the failed stage with: ${retryCommand}\n`
|
|
600
664
|
: `Next: ${retryCommand}\n`;
|
|
@@ -622,6 +686,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
622
686
|
let pair: { client: PublishedPackage; server: PublishedPackage };
|
|
623
687
|
let client: InstalledPackage;
|
|
624
688
|
let discoveredServer: InstalledPackage | null;
|
|
689
|
+
const updateRetry = updateRetryCommand(options.registry);
|
|
625
690
|
try {
|
|
626
691
|
[pair, client, discoveredServer] = await Promise.all([
|
|
627
692
|
publishedPair(options.target, deps),
|
|
@@ -631,7 +696,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
631
696
|
} catch (error) {
|
|
632
697
|
const interrupted = signalExitCode(error);
|
|
633
698
|
deps.stderr(options.target
|
|
634
|
-
? renderReentryPreflightFailure(error, options.target)
|
|
699
|
+
? renderReentryPreflightFailure(error, options.target, options.registry)
|
|
635
700
|
: (
|
|
636
701
|
`Update preflight failed: ${errorMessage(error, 'unknown failure')}\n` +
|
|
637
702
|
`Observed update state:\n` +
|
|
@@ -640,7 +705,9 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
640
705
|
` prepared runtime: not inspected\n` +
|
|
641
706
|
` running runtime: not inspected\n` +
|
|
642
707
|
`No mutation was attempted.\n` +
|
|
643
|
-
|
|
708
|
+
(error instanceof RegistryChangedDuringUpdateError
|
|
709
|
+
? renderUpdateRetry(error, options.registry)
|
|
710
|
+
: `Manual fallback: npm install -g ${CLIENT_PACKAGE} && npm install -g ${SERVER_PACKAGE}\n`)
|
|
644
711
|
));
|
|
645
712
|
return interrupted ?? 1;
|
|
646
713
|
}
|
|
@@ -655,13 +722,13 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
655
722
|
` prepared runtime: not inspected\n` +
|
|
656
723
|
` running runtime: not inspected\n` +
|
|
657
724
|
`Server mutation was not attempted.\n` +
|
|
658
|
-
`Retry with:
|
|
725
|
+
`Retry with: ${updateRetry}\n`,
|
|
659
726
|
);
|
|
660
727
|
return 1;
|
|
661
728
|
}
|
|
662
729
|
|
|
663
730
|
deps.stdout(
|
|
664
|
-
`Published update plan (${CANONICAL_NPM_REGISTRY}):\n` +
|
|
731
|
+
`Published update plan (${options.registry ?? CANONICAL_NPM_REGISTRY}):\n` +
|
|
665
732
|
` client: ${CLIENT_PACKAGE}@${client.version} -> ${CLIENT_PACKAGE}@${pair.client.version}\n` +
|
|
666
733
|
` target integrity: ${pair.client.integrity}\n` +
|
|
667
734
|
` server: ${discoveredServer ? `${SERVER_PACKAGE}@${discoveredServer.version}` : 'not installed'} -> ${SERVER_PACKAGE}@${pair.server.version}\n` +
|
|
@@ -707,6 +774,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
707
774
|
const args = [
|
|
708
775
|
'update',
|
|
709
776
|
'--yes',
|
|
777
|
+
...(options.registry ? ['--registry', options.registry] : []),
|
|
710
778
|
'--target-client', pair.client.version,
|
|
711
779
|
'--target-server', pair.server.version,
|
|
712
780
|
'--server-present', serverWasPresent ? 'yes' : 'no',
|
|
@@ -726,7 +794,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
726
794
|
` prepared runtime: not inspected\n` +
|
|
727
795
|
` running runtime: not inspected\n` +
|
|
728
796
|
`Server mutation was not attempted.\n` +
|
|
729
|
-
|
|
797
|
+
renderUpdateRetry(error, options.registry),
|
|
730
798
|
);
|
|
731
799
|
return interrupted ?? 1;
|
|
732
800
|
}
|
|
@@ -746,7 +814,9 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
746
814
|
` prepared runtime: not inspected\n` +
|
|
747
815
|
` running runtime: not inspected\n` +
|
|
748
816
|
`Server mutation was not attempted.\n` +
|
|
749
|
-
|
|
817
|
+
(error instanceof RegistryChangedDuringUpdateError
|
|
818
|
+
? renderUpdateRetry(error, options.registry)
|
|
819
|
+
: `Next: reinstall ${CLIENT_PACKAGE}@${pair.client.version} from ${options.registry ?? CANONICAL_NPM_REGISTRY}, then rerun ${updateRetry}.\n`),
|
|
750
820
|
);
|
|
751
821
|
return interrupted ?? 1;
|
|
752
822
|
}
|
|
@@ -794,7 +864,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
794
864
|
` prepared runtime: not inspected\n` +
|
|
795
865
|
` running runtime: not inspected\n` +
|
|
796
866
|
`Server runtime mutation was not attempted.\n` +
|
|
797
|
-
|
|
867
|
+
renderUpdateRetry(error, options.registry),
|
|
798
868
|
);
|
|
799
869
|
return interrupted ?? 1;
|
|
800
870
|
}
|
|
@@ -805,7 +875,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
805
875
|
let updateAttempted = false;
|
|
806
876
|
let recoveryStatusAttempted = false;
|
|
807
877
|
let failureStage: ServerUpdateFailureStage = 'initial server status check';
|
|
808
|
-
let retryCommand
|
|
878
|
+
let retryCommand = 'borg server status';
|
|
809
879
|
const observeStatusAfterFailure = async (): Promise<void> => {
|
|
810
880
|
recoveryStatusAttempted = true;
|
|
811
881
|
try {
|
|
@@ -820,7 +890,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
820
890
|
initialServerState = status.state;
|
|
821
891
|
if (status.installedController !== exactServerIdentity(pair.server.version)) {
|
|
822
892
|
failureStage = 'server controller identity check';
|
|
823
|
-
retryCommand =
|
|
893
|
+
retryCommand = updateRetry;
|
|
824
894
|
throw new Error('server status contradicted the verified controller identity');
|
|
825
895
|
}
|
|
826
896
|
try {
|
|
@@ -856,7 +926,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
856
926
|
retryCommand = 'borg server update';
|
|
857
927
|
const state = verifyServerStatus(status, pair.server);
|
|
858
928
|
failureStage = 'final package verification';
|
|
859
|
-
retryCommand =
|
|
929
|
+
retryCommand = updateRetry;
|
|
860
930
|
const [finalClient, finalServer] = await Promise.all([
|
|
861
931
|
deps.currentClient(),
|
|
862
932
|
deps.currentServer(),
|
|
@@ -904,7 +974,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
|
|
|
904
974
|
deps.stderr(
|
|
905
975
|
`Server update failed during ${failureStage}: ${errorMessage(error, 'unknown failure')}.\n` +
|
|
906
976
|
renderServerState(client, server, observedStatus, observedUpdate) +
|
|
907
|
-
renderServerFailureRecovery(observedStatus, updateAttempted, retryCommand),
|
|
977
|
+
renderServerFailureRecovery(observedStatus, updateAttempted, retryCommand, error, options.registry),
|
|
908
978
|
);
|
|
909
979
|
return interrupted ?? 1;
|
|
910
980
|
}
|
|
@@ -998,26 +1068,34 @@ async function npmText(commandPath: string, args: readonly string[], label: stri
|
|
|
998
1068
|
return singleLine(result.stdout, label);
|
|
999
1069
|
}
|
|
1000
1070
|
|
|
1001
|
-
function
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1071
|
+
function requireAcknowledgedRegistry(value: string, acknowledgedRegistry?: string): string {
|
|
1072
|
+
const normalized = normalizeRegistryUrl(value);
|
|
1073
|
+
if (acknowledgedRegistry !== undefined) {
|
|
1074
|
+
const acknowledged = normalizeRegistryUrl(acknowledgedRegistry);
|
|
1075
|
+
if (acknowledged !== normalized) {
|
|
1076
|
+
throw new Error(
|
|
1077
|
+
`the configured npm registry ${normalized} does not match the explicitly acknowledged registry ${acknowledged}`,
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
return normalized;
|
|
1007
1081
|
}
|
|
1008
1082
|
if (normalized !== CANONICAL_NPM_REGISTRY) {
|
|
1009
1083
|
throw new Error(
|
|
1010
|
-
`borg update
|
|
1011
|
-
`the configured registry
|
|
1084
|
+
`borg update uses the canonical npm registry ${CANONICAL_NPM_REGISTRY} by default; ` +
|
|
1085
|
+
`the configured registry ${normalized} has not been explicitly acknowledged. ` +
|
|
1086
|
+
`Rerun with: borg update --registry ${shellEscape(normalized)} to acknowledge this exact registry for one update.`,
|
|
1012
1087
|
);
|
|
1013
1088
|
}
|
|
1089
|
+
return normalized;
|
|
1014
1090
|
}
|
|
1015
1091
|
|
|
1016
|
-
async function resolveNpmContext(): Promise<NpmContext> {
|
|
1092
|
+
async function resolveNpmContext(acknowledgedRegistry?: string): Promise<NpmContext> {
|
|
1017
1093
|
const commandPath = which.sync('npm');
|
|
1018
1094
|
const commandIdentity = await realpath(commandPath);
|
|
1019
|
-
const registry =
|
|
1020
|
-
|
|
1095
|
+
const registry = requireAcknowledgedRegistry(
|
|
1096
|
+
await npmText(commandPath, ['config', 'get', 'registry'], 'registry'),
|
|
1097
|
+
acknowledgedRegistry,
|
|
1098
|
+
);
|
|
1021
1099
|
const prefixText = await npmText(commandPath, ['prefix', '--global'], 'global prefix');
|
|
1022
1100
|
const rootText = await npmText(commandPath, ['root', '--global'], 'global root');
|
|
1023
1101
|
if (!isAbsolute(prefixText) || !isAbsolute(rootText)) {
|
|
@@ -1029,7 +1107,7 @@ async function resolveNpmContext(): Promise<NpmContext> {
|
|
|
1029
1107
|
if (relativeRoot === '' || relativeRoot.startsWith('..') || isAbsolute(relativeRoot)) {
|
|
1030
1108
|
throw new Error('npm global root is outside its global prefix');
|
|
1031
1109
|
}
|
|
1032
|
-
return { commandPath, commandIdentity, prefix, root };
|
|
1110
|
+
return { commandPath, commandIdentity, registry, prefix, root };
|
|
1033
1111
|
}
|
|
1034
1112
|
|
|
1035
1113
|
async function assertNpmContext(context: NpmContext): Promise<NpmContext> {
|
|
@@ -1037,8 +1115,8 @@ async function assertNpmContext(context: NpmContext): Promise<NpmContext> {
|
|
|
1037
1115
|
if (await realpath(activeCommand) !== context.commandIdentity) {
|
|
1038
1116
|
throw new Error('active npm executable changed during update');
|
|
1039
1117
|
}
|
|
1040
|
-
const registry = await npmText(context.commandPath, ['config', 'get', 'registry'], 'registry');
|
|
1041
|
-
|
|
1118
|
+
const registry = normalizeRegistryUrl(await npmText(context.commandPath, ['config', 'get', 'registry'], 'registry'));
|
|
1119
|
+
if (registry !== context.registry) throw new RegistryChangedDuringUpdateError(context.registry, registry);
|
|
1042
1120
|
const prefix = await realpath(await npmText(context.commandPath, ['prefix', '--global'], 'global prefix'));
|
|
1043
1121
|
if (prefix !== context.prefix) throw new Error('npm global prefix changed during update');
|
|
1044
1122
|
const root = await realpath(await npmText(context.commandPath, ['root', '--global'], 'global root'));
|
|
@@ -1146,8 +1224,7 @@ async function defaultPublishedPackage(
|
|
|
1146
1224
|
if (version !== 'latest' && !isExactSemver(version)) throw new Error('invalid registry target version');
|
|
1147
1225
|
// Keep npm context validation above, but read the registry's typed manifest
|
|
1148
1226
|
// contract directly rather than parsing npm CLI presentation output.
|
|
1149
|
-
|
|
1150
|
-
const endpoint = new URL(`${encodeURIComponent(name)}/${encodeURIComponent(version)}`, CANONICAL_NPM_REGISTRY);
|
|
1227
|
+
const endpoint = new URL(`${encodeURIComponent(name)}/${encodeURIComponent(version)}`, context.registry);
|
|
1151
1228
|
let published: PublishedPackage;
|
|
1152
1229
|
try {
|
|
1153
1230
|
const response = await fetch(endpoint, {
|
|
@@ -1179,8 +1256,7 @@ async function defaultPublishedVersions(
|
|
|
1179
1256
|
name: typeof CLIENT_PACKAGE | typeof SERVER_PACKAGE,
|
|
1180
1257
|
context: NpmContext,
|
|
1181
1258
|
): Promise<string[]> {
|
|
1182
|
-
|
|
1183
|
-
const endpoint = new URL(encodeURIComponent(name), CANONICAL_NPM_REGISTRY);
|
|
1259
|
+
const endpoint = new URL(encodeURIComponent(name), context.registry);
|
|
1184
1260
|
try {
|
|
1185
1261
|
const response = await fetch(endpoint, {
|
|
1186
1262
|
headers: { Accept: 'application/json' },
|
|
@@ -1229,10 +1305,10 @@ async function defaultConfirm(message: string, defaultYes = false): Promise<'yes
|
|
|
1229
1305
|
}
|
|
1230
1306
|
}
|
|
1231
1307
|
|
|
1232
|
-
export function buildDefaultUpdateDeps(): UpdateDeps {
|
|
1308
|
+
export function buildDefaultUpdateDeps(acknowledgedRegistry?: string): UpdateDeps {
|
|
1233
1309
|
let contextPromise: Promise<NpmContext> | undefined;
|
|
1234
1310
|
const context = async (): Promise<NpmContext> => {
|
|
1235
|
-
contextPromise ??= resolveNpmContext();
|
|
1311
|
+
contextPromise ??= resolveNpmContext(acknowledgedRegistry);
|
|
1236
1312
|
return assertNpmContext(await contextPromise);
|
|
1237
1313
|
};
|
|
1238
1314
|
return {
|
|
@@ -1256,7 +1332,7 @@ export function buildDefaultUpdateDeps(): UpdateDeps {
|
|
|
1256
1332
|
'--global',
|
|
1257
1333
|
...(options?.ignoreScripts ? ['--ignore-scripts'] : []),
|
|
1258
1334
|
`--prefix=${npm.prefix}`,
|
|
1259
|
-
`--registry=${
|
|
1335
|
+
`--registry=${npm.registry}`,
|
|
1260
1336
|
`${name}@${version}`,
|
|
1261
1337
|
], { inherit: true });
|
|
1262
1338
|
if (result.code !== 0) throw new Error(`${name} installation exited ${result.code}`);
|
|
@@ -1292,17 +1368,18 @@ export function buildDefaultUpdateDeps(): UpdateDeps {
|
|
|
1292
1368
|
|
|
1293
1369
|
export async function runEarlyUpdate(
|
|
1294
1370
|
argv: readonly string[],
|
|
1295
|
-
deps
|
|
1371
|
+
deps?: UpdateDeps,
|
|
1296
1372
|
): Promise<number | null> {
|
|
1297
1373
|
if (argv[2] !== 'update') return null;
|
|
1298
1374
|
const parsed = parseUpdateArgs(argv.slice(3), process.env[REENTRY_ENV] === '1');
|
|
1375
|
+
const resolvedDeps = deps ?? buildDefaultUpdateDeps(parsed.ok ? parsed.registry : undefined);
|
|
1299
1376
|
if (!parsed.ok) {
|
|
1300
|
-
|
|
1377
|
+
resolvedDeps.stderr(`${parsed.error}\nRun \`borg update --help\` for usage.\n`);
|
|
1301
1378
|
return 1;
|
|
1302
1379
|
}
|
|
1303
1380
|
if (parsed.help) {
|
|
1304
|
-
|
|
1381
|
+
resolvedDeps.stdout(updateHelpText(''));
|
|
1305
1382
|
return 0;
|
|
1306
1383
|
}
|
|
1307
|
-
return runUpdate(parsed,
|
|
1384
|
+
return runUpdate(parsed, resolvedDeps);
|
|
1308
1385
|
}
|