borgmcp 2.1.0 → 2.1.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.
@@ -46,6 +46,9 @@ import {
46
46
  CubeCreationConfirmationError,
47
47
  CubeCreationOutcomeUnknownError,
48
48
  LegacySessionCredentialCollisionError,
49
+ RepositoryAssociationOperationError,
50
+ RepositoryAssociationOutcomeUnknownError,
51
+ RepositoryAssociationResolutionError,
49
52
  } from './server-errors.js';
50
53
  import type { SeatStatus } from './seat-probe.js';
51
54
  import type { ServerSessionOperation } from './config.js';
@@ -54,12 +57,18 @@ import type { SeatBinding, BindPendingSeatOutcome } from './seats.js';
54
57
  import { createHash } from 'node:crypto';
55
58
  import { buildOpenCodeLaunchArgs, type LaunchApprovalDecision } from './cli-tool-approval.js';
56
59
  import { resolveWorkingRepo, type WorkingRepo } from './working-repo.js';
57
- import type { CreateCubeRepository, CubeTemplate } from 'borgmcp-shared/protocol';
60
+ import type {
61
+ AssociateRepositoryCubeResponse,
62
+ CreateCubeRepository,
63
+ CubeTemplate,
64
+ } from 'borgmcp-shared/protocol';
58
65
  import {
59
66
  initializeRepositoryCube,
67
+ RepositoryAssociationConfirmationError,
60
68
  RepositoryAssociationSaveError,
61
69
  validRepositoryCubeName,
62
70
  type RepositoryCubeCreation,
71
+ type RepositoryCubeResolution,
63
72
  } from './repository-cube-init.js';
64
73
  import type { GitRepositoryContext, RepositoryAssociation } from './repository-identity.js';
65
74
 
@@ -266,6 +275,18 @@ export interface AssimilateDeps {
266
275
  serverCapabilities?: readonly string[];
267
276
  } | null>;
268
277
 
278
+ resolveRepositoryCube: (
279
+ apiUrl: string,
280
+ token: string,
281
+ input: { workingRepoName: string; repository: CreateCubeRepository },
282
+ serverTrustIdentity?: string,
283
+ ) => Promise<RepositoryCubeResolution>;
284
+ associateRepositoryCube: (
285
+ apiUrl: string,
286
+ token: string,
287
+ input: { cubeId: string; workingRepoName: string; repository: CreateCubeRepository },
288
+ serverTrustIdentity?: string,
289
+ ) => Promise<AssociateRepositoryCubeResponse>;
269
290
  listCubes: (apiUrl: string, token: string, serverTrustIdentity?: string) => Promise<CubeSummary[]>;
270
291
  getCube: (apiUrl: string, token: string, cubeId: string, serverTrustIdentity?: string) => Promise<CubeDetail>;
271
292
  createCube: (
@@ -684,71 +705,12 @@ export async function runAssimilate(
684
705
 
685
706
  let initialized;
686
707
  try {
687
- if (!auth.serverCapabilities.includes('create_cube') && !existing) {
688
- const repository = await deps.getRepositoryIdentity(repositoryContext);
689
- const association = await deps.getRepositoryAssociation(auth.serverTrustIdentity, repository);
690
- if (association) {
691
- initialized = await initializeRepositoryCube({
692
- mode, context: repositoryContext, serverOrigin: auth.apiUrl, flags: args.flags,
693
- }, {
694
- isTTY: deps.isTTY,
695
- prompt: deps.prompt,
696
- write: deps.stderr,
697
- getIdentity: async () => repository,
698
- getAssociation: async () => association,
699
- saveAssociation: (identity, saved) =>
700
- deps.saveRepositoryAssociation(auth.serverTrustIdentity, identity, saved),
701
- getCube: (cubeId) => deps.getCube(auth.apiUrl, auth.token, cubeId, auth.serverTrustIdentity),
702
- createCube: (params) => deps.createCube(auth.apiUrl, auth.token, params, auth.serverTrustIdentity),
703
- });
704
- } else {
705
- const candidateName = args.flags.cubeName ?? repositoryContext.derivedName;
706
- const accessible = await deps.listCubes(auth.apiUrl, auth.token, auth.serverTrustIdentity);
707
- const matches = accessible.filter((cube) => cube.name === candidateName);
708
- if (matches.length !== 1) {
709
- deps.stderr(matches.length === 0
710
- ? `This enrolled client cannot create a cube on ${auth.apiUrl}. Ask the server operator to grant access to a cube, then rerun ${localAssimilateCommand(auth.apiUrl)}.\n`
711
- : `More than one accessible cube is named '${candidateName}'. Nothing was changed. Ask the server operator for an unambiguous repository cube grant.\n`);
712
- return 1;
713
- }
714
- const cube = await deps.getCube(auth.apiUrl, auth.token, matches[0].id, auth.serverTrustIdentity);
715
- const association = {
716
- cubeId: cube.id,
717
- name: cube.name,
718
- workingRepoName: repositoryContext.derivedName,
719
- template: 'default' as const,
720
- };
721
- try {
722
- await deps.saveRepositoryAssociation(auth.serverTrustIdentity, repository, association);
723
- } catch {
724
- throw new RepositoryAssociationSaveError();
725
- }
726
- const response = {
727
- result: 'resolved' as const,
728
- cube_id: cube.id,
729
- name: cube.name,
730
- working_repo_name: repositoryContext.derivedName,
731
- repository,
732
- template: 'default' as const,
733
- human_seat_role_id: cube.roles.find((role) => role.is_human_seat)?.id ?? '',
734
- default_worker_role_id: cube.roles.find((role) => role.is_default)?.id ?? '',
735
- access: 'manage' as const,
736
- };
737
- deps.stderr(
738
- `Cube already initialized.\n Name: ${cube.name}\n Template: Default (legacy)\n` +
739
- ` Repository: ${repositoryContext.root}\n Server: ${auth.apiUrl}\n` +
740
- (mode === 'cube-init'
741
- ? `No drone was created.\nNext: borg assimilate --host ${shellEscape(auth.apiUrl)}\n`
742
- : 'Continuing with role and seat setup...\n'),
743
- );
744
- initialized = { kind: 'success' as const, creation: { response, cube }, existing: true };
745
- }
746
- } else {
747
708
  initialized = await initializeRepositoryCube({
748
709
  mode,
749
710
  context: repositoryContext,
750
711
  serverOrigin: auth.apiUrl,
751
712
  flags: args.flags,
713
+ canCreate: auth.serverCapabilities.includes('create_cube'),
752
714
  }, {
753
715
  isTTY: deps.isTTY,
754
716
  prompt: deps.prompt,
@@ -757,11 +719,62 @@ export async function runAssimilate(
757
719
  getAssociation: (repository) => deps.getRepositoryAssociation(auth.serverTrustIdentity, repository),
758
720
  saveAssociation: (repository, association) =>
759
721
  deps.saveRepositoryAssociation(auth.serverTrustIdentity, repository, association),
722
+ resolveAssociation: (repository, workingRepoName) => deps.resolveRepositoryCube(
723
+ auth.apiUrl,
724
+ auth.token,
725
+ { repository, workingRepoName },
726
+ auth.serverTrustIdentity,
727
+ ),
728
+ listCubes: () => deps.listCubes(auth.apiUrl, auth.token, auth.serverTrustIdentity),
729
+ associateCube: (params) => deps.associateRepositoryCube(
730
+ auth.apiUrl,
731
+ auth.token,
732
+ params,
733
+ auth.serverTrustIdentity,
734
+ ),
760
735
  getCube: (cubeId) => deps.getCube(auth.apiUrl, auth.token, cubeId, auth.serverTrustIdentity),
761
736
  createCube: (params) => deps.createCube(auth.apiUrl, auth.token, params, auth.serverTrustIdentity),
762
737
  });
763
- }
764
738
  } catch (error) {
739
+ if (error instanceof RepositoryAssociationOutcomeUnknownError) {
740
+ deps.stderr(
741
+ 'Repository cube association outcome is unknown.\n' +
742
+ 'The server may have created the repository binding; no local repository association was saved and no drone was created.\n' +
743
+ 'Run the same command again; Borg will first resolve the authoritative server association without creating a cube.\n',
744
+ );
745
+ return 1;
746
+ }
747
+ if (error instanceof RepositoryAssociationResolutionError) {
748
+ deps.stderr(
749
+ 'Repository cube association could not be resolved.\n' +
750
+ 'Verify that the server is reachable and the client and server versions match, then run the same command again.\n' +
751
+ 'No cube, repository binding, or drone was created.\n',
752
+ );
753
+ return 1;
754
+ }
755
+ if (error instanceof RepositoryAssociationOperationError) {
756
+ const recovery = error.failure === 'repository-already-associated'
757
+ ? 'This repository is already associated with another cube. Run the same command again to use the existing managed association, or ask the server operator to correct the repository binding.'
758
+ : error.failure === 'cube-already-associated'
759
+ ? 'The selected cube is already associated with another repository. Choose a different cube, or run the command from the repository already linked to that cube.'
760
+ : error.failure === 'access-denied'
761
+ ? 'This enrolled client does not have permission to manage the selected cube. Ask the server operator to grant this client management access, then run the same command again.'
762
+ : 'The selected cube does not have valid authoritative roles. Ask the server operator to repair its role configuration, or choose another cube.';
763
+ deps.stderr(
764
+ 'Repository cube association could not be completed.\n' +
765
+ `${recovery}\n` +
766
+ 'No cube, repository binding, or drone was created.\n',
767
+ );
768
+ return 1;
769
+ }
770
+ if (error instanceof RepositoryAssociationConfirmationError) {
771
+ deps.stderr(
772
+ 'Repository cube association could not be confirmed.\n' +
773
+ 'The server may have created the repository binding; no local repository association was saved and no drone was created.\n' +
774
+ 'Run the same command again; Borg will resolve authoritative server state before creating or associating a cube.\n',
775
+ );
776
+ return 1;
777
+ }
765
778
  if (error instanceof RepositoryAssociationSaveError) {
766
779
  deps.stderr(
767
780
  'The repository cube was confirmed, but Borg could not save its local repository association.\n' +
@@ -772,18 +785,17 @@ export async function runAssimilate(
772
785
  }
773
786
  if (error instanceof CubeCreationOutcomeUnknownError) {
774
787
  deps.stderr(
775
- 'Cube creation outcome is unknown.\n' +
776
- 'No local repository association was saved.\n' +
777
- 'Run the same command again; the server will resolve the original request or return a conflict.\n',
788
+ 'Cube creation outcome is unconfirmed.\n' +
789
+ 'The server may have created the cube and repository binding; no local repository association was saved and no drone was created.\n' +
790
+ 'Run the same command again; Borg will resolve authoritative server state before creating a cube.\n',
778
791
  );
779
792
  return 1;
780
793
  }
781
794
  if (error instanceof CubeCreationConfirmationError) {
782
795
  deps.stderr(
783
796
  'Cube creation could not be confirmed.\n' +
784
- `${error.message}\n` +
785
- 'No local repository association was saved.\n' +
786
- 'Resolve the conflict, then run the same command again.\n',
797
+ 'The server may have created the cube and repository binding; no local repository association was saved and no drone was created.\n' +
798
+ 'Run the same command again; Borg will resolve authoritative server state before creating a cube.\n',
787
799
  );
788
800
  return 1;
789
801
  }
@@ -797,7 +809,11 @@ export async function runAssimilate(
797
809
  if (error instanceof BorgServerError) {
798
810
  return reportServerFailure(deps, auth.apiUrl, error);
799
811
  }
800
- deps.stderr('Could not create cube.\nThe selected Borg server rejected the request.\nNothing was changed.\n');
812
+ deps.stderr(
813
+ 'Repository cube initialization failed.\n' +
814
+ 'The server may have created or associated a cube; local repository state may be incomplete and no drone was created.\n' +
815
+ 'Run the same command again; Borg will resolve authoritative server state before creating or associating a cube.\n',
816
+ );
801
817
  return 1;
802
818
  }
803
819
  if (initialized.kind === 'stop') return initialized.code;
@@ -25,10 +25,12 @@ import {
25
25
  } from './remote-client.js';
26
26
  import {
27
27
  DEFAULT_LOCAL_SERVER_ORIGIN,
28
+ associateLocalBorgServerRepositoryCube,
28
29
  connectLocalBorgServer,
29
30
  createLocalBorgServerCube,
30
31
  enrollLocalBorgServer,
31
32
  probeLocalBorgServer,
33
+ resolveLocalBorgServerRepositoryCube,
32
34
  resumeLocalBorgServerEnrollment,
33
35
  sendBorgServerAttach,
34
36
  } from './server-handshake.js';
@@ -45,7 +47,7 @@ import {
45
47
  } from './cubes.js';
46
48
  import { loadBorgServerTrust } from './server-trust.js';
47
49
  import { defaultProbeSeat } from './seat-probe.js';
48
- import { BorgServerError } from './server-errors.js';
50
+ import { BorgServerError, CubeCreationConfirmationError } from './server-errors.js';
49
51
  import {
50
52
  findProjectRoot as cubesFindProjectRoot,
51
53
  getActiveCube as cubesGetActive,
@@ -238,6 +240,29 @@ export function buildDefaultAssimilateDeps(
238
240
  ...(onPending === undefined ? {} : { onPending }),
239
241
  }),
240
242
 
243
+ resolveRepositoryCube: async (apiUrl, token, input, serverTrustIdentity) => {
244
+ if (serverTrustIdentity === undefined) {
245
+ throw new Error('Selected Borg server authority state is missing or unreadable');
246
+ }
247
+ return resolveLocalBorgServerRepositoryCube(
248
+ apiUrl,
249
+ serverTrustIdentity,
250
+ token,
251
+ input,
252
+ );
253
+ },
254
+ associateRepositoryCube: async (apiUrl, token, input, serverTrustIdentity) => {
255
+ if (serverTrustIdentity === undefined) {
256
+ throw new Error('Selected Borg server authority state is missing or unreadable');
257
+ }
258
+ return associateLocalBorgServerRepositoryCube(
259
+ apiUrl,
260
+ serverTrustIdentity,
261
+ token,
262
+ input,
263
+ );
264
+ },
265
+
241
266
  listCubes: async (apiUrl, token, serverTrustIdentity) => {
242
267
  if (serverTrustIdentity === undefined) {
243
268
  throw new Error('Selected Borg server authority state is missing or unreadable');
@@ -284,18 +309,23 @@ export function buildDefaultAssimilateDeps(
284
309
  template: params.template,
285
310
  },
286
311
  );
287
- const cube = await remoteGetCube(created.cube_id, {
288
- apiUrl,
289
- authToken: token,
290
- serverTrustIdentity,
291
- });
312
+ let cube;
313
+ try {
314
+ cube = await remoteGetCube(created.cube_id, {
315
+ apiUrl,
316
+ authToken: token,
317
+ serverTrustIdentity,
318
+ });
319
+ } catch {
320
+ throw new CubeCreationConfirmationError('The server returned a cube result that could not be read back.');
321
+ }
292
322
  if (
293
323
  cube.id !== created.cube_id ||
294
324
  cube.name !== created.name ||
295
325
  !Array.isArray(cube.roles) ||
296
326
  !cube.roles.some((role: { id?: string }) => role.id === created.default_worker_role_id)
297
327
  ) {
298
- throw new Error('Borg server returned cube details outside the creation result');
328
+ throw new CubeCreationConfirmationError('The server returned cube details outside the creation result.');
299
329
  }
300
330
  return {
301
331
  response: created,
package/src/cli-help.ts CHANGED
@@ -71,7 +71,7 @@ export function cubeInitHelpText(): string {
71
71
  ` --enroll Prompt for a hidden enrollment invitation\n` +
72
72
  ` --cube-name <name> Repository cube name (otherwise edit the proposed name)\n` +
73
73
  ` --template software-dev|starter New-cube template (default: software-dev)\n` +
74
- ` --yes, -y Skip confirmation prompts\n` +
74
+ ` --yes, -y Accept new-cube defaults; never adopt by name\n` +
75
75
  ` --help, -h Show this help\n`
76
76
  );
77
77
  }
@@ -101,11 +101,12 @@ export function assimilateHelpText(version: string): string {
101
101
  ` --no-template Unsupported for repository cube creation\n` +
102
102
  ` --cli claude|codex|opencode Agent CLI to launch (default: claude)\n` +
103
103
  ` --model claude:<model> Legacy Claude model override (configure models in the agent CLI)\n` +
104
- ` --yes, -y Skip confirmation prompts\n\n` +
104
+ ` --yes, -y Accept new-cube defaults; never adopt by name\n\n` +
105
105
  `Creation shows repository context, name, template, and one confirmation. An existing\n` +
106
- `repository association skips all prompts. An enrolled owner client may create an\n` +
107
- `idempotent repository cube; ordinary clients\n` +
108
- `require an explicit cube grant. Agent seats begin only after enrollment. Preview only.\n` +
106
+ `repository association skips all prompts. One accessible exact-name legacy cube requires\n` +
107
+ `explicit interactive adoption; ambiguous matches fail closed. An enrolled owner client may\n` +
108
+ `create an idempotent repository cube; ordinary clients require an explicit cube grant.\n` +
109
+ `Agent seats begin only after enrollment. Preview only.\n` +
109
110
  `See docs/LOCAL_SERVER.md for self-hosted setup and current status.\n\n` +
110
111
  `For local or provider-specific models, configure the selected agent CLI directly.\n` +
111
112
  `OpenCode supports Ollama and other providers through its own model configuration.\n`
@@ -712,7 +712,7 @@ async function authedFetch(
712
712
  parsed.error !== null && typeof parsed.error === 'object' &&
713
713
  parsed.error.code === ROLE_SECTION_CONFLICT_CODE
714
714
  ) {
715
- // Shared 0.6.3 intentionally omits this server-local code. Re-validate the whole
715
+ // The shared protocol intentionally omits this server-local code. Re-validate the whole
716
716
  // envelope through the strict shared decoder with only the recognized
717
717
  // code substituted; no server-provided diagnostic is ever surfaced.
718
718
  decodeProtocolErrorEnvelope({
@@ -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
  }