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.
- package/README.md +2 -2
- package/dist/assimilate-cmd.d.ts +11 -2
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +60 -85
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +26 -8
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +6 -5
- package/dist/cli-help.js.map +1 -1
- package/dist/remote-client.js +1 -1
- package/dist/remote-client.js.map +1 -1
- package/dist/repository-cube-init.d.ts +16 -1
- package/dist/repository-cube-init.d.ts.map +1 -1
- package/dist/repository-cube-init.js +167 -22
- package/dist/repository-cube-init.js.map +1 -1
- package/dist/server-errors.d.ts +11 -0
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js +20 -0
- package/dist/server-errors.js.map +1 -1
- package/dist/server-handshake.d.ts +32 -2
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +161 -8
- package/dist/server-handshake.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +9 -7
- package/docs/LOCAL_SERVER.md +19 -13
- package/docs/RELEASING.md +14 -5
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +85 -69
- package/src/assimilate-deps.ts +37 -7
- package/src/cli-help.ts +6 -5
- package/src/remote-client.ts +1 -1
- package/src/repository-cube-init.ts +202 -23
- package/src/server-errors.ts +27 -0
- package/src/server-handshake.ts +226 -8
package/src/assimilate-cmd.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
776
|
-
'
|
|
777
|
-
'Run the same command again;
|
|
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
|
-
|
|
785
|
-
'
|
|
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(
|
|
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;
|
package/src/assimilate-deps.ts
CHANGED
|
@@ -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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
107
|
-
`
|
|
108
|
-
`
|
|
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`
|
package/src/remote-client.ts
CHANGED
|
@@ -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
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
136
|
-
input.flags.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
257
|
-
name:
|
|
258
|
-
workingRepoName:
|
|
259
|
-
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:
|
|
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 {
|
|
447
|
+
return {
|
|
448
|
+
kind: 'success',
|
|
449
|
+
creation: { response: confirmed, cube: creation.cube },
|
|
450
|
+
existing: creation.response.result === 'resolved',
|
|
451
|
+
};
|
|
273
452
|
}
|