borgmcp 2.7.3 → 2.9.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.
Files changed (78) hide show
  1. package/README.md +96 -230
  2. package/dist/assimilate-cmd.d.ts +1 -0
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +18 -4
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/assimilate-deps.d.ts.map +1 -1
  7. package/dist/assimilate-deps.js +3 -0
  8. package/dist/assimilate-deps.js.map +1 -1
  9. package/dist/cli-help.d.ts.map +1 -1
  10. package/dist/cli-help.js +2 -3
  11. package/dist/cli-help.js.map +1 -1
  12. package/dist/config.d.ts +1 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +3 -0
  15. package/dist/config.js.map +1 -1
  16. package/dist/drone-lifecycle.d.ts +8 -0
  17. package/dist/drone-lifecycle.d.ts.map +1 -1
  18. package/dist/drone-lifecycle.js +19 -0
  19. package/dist/drone-lifecycle.js.map +1 -1
  20. package/dist/first-run-server.d.ts +10 -2
  21. package/dist/first-run-server.d.ts.map +1 -1
  22. package/dist/first-run-server.js +54 -7
  23. package/dist/first-run-server.js.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +14 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/log-stream.d.ts +4 -0
  28. package/dist/log-stream.d.ts.map +1 -1
  29. package/dist/log-stream.js +28 -4
  30. package/dist/log-stream.js.map +1 -1
  31. package/dist/remote-client.d.ts +2 -2
  32. package/dist/remote-client.d.ts.map +1 -1
  33. package/dist/remote-client.js +43 -8
  34. package/dist/remote-client.js.map +1 -1
  35. package/dist/runtime-metadata.d.ts +1 -1
  36. package/dist/runtime-metadata.d.ts.map +1 -1
  37. package/dist/runtime-metadata.js +3 -3
  38. package/dist/runtime-metadata.js.map +1 -1
  39. package/dist/server-errors.d.ts +9 -1
  40. package/dist/server-errors.d.ts.map +1 -1
  41. package/dist/server-errors.js +19 -0
  42. package/dist/server-errors.js.map +1 -1
  43. package/dist/server-handshake.d.ts +5 -1
  44. package/dist/server-handshake.d.ts.map +1 -1
  45. package/dist/server-handshake.js +18 -0
  46. package/dist/server-handshake.js.map +1 -1
  47. package/dist/server-trust.d.ts.map +1 -1
  48. package/dist/server-trust.js +10 -0
  49. package/dist/server-trust.js.map +1 -1
  50. package/dist/setup.js +5 -2
  51. package/dist/setup.js.map +1 -1
  52. package/dist/tool-manifest.d.ts.map +1 -1
  53. package/dist/tool-manifest.js +8 -5
  54. package/dist/tool-manifest.js.map +1 -1
  55. package/dist/update-cmd.d.ts +1 -0
  56. package/dist/update-cmd.d.ts.map +1 -1
  57. package/dist/update-cmd.js +130 -19
  58. package/dist/update-cmd.js.map +1 -1
  59. package/docs/EXTRACTION_PROVENANCE.md +7 -7
  60. package/docs/LOCAL_SERVER.md +3 -3
  61. package/docs/RELEASING.md +16 -6
  62. package/package.json +2 -2
  63. package/src/assimilate-cmd.ts +25 -6
  64. package/src/assimilate-deps.ts +3 -0
  65. package/src/cli-help.ts +2 -3
  66. package/src/config.ts +4 -0
  67. package/src/drone-lifecycle.ts +25 -0
  68. package/src/first-run-server.ts +71 -8
  69. package/src/index.ts +15 -1
  70. package/src/log-stream.ts +26 -5
  71. package/src/remote-client.ts +58 -7
  72. package/src/runtime-metadata.ts +4 -3
  73. package/src/server-errors.ts +25 -0
  74. package/src/server-handshake.ts +30 -1
  75. package/src/server-trust.ts +11 -0
  76. package/src/setup.ts +5 -2
  77. package/src/tool-manifest.ts +8 -5
  78. package/src/update-cmd.ts +153 -19
@@ -196,7 +196,7 @@ export interface ServerAttachResult {
196
196
  }
197
197
 
198
198
  /**
199
- * Attach an enrolled client principal to one granted cube/role over protocol v6.
199
+ * Attach an enrolled client principal to one granted cube/role over protocol v7.
200
200
  * The client CSPRNG-generates the session bearer and persists it PENDING in the
201
201
  * OS keychain (keyed by the stable per-seat identity) BEFORE this request, so an
202
202
  * interrupted/lost response is recovered by re-sending the exact same bearer —
@@ -452,6 +452,8 @@ export async function enrollBorgServer(
452
452
  prepareEnrollment?: typeof getOrCreatePendingServerEnrollment;
453
453
  activateEnrollment?: typeof activatePendingServerEnrollment;
454
454
  clearPendingEnrollment?: typeof clearPendingServerEnrollment;
455
+ loadCredentialRecord?: typeof getServerCredentialRecord;
456
+ confirmReplacement?: () => Promise<boolean>;
455
457
  clientName?: string;
456
458
  } = {},
457
459
  ): Promise<NewServerEnrollment> {
@@ -461,6 +463,24 @@ export async function enrollBorgServer(
461
463
  // persisted and before any invitation/secret-bearing request is sent.
462
464
  const protocol = await preflightBorgServerTag(origin, fetchImpl);
463
465
 
466
+ const existingCredential = await (deps.loadCredentialRecord ?? getServerCredentialRecord)(
467
+ origin,
468
+ trustIdentity,
469
+ );
470
+ let replacementConfirmed = false;
471
+ if (existingCredential) {
472
+ const confirmed = deps.confirmReplacement !== undefined
473
+ ? await deps.confirmReplacement()
474
+ : false;
475
+ if (!confirmed) {
476
+ throw new BorgServerError(
477
+ 'LOCAL_CREDENTIAL_EXISTS',
478
+ 'a local enrollment already exists for this server and was not replaced',
479
+ );
480
+ }
481
+ replacementConfirmed = true;
482
+ }
483
+
464
484
  const pending = await (deps.prepareEnrollment ?? getOrCreatePendingServerEnrollment)({
465
485
  origin,
466
486
  trustIdentity,
@@ -528,6 +548,7 @@ export async function enrollBorgServer(
528
548
  credential: pending.credential,
529
549
  clientId: decoded.payload.client_id,
530
550
  serverCapabilities: decoded.payload.server_capabilities,
551
+ ...(replacementConfirmed ? { allowReplacement: true } : {}),
531
552
  });
532
553
  return {
533
554
  token: pending.credential,
@@ -1011,6 +1032,8 @@ export async function enrollLocalBorgServer(
1011
1032
  prepareEnrollment?: typeof getOrCreatePendingServerEnrollment;
1012
1033
  activateEnrollment?: typeof activatePendingServerEnrollment;
1013
1034
  clearPendingEnrollment?: typeof clearPendingServerEnrollment;
1035
+ loadCredentialRecord?: typeof getServerCredentialRecord;
1036
+ confirmReplacement?: () => Promise<boolean>;
1014
1037
  clientName?: string;
1015
1038
  } = {},
1016
1039
  ): Promise<NewServerEnrollment> {
@@ -1022,6 +1045,12 @@ export async function enrollLocalBorgServer(
1022
1045
  ...(deps.clearPendingEnrollment === undefined
1023
1046
  ? {}
1024
1047
  : { clearPendingEnrollment: deps.clearPendingEnrollment }),
1048
+ ...(deps.loadCredentialRecord === undefined
1049
+ ? {}
1050
+ : { loadCredentialRecord: deps.loadCredentialRecord }),
1051
+ ...(deps.confirmReplacement === undefined
1052
+ ? {}
1053
+ : { confirmReplacement: deps.confirmReplacement }),
1025
1054
  ...(deps.clientName === undefined ? {} : { clientName: deps.clientName }),
1026
1055
  });
1027
1056
  }
@@ -1,3 +1,4 @@
1
+ import { Buffer } from 'node:buffer';
1
2
  import { createHash, timingSafeEqual, X509Certificate } from 'node:crypto';
2
3
  import { constants } from 'node:fs';
3
4
  import { open } from 'node:fs/promises';
@@ -157,6 +158,16 @@ export function createPinnedServerFetch(origin: string, caCertificate: string):
157
158
 
158
159
  const body = requestBody(init.body);
159
160
  const headers = new Headers(init.headers);
161
+ if (body !== undefined) {
162
+ const contentLength = String(
163
+ typeof body === 'string' ? Buffer.byteLength(body, 'utf8') : body.byteLength,
164
+ );
165
+ const declaredLength = headers.get('Content-Length');
166
+ if (declaredLength !== null && declaredLength !== contentLength) {
167
+ throw new Error('Borg server transport refused an inconsistent Content-Length');
168
+ }
169
+ if (declaredLength === null) headers.set('Content-Length', contentLength);
170
+ }
160
171
  return await new Promise<Response>((resolvePromise, rejectPromise) => {
161
172
  const request = httpsRequest({
162
173
  protocol: 'https:',
package/src/setup.ts CHANGED
@@ -73,11 +73,12 @@ async function main() {
73
73
  // configuration. Decline/non-interactive/failure paths therefore leave no
74
74
  // partial setup state behind.
75
75
  console.log(chalk.blue('◼ Local Server'));
76
- const serverInstall = await offerFirstRunServerInstall();
76
+ const serverInstall = await offerFirstRunServerInstall(undefined, undefined, { initializeServer: true });
77
77
  if (serverInstall.kind !== 'present' && serverInstall.kind !== 'installed') {
78
78
  process.exit(serverInstall.kind === 'declined' ? 0 : 1);
79
79
  }
80
80
  console.log('');
81
+ console.log('◼ Local server initialized');
81
82
 
82
83
  // Step 1: Configure every detected agent CLI
83
84
  console.log(chalk.blue('◼ Agent CLI Integration'));
@@ -185,7 +186,9 @@ async function main() {
185
186
  // Success message
186
187
  console.log(chalk.green.bold('\nSetup complete!\n'));
187
188
  console.log(chalk.yellow('🔄 Restart Claude Code / Codex / OpenCode (or open a new session) for the changes to take effect.\n'));
188
- console.log(chalk.gray(setupNextStepsText()));
189
+ if (!serverInstall.suppressClientNextSteps) {
190
+ console.log(chalk.gray(setupNextStepsText()));
191
+ }
189
192
  }
190
193
 
191
194
  // Run wizard
@@ -30,7 +30,7 @@ export const TOOL_MANIFEST: ToolManifestEntry[] = [
30
30
  'Optional `since` (entry-id UUID or ISO-8601 timestamp) trims the recent-log section ' +
31
31
  'to entries strictly after the anchor — pass your last-seen entry id to skip ' +
32
32
  'already-processed history on each refresh. If you know the current session model, pass ' +
33
- 'optional `model` to self-report it as advisory metadata; unrecognized model strings are accepted.',
33
+ 'optional `model` to self-report its printable identifier as advisory metadata; model names are not allowlisted.',
34
34
  inputSchema: {
35
35
  type: 'object',
36
36
  properties: {
@@ -47,9 +47,11 @@ export const TOOL_MANIFEST: ToolManifestEntry[] = [
47
47
  },
48
48
  model: {
49
49
  type: 'string',
50
- maxLength: 256,
50
+ minLength: 1,
51
+ maxLength: 160,
52
+ pattern: '^[A-Za-z0-9][A-Za-z0-9._/@:+-]*$',
51
53
  description:
52
- 'Optional advisory self-report of the model running this agent session. Use the model identifier you know from session context; the server does not infer or validate it against an allowlist.',
54
+ 'Optional advisory self-report of the model running this agent session. Use a printable model identifier of 1-160 ASCII characters; model names are not allowlisted.',
53
55
  },
54
56
  },
55
57
  required: [],
@@ -419,13 +421,14 @@ export const TOOL_MANIFEST: ToolManifestEntry[] = [
419
421
  },
420
422
  {
421
423
  name: 'borg_delete-cube',
422
- description: 'Delete a cube and all its roles, drones, and log entries. Irreversible confirm with the user before invoking unless the cube is clearly disposable.',
424
+ description: 'Delete a cube and all its roles, drones, and log entries. Irreversible; requires the exact cube UUID again after explicit user confirmation.',
423
425
  inputSchema: {
424
426
  type: 'object',
425
427
  properties: {
426
428
  cube_id: { type: 'string', description: 'UUID of the cube to delete.' },
429
+ confirm_cube_id: { type: 'string', description: 'Explicit user confirmation: repeat the exact cube UUID to confirm this irreversible deletion.' },
427
430
  },
428
- required: ['cube_id'],
431
+ required: ['cube_id', 'confirm_cube_id'],
429
432
  },
430
433
  },
431
434
  {
package/src/update-cmd.ts CHANGED
@@ -7,6 +7,7 @@ import which from 'which';
7
7
  import { updateHelpText } from './cli-help.js';
8
8
  import { preflightBorgServerTag } from './server-handshake.js';
9
9
  import { loadBorgServerTrust } from './server-trust.js';
10
+ import { shellEscape } from './shell-escape.js';
10
11
 
11
12
  const CLIENT_PACKAGE = 'borgmcp';
12
13
  const SERVER_PACKAGE = 'borgmcp-server';
@@ -81,6 +82,7 @@ interface ServerStatus {
81
82
  endpoint: string | null;
82
83
  mode: 'foreground' | 'managed' | 'legacy' | 'stopped';
83
84
  serviceAdapter: 'launchd' | 'systemd' | null;
85
+ serviceRecovery: { command: string[] } | null;
84
86
  dataIdentity: 'available' | 'unavailable';
85
87
  nextAction: string | null;
86
88
  }
@@ -103,6 +105,15 @@ interface ServerUpdateFailure {
103
105
  }
104
106
 
105
107
  type ServerUpdateResult = ServerUpdateSuccess | ServerUpdateFailure;
108
+ type ServerUpdateFailureStage =
109
+ | 'initial server status check'
110
+ | 'server controller identity check'
111
+ | 'server runtime activation'
112
+ | 'post-update server status check'
113
+ | 'final server state verification'
114
+ | 'final package verification'
115
+ | 'managed service continuity check'
116
+ | 'running server protocol verification';
106
117
 
107
118
  interface NpmContext {
108
119
  commandPath: string;
@@ -119,9 +130,13 @@ function errorMessage(error: unknown, fallback: string): string {
119
130
  return error instanceof Error ? error.message : fallback;
120
131
  }
121
132
 
133
+ function hasErrorCode(error: unknown, code: string): boolean {
134
+ return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
135
+ }
136
+
122
137
  function renderReentryPreflightFailure(error: unknown, target: UpdateTarget): string {
123
138
  return (
124
- `${errorMessage(error, 'Update preflight failed')}\n` +
139
+ `Update preflight failed: ${errorMessage(error, 'unknown failure')}\n` +
125
140
  `Observed update state:\n` +
126
141
  ` client: ${CLIENT_PACKAGE}@${target.clientVersion} installed and verified before re-entry\n` +
127
142
  ` server controller: not changed by this continuation\n` +
@@ -346,6 +361,18 @@ function isNextAction(value: unknown): value is string | null {
346
361
  );
347
362
  }
348
363
 
364
+ function decodeManagedServiceRecovery(value: unknown): { command: string[] } | null {
365
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
366
+ const record = value as Record<string, unknown>;
367
+ if (record.kind !== 'run-platform-command' || !Array.isArray(record.command) ||
368
+ record.command.length === 0 || record.command.length > 32 ||
369
+ record.command.some((arg) => typeof arg !== 'string' || arg.length === 0 ||
370
+ Array.from(arg).length > 1024 || /\p{Cc}/u.test(arg))) {
371
+ return null;
372
+ }
373
+ return { command: record.command as string[] };
374
+ }
375
+
349
376
  function decodeServerStatus(value: unknown): ServerStatus {
350
377
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
351
378
  throw new Error('server returned invalid JSON status');
@@ -379,13 +406,19 @@ function decodeServerStatus(value: unknown): ServerStatus {
379
406
  record.running_integrity !== null ||
380
407
  record.build_identity !== null ||
381
408
  record.endpoint !== null ||
382
- record.mode !== 'stopped' ||
383
- record.service_adapter !== null
409
+ record.mode !== 'stopped'
384
410
  )) ||
385
411
  (record.status === 'running' && record.mode === 'stopped')
386
412
  ) {
387
413
  throw new Error('server returned inconsistent JSON status');
388
414
  }
415
+ const managedRecovery = record.status === 'stopped' && record.service_adapter !== null
416
+ ? decodeManagedServiceRecovery(record.service_recovery)
417
+ : null;
418
+ if (record.status === 'stopped' && record.service_adapter !== null &&
419
+ (record.service_state !== 'inactive' || managedRecovery === null)) {
420
+ throw new Error('server returned invalid managed-service recovery status');
421
+ }
389
422
  return {
390
423
  state: record.status,
391
424
  installedController: record.installed_controller,
@@ -397,6 +430,7 @@ function decodeServerStatus(value: unknown): ServerStatus {
397
430
  endpoint: record.endpoint,
398
431
  mode: record.mode as ServerStatus['mode'],
399
432
  serviceAdapter: record.service_adapter,
433
+ serviceRecovery: managedRecovery,
400
434
  dataIdentity: record.data_identity,
401
435
  nextAction: record.next_action,
402
436
  };
@@ -472,7 +506,6 @@ function verifyServerStatus(status: ServerStatus, target: PublishedPackage): 'ru
472
506
  status.buildIdentity !== null ||
473
507
  status.endpoint !== null ||
474
508
  status.mode !== 'stopped' ||
475
- status.serviceAdapter !== null ||
476
509
  status.dataIdentity !== 'available'
477
510
  ) {
478
511
  throw new Error('final server verification failed: stopped server reported a running runtime');
@@ -492,6 +525,37 @@ function verifyServerStatus(status: ServerStatus, target: PublishedPackage): 'ru
492
525
  return 'running';
493
526
  }
494
527
 
528
+ function renderServerFailureRecovery(
529
+ status: ServerStatus | null,
530
+ updateAttempted: boolean,
531
+ retryCommand: 'borg update --yes' | 'borg server status' | 'borg server update' | 'borg server start',
532
+ ): string {
533
+ let text = '';
534
+ if (status?.state === 'stopped') {
535
+ text += renderStoppedServiceRecovery(status);
536
+ } else if (updateAttempted && status === null) {
537
+ text += (
538
+ `Local server service state could not be verified; it may be stopped.\n` +
539
+ `Check it with: borg server status\n` +
540
+ `If it is stopped, run the recovery command reported by borg server status.\n`
541
+ );
542
+ }
543
+ if (retryCommand !== 'borg server start') {
544
+ text += status?.state === 'stopped'
545
+ ? `Then retry the failed stage with: ${retryCommand}\n`
546
+ : `Next: ${retryCommand}\n`;
547
+ }
548
+ return text;
549
+ }
550
+
551
+ function renderStoppedServiceRecovery(status: ServerStatus): string {
552
+ if (status.serviceRecovery !== null) {
553
+ const command = status.serviceRecovery.command.map(shellEscape).join(' ');
554
+ return `Local server service is stopped.\nRestart it with: ${command}\n`;
555
+ }
556
+ return `Local server service is stopped.\nStart it with: borg server start\n`;
557
+ }
558
+
495
559
  export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promise<number> {
496
560
  if (options.help) {
497
561
  deps.stdout(updateHelpText(''));
@@ -512,7 +576,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
512
576
  deps.stderr(options.target
513
577
  ? renderReentryPreflightFailure(error, options.target)
514
578
  : (
515
- `${errorMessage(error, 'Update preflight failed')}\n` +
579
+ `Update preflight failed: ${errorMessage(error, 'unknown failure')}\n` +
516
580
  `Observed update state:\n` +
517
581
  ` client: not inspected (registry preflight incomplete)\n` +
518
582
  ` server controller: not inspected (registry preflight incomplete)\n` +
@@ -540,7 +604,7 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
540
604
  }
541
605
 
542
606
  deps.stdout(
543
- `Published update plan (npm registry):\n` +
607
+ `Published update plan (${CANONICAL_NPM_REGISTRY}):\n` +
544
608
  ` client: ${CLIENT_PACKAGE}@${client.version} -> ${CLIENT_PACKAGE}@${pair.client.version}\n` +
545
609
  ` target integrity: ${pair.client.integrity}\n` +
546
610
  ` server: ${discoveredServer ? `${SERVER_PACKAGE}@${discoveredServer.version}` : 'not installed'} -> ${SERVER_PACKAGE}@${pair.server.version}\n` +
@@ -624,7 +688,8 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
624
688
  : 'not installed'}\n` +
625
689
  ` prepared runtime: not inspected\n` +
626
690
  ` running runtime: not inspected\n` +
627
- `Server mutation was not attempted.\n`,
691
+ `Server mutation was not attempted.\n` +
692
+ `Next: reinstall ${CLIENT_PACKAGE}@${pair.client.version} from ${CANONICAL_NPM_REGISTRY}, then rerun borg update --yes.\n`,
628
693
  );
629
694
  return interrupted ?? 1;
630
695
  }
@@ -671,19 +736,39 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
671
736
 
672
737
  let observedStatus: ServerStatus | null = null;
673
738
  let observedUpdate: ServerUpdateResult | null = null;
739
+ let initialServerState: ServerStatus['state'] | null = null;
740
+ let updateAttempted = false;
741
+ let recoveryStatusAttempted = false;
742
+ let failureStage: ServerUpdateFailureStage = 'initial server status check';
743
+ let retryCommand: Parameters<typeof renderServerFailureRecovery>[2] = 'borg server status';
744
+ const observeStatusAfterFailure = async (): Promise<void> => {
745
+ recoveryStatusAttempted = true;
746
+ try {
747
+ observedStatus = decodeServerStatus(await deps.serverJson(server.binPath, 'status'));
748
+ } catch {
749
+ observedStatus = null;
750
+ }
751
+ };
674
752
  try {
675
753
  let status = decodeServerStatus(await deps.serverJson(server.binPath, 'status'));
676
754
  observedStatus = status;
755
+ initialServerState = status.state;
677
756
  if (status.installedController !== exactServerIdentity(pair.server.version)) {
757
+ failureStage = 'server controller identity check';
758
+ retryCommand = 'borg update --yes';
678
759
  throw new Error('server status contradicted the verified controller identity');
679
760
  }
680
761
  try {
681
762
  verifyServerStatus(status, pair.server);
682
763
  } catch {
683
764
  observedStatus = null;
765
+ updateAttempted = true;
766
+ failureStage = 'server runtime activation';
767
+ retryCommand = 'borg server update';
684
768
  const update = decodeServerUpdate(await deps.serverJson(server.binPath, 'update'));
685
769
  observedUpdate = update;
686
770
  if (update.status === 'failed') {
771
+ await observeStatusAfterFailure();
687
772
  throw new Error(`server update failed: ${update.errorCode} (${update.recovery})`);
688
773
  }
689
774
  const serverIdentity = exactServerIdentity(pair.server.version);
@@ -696,10 +781,17 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
696
781
  ) {
697
782
  throw new Error('server update result did not reach the target artifact');
698
783
  }
784
+ failureStage = 'post-update server status check';
785
+ retryCommand = 'borg server status';
786
+ recoveryStatusAttempted = true;
699
787
  status = decodeServerStatus(await deps.serverJson(server.binPath, 'status'));
700
788
  observedStatus = status;
701
789
  }
790
+ failureStage = 'final server state verification';
791
+ retryCommand = 'borg server update';
702
792
  const state = verifyServerStatus(status, pair.server);
793
+ failureStage = 'final package verification';
794
+ retryCommand = 'borg update --yes';
703
795
  const [finalClient, finalServer] = await Promise.all([
704
796
  deps.currentClient(),
705
797
  deps.currentServer(),
@@ -707,20 +799,35 @@ export async function runUpdate(options: UpdateOptions, deps: UpdateDeps): Promi
707
799
  assertInstalled(finalClient, pair.client);
708
800
  if (!finalServer) throw new Error('server controller disappeared during final verification');
709
801
  assertInstalled(finalServer, pair.server);
710
- if (state === 'running') await deps.verifyRunningProtocol(status.endpoint!);
802
+ if (initialServerState === 'running' && state === 'stopped') {
803
+ failureStage = 'managed service continuity check';
804
+ retryCommand = 'borg server start';
805
+ throw new Error('a previously running local server is now stopped');
806
+ }
807
+ if (state === 'running') {
808
+ failureStage = 'running server protocol verification';
809
+ retryCommand = 'borg server status';
810
+ await deps.verifyRunningProtocol(status.endpoint!);
811
+ }
711
812
  deps.stdout(
712
813
  state === 'stopped'
713
- ? `Updated ${CLIENT_PACKAGE}@${pair.client.version} and ${SERVER_PACKAGE}@${pair.server.version}: prepared; still stopped.\n`
814
+ ? (
815
+ `Updated ${CLIENT_PACKAGE}@${pair.client.version} and ${SERVER_PACKAGE}@${pair.server.version}: prepared.\n` +
816
+ renderStoppedServiceRecovery(status)
817
+ )
714
818
  : `Updated ${CLIENT_PACKAGE}@${pair.client.version} and ${SERVER_PACKAGE}@${pair.server.version}; running identities and protocol verified.\n`,
715
819
  );
716
820
  deps.stdout('Restart active agent sessions to load the updated client.\n');
717
821
  return 0;
718
822
  } catch (error) {
719
823
  const interrupted = signalExitCode(error);
824
+ if (updateAttempted && !recoveryStatusAttempted && observedStatus === null) {
825
+ await observeStatusAfterFailure();
826
+ }
720
827
  deps.stderr(
721
- `Server update or final verification failed: ${errorMessage(error, 'unknown failure')}.\n` +
828
+ `Server update failed during ${failureStage}: ${errorMessage(error, 'unknown failure')}.\n` +
722
829
  renderServerState(client, server, observedStatus, observedUpdate) +
723
- `Retry with: borg update --yes\n`,
830
+ renderServerFailureRecovery(observedStatus, updateAttempted, retryCommand),
724
831
  );
725
832
  return interrupted ?? 1;
726
833
  }
@@ -735,8 +842,8 @@ interface CommandResult {
735
842
  class CommandSignalError extends Error {
736
843
  readonly exitCode: number;
737
844
 
738
- constructor(signal: NodeJS.Signals) {
739
- super(`command stopped by ${signal}`);
845
+ constructor(signal: NodeJS.Signals, stderr = '') {
846
+ super(`command stopped by ${signal}${serverCommandStderr(stderr)}`);
740
847
  this.name = 'CommandSignalError';
741
848
  this.exitCode = 128 + (constants.signals[signal] ?? 1);
742
849
  }
@@ -775,7 +882,7 @@ function runCommand(
775
882
  child.once('exit', (code, signal) => {
776
883
  if (settled) return;
777
884
  if (signal) {
778
- fail(new CommandSignalError(signal));
885
+ fail(new CommandSignalError(signal, stderr));
779
886
  return;
780
887
  }
781
888
  settled = true;
@@ -800,6 +907,14 @@ function singleLine(text: string, label: string): string {
800
907
  return value;
801
908
  }
802
909
 
910
+ function serverCommandStderr(stderr: string): string {
911
+ const detail = stderr.trim();
912
+ if (detail === '') return '';
913
+ const bounded = Array.from(detail).slice(-4096).join('')
914
+ .replace(/\p{Cc}/gu, (character) => character === '\n' || character === '\t' ? character : '?');
915
+ return `\nServer command stderr:\n${bounded}`;
916
+ }
917
+
803
918
  async function npmText(commandPath: string, args: readonly string[], label: string): Promise<string> {
804
919
  const result = await runCommand(commandPath, args);
805
920
  if (result.code !== 0) throw new Error(`npm ${label} lookup failed`);
@@ -925,6 +1040,18 @@ async function inspectNpmPackage(
925
1040
  if (!required) return null;
926
1041
  throw new Error(`${binName} is not available on PATH`);
927
1042
  }
1043
+ try {
1044
+ await realpath(join(context.root, name));
1045
+ } catch (error) {
1046
+ if (hasErrorCode(error, 'ENOENT')) {
1047
+ throw new Error(
1048
+ `${binName} is on PATH from a different npm global prefix. ` +
1049
+ `borg update only manages packages under the active npm prefix. ` +
1050
+ `Run npm prefix --global to inspect it, then update the other installation with its package manager.`,
1051
+ );
1052
+ }
1053
+ throw error;
1054
+ }
928
1055
  return inspectNpmPackageAt({
929
1056
  name,
930
1057
  binName,
@@ -1001,7 +1128,13 @@ async function defaultPublishedVersions(
1001
1128
  }
1002
1129
  }
1003
1130
 
1004
- async function defaultConfirm(message: string): Promise<'yes' | 'no' | 'eof' | 'interrupted'> {
1131
+ export function parseConfirmationAnswer(answer: string, defaultYes = false): 'yes' | 'no' {
1132
+ const normalized = answer.trim().toLowerCase();
1133
+ if (normalized === '') return defaultYes ? 'yes' : 'no';
1134
+ return normalized === 'y' || normalized === 'yes' ? 'yes' : 'no';
1135
+ }
1136
+
1137
+ async function defaultConfirm(message: string, defaultYes = false): Promise<'yes' | 'no' | 'eof' | 'interrupted'> {
1005
1138
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1006
1139
  let interrupted = false;
1007
1140
  rl.once('SIGINT', () => {
@@ -1009,8 +1142,7 @@ async function defaultConfirm(message: string): Promise<'yes' | 'no' | 'eof' | '
1009
1142
  rl.close();
1010
1143
  });
1011
1144
  try {
1012
- const answer = (await rl.question(message)).trim().toLowerCase();
1013
- return answer === 'y' || answer === 'yes' ? 'yes' : 'no';
1145
+ return parseConfirmationAnswer(await rl.question(message), defaultYes);
1014
1146
  } catch (error) {
1015
1147
  if (interrupted) return 'interrupted';
1016
1148
  if ((error as NodeJS.ErrnoException).code === 'ERR_USE_AFTER_CLOSE') return 'eof';
@@ -1065,10 +1197,12 @@ export function buildDefaultUpdateDeps(): UpdateDeps {
1065
1197
  try {
1066
1198
  parsed = JSON.parse(result.stdout);
1067
1199
  } catch {
1068
- throw new Error(`server ${command} returned invalid JSON`);
1200
+ throw new Error(`server ${command} returned invalid JSON${serverCommandStderr(result.stderr)}`);
1069
1201
  }
1070
1202
  if (result.code !== 0 && command !== 'update') {
1071
- throw new Error(`server ${command} exited ${result.code}`);
1203
+ throw new Error(
1204
+ `server ${command} exited ${result.code}${serverCommandStderr(result.stderr)}`,
1205
+ );
1072
1206
  }
1073
1207
  return parsed;
1074
1208
  },