borgmcp 2.1.0 → 2.2.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 (50) hide show
  1. package/README.md +2 -2
  2. package/dist/assimilate-cmd.d.ts +11 -2
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +60 -85
  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 +26 -8
  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 +7 -5
  11. package/dist/cli-help.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +7 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/regen-format.d.ts +1 -6
  16. package/dist/regen-format.d.ts.map +1 -1
  17. package/dist/regen-format.js +9 -8
  18. package/dist/regen-format.js.map +1 -1
  19. package/dist/remote-client.js +1 -1
  20. package/dist/remote-client.js.map +1 -1
  21. package/dist/repository-cube-init.d.ts +16 -1
  22. package/dist/repository-cube-init.d.ts.map +1 -1
  23. package/dist/repository-cube-init.js +167 -22
  24. package/dist/repository-cube-init.js.map +1 -1
  25. package/dist/server-errors.d.ts +11 -0
  26. package/dist/server-errors.d.ts.map +1 -1
  27. package/dist/server-errors.js +20 -0
  28. package/dist/server-errors.js.map +1 -1
  29. package/dist/server-facade.d.ts +2 -1
  30. package/dist/server-facade.d.ts.map +1 -1
  31. package/dist/server-facade.js +16 -10
  32. package/dist/server-facade.js.map +1 -1
  33. package/dist/server-handshake.d.ts +32 -2
  34. package/dist/server-handshake.d.ts.map +1 -1
  35. package/dist/server-handshake.js +161 -8
  36. package/dist/server-handshake.js.map +1 -1
  37. package/docs/EXTRACTION_PROVENANCE.md +9 -7
  38. package/docs/LOCAL_SERVER.md +19 -13
  39. package/docs/RELEASING.md +21 -5
  40. package/package.json +2 -2
  41. package/src/assimilate-cmd.ts +85 -69
  42. package/src/assimilate-deps.ts +37 -7
  43. package/src/cli-help.ts +7 -5
  44. package/src/index.ts +5 -1
  45. package/src/regen-format.ts +11 -9
  46. package/src/remote-client.ts +1 -1
  47. package/src/repository-cube-init.ts +202 -23
  48. package/src/server-errors.ts +27 -0
  49. package/src/server-facade.ts +16 -10
  50. package/src/server-handshake.ts +226 -8
@@ -3,15 +3,23 @@ import {
3
3
  LEGACY_DEFAULT_TEMPLATE_LABEL,
4
4
  } from 'borgmcp-shared/templates';
5
5
  import type {
6
+ AssociateRepositoryCubeResponse,
6
7
  CreateCubeRepository,
7
8
  CreateCubeResponse,
8
9
  CubeTemplate,
10
+ ResolvedRepositoryCube,
11
+ ResolveRepositoryCubeResponse,
9
12
  } from 'borgmcp-shared/protocol';
10
13
  import { shellEscape } from './shell-escape.js';
11
14
  import type {
12
15
  GitRepositoryContext,
13
16
  RepositoryAssociation,
14
17
  } from './repository-identity.js';
18
+ import {
19
+ BorgServerError,
20
+ CubeCreationConfirmationError,
21
+ CubeCreationOutcomeUnknownError,
22
+ } from './server-errors.js';
15
23
 
16
24
  export interface RepositoryCubeDetail {
17
25
  id: string;
@@ -25,6 +33,8 @@ export interface RepositoryCubeCreation {
25
33
  cube: RepositoryCubeDetail;
26
34
  }
27
35
 
36
+ export type RepositoryCubeResolution = ResolveRepositoryCubeResponse;
37
+
28
38
  export interface RepositoryCubeInitFlags {
29
39
  cubeName?: string;
30
40
  template?: string;
@@ -39,6 +49,13 @@ export interface RepositoryCubeInitDeps {
39
49
  getIdentity(context: GitRepositoryContext): Promise<CreateCubeRepository>;
40
50
  getAssociation(repository: CreateCubeRepository): Promise<RepositoryAssociation | null>;
41
51
  saveAssociation(repository: CreateCubeRepository, association: RepositoryAssociation): Promise<void>;
52
+ resolveAssociation(repository: CreateCubeRepository, workingRepoName: string): Promise<RepositoryCubeResolution>;
53
+ listCubes(): Promise<Array<{ id: string; name: string }>>;
54
+ associateCube(input: {
55
+ cubeId: string;
56
+ workingRepoName: string;
57
+ repository: CreateCubeRepository;
58
+ }): Promise<AssociateRepositoryCubeResponse>;
42
59
  getCube(cubeId: string): Promise<RepositoryCubeDetail>;
43
60
  createCube(input: {
44
61
  name: string;
@@ -59,6 +76,13 @@ export class RepositoryAssociationSaveError extends Error {
59
76
  }
60
77
  }
61
78
 
79
+ export class RepositoryAssociationConfirmationError extends Error {
80
+ constructor() {
81
+ super('The Borg server did not confirm the repository cube association.');
82
+ this.name = 'RepositoryAssociationConfirmationError';
83
+ }
84
+ }
85
+
62
86
  export class PromptInterruptedError extends Error {
63
87
  constructor() {
64
88
  super('prompt interrupted');
@@ -79,15 +103,19 @@ function presentation(template: CubeTemplate) {
79
103
  return NEW_CUBE_TEMPLATE_PRESENTATIONS.find((candidate) => candidate.name === template)!;
80
104
  }
81
105
 
82
- async function ask(deps: RepositoryCubeInitDeps, message: string): Promise<{ value: string } | { stop: number }> {
106
+ async function ask(
107
+ deps: RepositoryCubeInitDeps,
108
+ message: string,
109
+ operation: 'creation' | 'adoption' = 'creation',
110
+ ): Promise<{ value: string } | { stop: number }> {
83
111
  try {
84
112
  return { value: await deps.prompt(message) };
85
113
  } catch (error) {
86
114
  if (error instanceof PromptInterruptedError) {
87
- deps.write('\nCube creation cancelled. Nothing was changed.\n');
115
+ deps.write(`\nCube ${operation} cancelled. No cube, repository binding, or drone was created.\n`);
88
116
  return { stop: 130 };
89
117
  }
90
- deps.write('Input ended before cube creation. Nothing was changed.\n');
118
+ deps.write(`Input ended before cube ${operation}. No cube, repository binding, or drone was created.\n`);
91
119
  return { stop: 1 };
92
120
  }
93
121
  }
@@ -101,10 +129,11 @@ function renderResult(
101
129
  serverOrigin: string;
102
130
  mode: 'assimilate' | 'cube-init';
103
131
  creationOptionsUnused: boolean;
132
+ adopted?: boolean;
104
133
  },
105
134
  ): void {
106
135
  const lines = [
107
- input.existing ? 'Cube already initialized.' : 'Cube created.',
136
+ input.adopted ? 'Existing cube associated with this repository.' : input.existing ? 'Cube already initialized.' : 'Cube created.',
108
137
  ` Name: ${input.response.name}`,
109
138
  ` Template: ${presentation(input.response.template).label}`,
110
139
  ` Repository: ${input.root}`,
@@ -129,22 +158,15 @@ export async function initializeRepositoryCube(input: {
129
158
  context: GitRepositoryContext;
130
159
  serverOrigin: string;
131
160
  flags: RepositoryCubeInitFlags;
161
+ canCreate?: boolean;
132
162
  }, deps: RepositoryCubeInitDeps): Promise<RepositoryCubeInitResult> {
133
163
  const repository = await deps.getIdentity(input.context);
134
164
  const association = await deps.getAssociation(repository);
135
- const creationOptionsUnused = association !== null && (
136
- input.flags.cubeName !== undefined || input.flags.template !== undefined || input.flags.yes === true
137
- );
165
+ const creationOptionsRequested = input.flags.cubeName !== undefined ||
166
+ input.flags.template !== undefined || input.flags.noTemplate === true || input.flags.yes === true;
167
+ const creationOptionsUnused = association !== null && creationOptionsRequested;
138
168
  if (association) {
139
169
  const cube = await deps.getCube(association.cubeId);
140
- const authoritativeAssociation = { ...association, name: cube.name };
141
- if (cube.name !== association.name) {
142
- try {
143
- await deps.saveAssociation(repository, authoritativeAssociation);
144
- } catch {
145
- throw new RepositoryAssociationSaveError();
146
- }
147
- }
148
170
  const response: CreateCubeResponse = {
149
171
  result: 'resolved',
150
172
  cube_id: association.cubeId,
@@ -167,6 +189,123 @@ export async function initializeRepositoryCube(input: {
167
189
  return { kind: 'success', creation: { response, cube }, existing: true };
168
190
  }
169
191
 
192
+ const saveResolvedAssociation = async (
193
+ response: ResolvedRepositoryCube,
194
+ options: { adopted: boolean; creationOptionsUnused: boolean },
195
+ ): Promise<RepositoryCubeInitResult> => {
196
+ if (
197
+ response.repository.kind !== repository.kind ||
198
+ response.repository.value !== repository.value ||
199
+ response.working_repo_name !== input.context.derivedName
200
+ ) {
201
+ throw new RepositoryAssociationConfirmationError();
202
+ }
203
+ const cube = await deps.getCube(response.cube_id);
204
+ if (
205
+ cube.id !== response.cube_id ||
206
+ cube.name !== response.name ||
207
+ !cube.roles.some((role) => role.id === response.human_seat_role_id) ||
208
+ !cube.roles.some((role) => role.id === response.default_worker_role_id)
209
+ ) {
210
+ throw new RepositoryAssociationConfirmationError();
211
+ }
212
+ try {
213
+ await deps.saveAssociation(repository, {
214
+ cubeId: response.cube_id,
215
+ name: response.name,
216
+ workingRepoName: response.working_repo_name,
217
+ template: response.template,
218
+ });
219
+ } catch {
220
+ throw new RepositoryAssociationSaveError();
221
+ }
222
+ renderResult(deps, {
223
+ existing: true,
224
+ adopted: options.adopted,
225
+ response,
226
+ root: input.context.root,
227
+ serverOrigin: input.serverOrigin,
228
+ mode: input.mode,
229
+ creationOptionsUnused: options.creationOptionsUnused,
230
+ });
231
+ return { kind: 'success', creation: { response, cube }, existing: true };
232
+ };
233
+
234
+ const serverAssociation = await deps.resolveAssociation(repository, input.context.derivedName);
235
+ if (serverAssociation.result === 'resolved') {
236
+ return saveResolvedAssociation(serverAssociation, {
237
+ adopted: false,
238
+ creationOptionsUnused: creationOptionsRequested,
239
+ });
240
+ }
241
+
242
+ const proposedName = input.flags.cubeName?.trim() ?? input.context.derivedName;
243
+ if (!validRepositoryCubeName(proposedName)) {
244
+ deps.write(`${NAME_ERROR}\n`);
245
+ return { kind: 'stop', code: 1 };
246
+ }
247
+ const accessibleCubes = await deps.listCubes();
248
+ const adoptExactMatch = async (name: string): Promise<RepositoryCubeInitResult | null> => {
249
+ const matches = accessibleCubes.filter((cube) => cube.name === name);
250
+ if (matches.length === 0) return null;
251
+ if (matches.length > 1) {
252
+ deps.write(`More than one accessible cube is named '${name}'. No cube, repository binding, or drone was created. Ask the server operator for an unambiguous repository cube grant.\n`);
253
+ return { kind: 'stop', code: 1 };
254
+ }
255
+ if (!deps.isTTY() || input.flags.yes) {
256
+ deps.write(
257
+ 'Adopting an existing cube requires interactive confirmation; --yes is not accepted here.\n' +
258
+ 'Rerun without --yes in an interactive terminal. No cube, repository binding, or drone was created.\n',
259
+ );
260
+ return { kind: 'stop', code: 1 };
261
+ }
262
+ deps.write(
263
+ `Found an existing cube matching this repository:\n` +
264
+ ` cube: ${matches[0].name}\n` +
265
+ ` repository: ${input.context.root}\n` +
266
+ ` server: ${input.serverOrigin}\n`,
267
+ );
268
+ while (true) {
269
+ const answer = await ask(deps, 'Link this repository to that cube? [y/N]: ', 'adoption');
270
+ if ('stop' in answer) return { kind: 'stop', code: answer.stop };
271
+ const confirmation = answer.value.trim().toLowerCase();
272
+ if (confirmation === 'y' || confirmation === 'yes') break;
273
+ if (confirmation === '' || confirmation === 'n' || confirmation === 'no') {
274
+ deps.write('No cube, repository binding, or drone was created.\n');
275
+ return { kind: 'stop', code: 0 };
276
+ }
277
+ deps.write('Enter y or n.\n');
278
+ }
279
+ const associated = await deps.associateCube({
280
+ cubeId: matches[0].id,
281
+ workingRepoName: input.context.derivedName,
282
+ repository,
283
+ });
284
+ if (associated.cube_id !== matches[0].id) {
285
+ throw new RepositoryAssociationConfirmationError();
286
+ }
287
+ try {
288
+ const confirmed = await deps.resolveAssociation(repository, input.context.derivedName);
289
+ if (confirmed.result !== 'resolved' || confirmed.cube_id !== associated.cube_id) {
290
+ throw new RepositoryAssociationConfirmationError();
291
+ }
292
+ return await saveResolvedAssociation(confirmed, { adopted: true, creationOptionsUnused: creationOptionsRequested });
293
+ } catch (error) {
294
+ if (error instanceof RepositoryAssociationSaveError) throw error;
295
+ throw new RepositoryAssociationConfirmationError();
296
+ }
297
+ };
298
+
299
+ const proposedAdoption = await adoptExactMatch(proposedName);
300
+ if (proposedAdoption) return proposedAdoption;
301
+
302
+ if (input.canCreate === false) {
303
+ deps.write(
304
+ `This enrolled client cannot create a cube on ${input.serverOrigin}. Ask the server operator to grant access to a cube, then rerun borg assimilate --host ${shellEscape(input.serverOrigin)}.\n`,
305
+ );
306
+ return { kind: 'stop', code: 1 };
307
+ }
308
+
170
309
  if (input.flags.noTemplate) {
171
310
  deps.write('--no-template is not supported for repository cube creation. Use --template software-dev or --template starter.\n');
172
311
  return { kind: 'stop', code: 1 };
@@ -201,6 +340,10 @@ export async function initializeRepositoryCube(input: {
201
340
  deps.write(`${NAME_ERROR}\n`);
202
341
  return { kind: 'stop', code: 1 };
203
342
  }
343
+ if (name !== proposedName) {
344
+ const editedNameAdoption = await adoptExactMatch(name);
345
+ if (editedNameAdoption) return editedNameAdoption;
346
+ }
204
347
 
205
348
  let template = input.flags.template as 'software-dev' | 'starter' | undefined;
206
349
  if (!template && deps.isTTY() && !input.flags.yes) {
@@ -241,7 +384,7 @@ export async function initializeRepositoryCube(input: {
241
384
  break;
242
385
  }
243
386
  if (confirmation === 'n' || confirmation === 'no') {
244
- deps.write('Cube creation cancelled. Nothing was changed.\n');
387
+ deps.write('Cube creation cancelled. No cube, repository binding, or drone was created.\n');
245
388
  return { kind: 'stop', code: 0 };
246
389
  }
247
390
  deps.write('Enter y or n.\n');
@@ -250,24 +393,60 @@ export async function initializeRepositoryCube(input: {
250
393
 
251
394
  deps.write('Creating cube...\n');
252
395
  const workingRepoName = input.context.derivedName;
253
- const creation = await deps.createCube({ name, workingRepoName, repository, template });
396
+ let creation: RepositoryCubeCreation;
397
+ try {
398
+ creation = await deps.createCube({ name, workingRepoName, repository, template });
399
+ } catch (error) {
400
+ if (
401
+ error instanceof BorgServerError ||
402
+ error instanceof CubeCreationConfirmationError ||
403
+ error instanceof CubeCreationOutcomeUnknownError
404
+ ) {
405
+ throw error;
406
+ }
407
+ throw new CubeCreationOutcomeUnknownError();
408
+ }
409
+ let confirmed: RepositoryCubeResolution;
410
+ try {
411
+ confirmed = await deps.resolveAssociation(repository, workingRepoName);
412
+ } catch {
413
+ throw new CubeCreationConfirmationError('The server did not return authoritative repository association state.');
414
+ }
415
+ if (
416
+ confirmed.result !== 'resolved' ||
417
+ confirmed.repository.kind !== repository.kind ||
418
+ confirmed.repository.value !== repository.value ||
419
+ confirmed.working_repo_name !== workingRepoName ||
420
+ confirmed.cube_id !== creation.response.cube_id ||
421
+ confirmed.name !== creation.response.name ||
422
+ creation.cube.id !== confirmed.cube_id ||
423
+ creation.cube.name !== confirmed.name ||
424
+ !creation.cube.roles.some((role) => role.id === confirmed.human_seat_role_id) ||
425
+ !creation.cube.roles.some((role) => role.id === confirmed.default_worker_role_id)
426
+ ) {
427
+ throw new CubeCreationConfirmationError('The server did not confirm the created cube through authoritative repository resolution.');
428
+ }
254
429
  try {
255
430
  await deps.saveAssociation(repository, {
256
- cubeId: creation.response.cube_id,
257
- name: creation.response.name,
258
- workingRepoName: creation.response.working_repo_name,
259
- template: creation.response.template,
431
+ cubeId: confirmed.cube_id,
432
+ name: confirmed.name,
433
+ workingRepoName: confirmed.working_repo_name,
434
+ template: confirmed.template,
260
435
  });
261
436
  } catch {
262
437
  throw new RepositoryAssociationSaveError();
263
438
  }
264
439
  renderResult(deps, {
265
440
  existing: creation.response.result === 'resolved',
266
- response: creation.response,
441
+ response: confirmed,
267
442
  root: input.context.root,
268
443
  serverOrigin: input.serverOrigin,
269
444
  mode: input.mode,
270
445
  creationOptionsUnused: false,
271
446
  });
272
- return { kind: 'success', creation, existing: creation.response.result === 'resolved' };
447
+ return {
448
+ kind: 'success',
449
+ creation: { response: confirmed, cube: creation.cube },
450
+ existing: creation.response.result === 'resolved',
451
+ };
273
452
  }
@@ -106,6 +106,33 @@ export class CubeCreationConfirmationError extends Error {
106
106
  }
107
107
  }
108
108
 
109
+ export type RepositoryAssociationFailure =
110
+ | 'repository-already-associated'
111
+ | 'cube-already-associated'
112
+ | 'access-denied'
113
+ | 'invalid-cube';
114
+
115
+ export class RepositoryAssociationOperationError extends Error {
116
+ constructor(public readonly failure: RepositoryAssociationFailure) {
117
+ super('Borg server rejected the repository cube association');
118
+ this.name = 'RepositoryAssociationOperationError';
119
+ }
120
+ }
121
+
122
+ export class RepositoryAssociationOutcomeUnknownError extends Error {
123
+ constructor() {
124
+ super('Repository cube association outcome is unknown.');
125
+ this.name = 'RepositoryAssociationOutcomeUnknownError';
126
+ }
127
+ }
128
+
129
+ export class RepositoryAssociationResolutionError extends Error {
130
+ constructor() {
131
+ super('Repository cube association could not be resolved.');
132
+ this.name = 'RepositoryAssociationResolutionError';
133
+ }
134
+ }
135
+
109
136
  /** Exact retired TTL-replacement state: two saved bearers and no safe implicit winner. */
110
137
  export class LegacySessionCredentialCollisionError extends Error {
111
138
  constructor(public readonly origin: string) {
@@ -2,7 +2,7 @@ import { spawn as spawnChild, type SpawnOptions } from 'node:child_process';
2
2
  import { constants } from 'node:os';
3
3
  import { cubeInitHelpText, isHelpFlag, serverHelpText } from './cli-help.js';
4
4
 
5
- export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'stop', 'status', 'update', 'invite'] as const;
5
+ export const SERVER_LIFECYCLE_COMMANDS = ['setup', 'start', 'stop', 'status', 'update', 'invite', 'dashboard'] as const;
6
6
  export type ServerLifecycleCommand = typeof SERVER_LIFECYCLE_COMMANDS[number];
7
7
 
8
8
  export type ParsedServerFacadeArgs =
@@ -48,6 +48,7 @@ export interface ServerFacadeProcessDeps {
48
48
  args: readonly string[],
49
49
  options: Pick<SpawnOptions, 'shell' | 'stdio'>,
50
50
  ): ServerFacadeChild;
51
+ isInteractiveTerminal(): boolean;
51
52
  addSignalListener(signal: NodeJS.Signals, listener: () => void): void;
52
53
  removeSignalListener(signal: NodeJS.Signals, listener: () => void): void;
53
54
  }
@@ -70,6 +71,7 @@ export type ServerFacadeProcessResult =
70
71
 
71
72
  const defaultProcessDeps: ServerFacadeProcessDeps = {
72
73
  spawn: (command, args, options) => spawnChild(command, [...args], options),
74
+ isInteractiveTerminal: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
73
75
  addSignalListener: (signal, listener) => process.on(signal, listener),
74
76
  removeSignalListener: (signal, listener) => process.off(signal, listener),
75
77
  };
@@ -125,7 +127,7 @@ function inertCommand(command: string): string {
125
127
  export function unknownServerCommandText(command: string): string {
126
128
  return (
127
129
  `Unknown server command: ${inertCommand(command)}.\n` +
128
- `Available commands: setup, start, stop, status, update, invite, cube init.\n` +
130
+ `Available commands: setup, start, stop, status, update, invite, dashboard, cube init.\n` +
129
131
  `Next: run borg server --help.\n`
130
132
  );
131
133
  }
@@ -159,7 +161,6 @@ export function runServerFacadeProcess(
159
161
  [input.command, ...input.args],
160
162
  { shell: false, stdio: 'inherit' },
161
163
  );
162
- const signals = ['SIGINT', 'SIGTERM'] as const;
163
164
 
164
165
  return new Promise((resolve) => {
165
166
  let settled = false;
@@ -177,13 +178,18 @@ export function runServerFacadeProcess(
177
178
  resolve(result);
178
179
  };
179
180
 
180
- for (const signal of signals) {
181
- const listener = () => {
182
- child.kill(signal);
183
- };
184
- forwarders.set(signal, listener);
185
- deps.addSignalListener(signal, listener);
186
- }
181
+ const forwardSigint = () => {
182
+ // An interactive terminal has already sent SIGINT to this foreground
183
+ // group, including the server. Forwarding again kills its cleanup path.
184
+ // Node exposes no signal origin, so parent-only SIGINT while attached to
185
+ // a TTY is intentionally treated as terminal delivery and is not forwarded.
186
+ if (!deps.isInteractiveTerminal()) child.kill('SIGINT');
187
+ };
188
+ const forwardSigterm = () => child.kill('SIGTERM');
189
+ forwarders.set('SIGINT', forwardSigint);
190
+ forwarders.set('SIGTERM', forwardSigterm);
191
+ deps.addSignalListener('SIGINT', forwardSigint);
192
+ deps.addSignalListener('SIGTERM', forwardSigterm);
187
193
 
188
194
  child.once('error', (error) => settle({ kind: 'spawn-error', error }));
189
195
  child.once('exit', (code, signal) => {