borgmcp 2.0.10 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/SECURITY.md +1 -1
- package/dist/assimilate-cmd.d.ts +13 -4
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +134 -110
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/assimilate-deps.d.ts +7 -1
- package/dist/assimilate-deps.d.ts.map +1 -1
- package/dist/assimilate-deps.js +44 -18
- package/dist/assimilate-deps.js.map +1 -1
- package/dist/claude.d.ts +3 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +26 -12
- package/dist/claude.js.map +1 -1
- package/dist/cli-help.d.ts +2 -0
- package/dist/cli-help.d.ts.map +1 -1
- package/dist/cli-help.js +22 -5
- package/dist/cli-help.js.map +1 -1
- package/dist/config.d.ts +7 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +21 -4
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -8
- package/dist/index.js.map +1 -1
- package/dist/local-manage-tool-result.d.ts +10 -0
- package/dist/local-manage-tool-result.d.ts.map +1 -1
- package/dist/local-manage-tool-result.js +36 -0
- package/dist/local-manage-tool-result.js.map +1 -1
- package/dist/remote-client.d.ts +8 -5
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +54 -11
- package/dist/remote-client.js.map +1 -1
- package/dist/repository-cube-init.d.ts +57 -0
- package/dist/repository-cube-init.d.ts.map +1 -0
- package/dist/repository-cube-init.js +196 -0
- package/dist/repository-cube-init.js.map +1 -0
- package/dist/repository-identity.d.ts +29 -0
- package/dist/repository-identity.d.ts.map +1 -0
- package/dist/repository-identity.js +143 -0
- package/dist/repository-identity.js.map +1 -0
- package/dist/server-errors.d.ts +6 -0
- package/dist/server-errors.d.ts.map +1 -1
- package/dist/server-errors.js +12 -0
- package/dist/server-errors.js.map +1 -1
- package/dist/server-facade.d.ts +11 -1
- package/dist/server-facade.d.ts.map +1 -1
- package/dist/server-facade.js +33 -3
- package/dist/server-facade.js.map +1 -1
- package/dist/server-handshake.d.ts +8 -4
- package/dist/server-handshake.d.ts.map +1 -1
- package/dist/server-handshake.js +15 -6
- package/dist/server-handshake.js.map +1 -1
- package/dist/tool-manifest.js +2 -2
- package/dist/tool-manifest.js.map +1 -1
- package/docs/EXTRACTION_PROVENANCE.md +8 -8
- package/docs/LOCAL_SERVER.md +35 -6
- package/docs/RELEASING.md +25 -14
- package/package.json +2 -2
- package/src/assimilate-cmd.ts +166 -140
- package/src/assimilate-deps.ts +59 -17
- package/src/claude.ts +34 -14
- package/src/cli-help.ts +25 -5
- package/src/config.ts +30 -8
- package/src/index.ts +9 -8
- package/src/local-manage-tool-result.ts +47 -0
- package/src/remote-client.ts +64 -19
- package/src/repository-cube-init.ts +273 -0
- package/src/repository-identity.ts +199 -0
- package/src/server-errors.ts +14 -0
- package/src/server-facade.ts +47 -2
- package/src/server-handshake.ts +33 -7
- package/src/tool-manifest.ts +2 -2
package/src/config.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* read-compare-write shared by client enrollment and same-machine server setup.
|
|
8
8
|
*/
|
|
9
9
|
import { createHash, randomBytes, randomUUID } from 'crypto';
|
|
10
|
-
import type { ServerCapability } from 'borgmcp-shared/protocol';
|
|
10
|
+
import type { CreateCubeRepository, CubeTemplate, ServerCapability } from 'borgmcp-shared/protocol';
|
|
11
11
|
import { withStoreLock } from './seat-store.js';
|
|
12
12
|
import {
|
|
13
13
|
makeFileBackend,
|
|
@@ -17,7 +17,7 @@ import { BORG_USER_ROOT, SERVER_CREDENTIALS_FILE } from './credential-paths.js';
|
|
|
17
17
|
|
|
18
18
|
const SERVER_CREDENTIAL_RECORD_VERSION = 2 as const;
|
|
19
19
|
const SERVER_PENDING_ENROLLMENT_RECORD_VERSION = 1 as const;
|
|
20
|
-
const SERVER_CUBE_RETRY_RECORD_VERSION =
|
|
20
|
+
const SERVER_CUBE_RETRY_RECORD_VERSION = 2 as const;
|
|
21
21
|
// The 0600 credential store (Queen rescope: replaces the OS keychain). A single
|
|
22
22
|
// file holds every parent credential/enrollment record; a single flock
|
|
23
23
|
// serializes every mutator + observer that must (SR-seven #4).
|
|
@@ -57,7 +57,9 @@ export interface PendingServerCubeCreationRecord {
|
|
|
57
57
|
repositoryBinding: string;
|
|
58
58
|
retryKey: string;
|
|
59
59
|
name: string;
|
|
60
|
-
|
|
60
|
+
workingRepoName: string;
|
|
61
|
+
repository: CreateCubeRepository;
|
|
62
|
+
template: CubeTemplate;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
/**
|
|
@@ -510,21 +512,29 @@ export async function getOrCreatePendingServerCubeCreation(
|
|
|
510
512
|
origin: string;
|
|
511
513
|
trustIdentity: string;
|
|
512
514
|
clientId: string;
|
|
513
|
-
projectRoot: string;
|
|
514
515
|
name: string;
|
|
515
|
-
|
|
516
|
+
workingRepoName: string;
|
|
517
|
+
repository: CreateCubeRepository;
|
|
518
|
+
template: CubeTemplate;
|
|
516
519
|
},
|
|
517
520
|
): Promise<PendingServerCubeCreationRecord> {
|
|
518
521
|
validateServerCredentialBinding(input.origin, input.trustIdentity);
|
|
519
522
|
validateUuid(input.clientId, 'client identity');
|
|
520
|
-
if (
|
|
523
|
+
if (
|
|
524
|
+
(input.repository.kind !== 'origin' && input.repository.kind !== 'local') ||
|
|
525
|
+
typeof input.repository.value !== 'string' || input.repository.value.length < 1
|
|
526
|
+
) {
|
|
521
527
|
throw new Error('invalid Borg server repository binding');
|
|
522
528
|
}
|
|
523
529
|
if (Buffer.byteLength(input.name, 'utf8') < 1 || Buffer.byteLength(input.name, 'utf8') > 120 ||
|
|
524
530
|
!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(input.name)) {
|
|
525
531
|
throw new Error('invalid Borg server cube name');
|
|
526
532
|
}
|
|
527
|
-
const repositoryBinding = createHash('sha256')
|
|
533
|
+
const repositoryBinding = createHash('sha256')
|
|
534
|
+
.update(input.repository.kind)
|
|
535
|
+
.update('\0')
|
|
536
|
+
.update(input.repository.value)
|
|
537
|
+
.digest('hex');
|
|
528
538
|
const backend = await getServerCredentialBackend();
|
|
529
539
|
const account = serverCubeRetryAccount(
|
|
530
540
|
input.origin,
|
|
@@ -548,6 +558,7 @@ export async function getOrCreatePendingServerCubeCreation(
|
|
|
548
558
|
record.clientId !== input.clientId ||
|
|
549
559
|
record.repositoryBinding !== repositoryBinding ||
|
|
550
560
|
record.name !== input.name ||
|
|
561
|
+
record.workingRepoName !== input.workingRepoName ||
|
|
551
562
|
record.template !== input.template ||
|
|
552
563
|
typeof record.retryKey !== 'string' || !UUID_RE.test(record.retryKey)
|
|
553
564
|
) {
|
|
@@ -560,6 +571,8 @@ export async function getOrCreatePendingServerCubeCreation(
|
|
|
560
571
|
repositoryBinding,
|
|
561
572
|
retryKey: record.retryKey,
|
|
562
573
|
name: input.name,
|
|
574
|
+
workingRepoName: input.workingRepoName,
|
|
575
|
+
repository: input.repository,
|
|
563
576
|
template: input.template,
|
|
564
577
|
};
|
|
565
578
|
} catch {
|
|
@@ -573,12 +586,21 @@ export async function getOrCreatePendingServerCubeCreation(
|
|
|
573
586
|
repositoryBinding,
|
|
574
587
|
retryKey: randomUUID(),
|
|
575
588
|
name: input.name,
|
|
589
|
+
workingRepoName: input.workingRepoName,
|
|
590
|
+
repository: input.repository,
|
|
576
591
|
template: input.template,
|
|
577
592
|
};
|
|
578
593
|
await backend.set(account, JSON.stringify({
|
|
579
594
|
version: SERVER_CUBE_RETRY_RECORD_VERSION,
|
|
580
595
|
state: 'pending',
|
|
581
|
-
|
|
596
|
+
origin: record.origin,
|
|
597
|
+
trustIdentity: record.trustIdentity,
|
|
598
|
+
clientId: record.clientId,
|
|
599
|
+
repositoryBinding: record.repositoryBinding,
|
|
600
|
+
retryKey: record.retryKey,
|
|
601
|
+
name: record.name,
|
|
602
|
+
workingRepoName: record.workingRepoName,
|
|
603
|
+
template: record.template,
|
|
582
604
|
}));
|
|
583
605
|
return record;
|
|
584
606
|
});
|
package/src/index.ts
CHANGED
|
@@ -139,14 +139,15 @@ import {
|
|
|
139
139
|
/**
|
|
140
140
|
* Apply a template's roles + message_taxonomy to a cube.
|
|
141
141
|
*
|
|
142
|
-
*
|
|
143
|
-
* are inserted; existing template-named roles get ADD
|
|
144
|
-
* sections/classes the cube lacks) auto-applied, but
|
|
145
|
-
* fragments are
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
142
|
+
* Client-orchestrated through the server's non-clobbering role and taxonomy
|
|
143
|
+
* primitives. New roles are inserted; existing template-named roles get ADD
|
|
144
|
+
* fragments (template sections/classes the cube lacks) auto-applied, but
|
|
145
|
+
* EVOLVED (conflicting) fragments are kept — never silently overwritten. The
|
|
146
|
+
* old per-role blanket `updateRole`/whole-taxonomy `updateCube` overwrite path
|
|
147
|
+
* is removed. Operators who want to take the template version of a conflicting
|
|
148
|
+
* fragment use `borg_sync-roles` with a `decisions` map. Primitive operations
|
|
149
|
+
* are sequential, so a later failure can leave earlier changes committed.
|
|
150
|
+
* Returns `{ created, updated }` for the caller's toast.
|
|
150
151
|
*/
|
|
151
152
|
async function applyTemplateToCube(
|
|
152
153
|
cubeId: string,
|
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
import { LocalManageRequiredError } from './server-errors.js';
|
|
2
2
|
|
|
3
|
+
export interface RoleSectionConflictOperation {
|
|
4
|
+
roleId: string;
|
|
5
|
+
action: 'replace' | 'insert' | 'delete';
|
|
6
|
+
heading: string;
|
|
7
|
+
after?: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class RoleSectionConflictError extends Error {
|
|
11
|
+
constructor(public readonly operation: RoleSectionConflictOperation) {
|
|
12
|
+
super('The role section patch conflicted with the current role text.');
|
|
13
|
+
this.name = 'RoleSectionConflictError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const UNSAFE_OPERATION_CHARACTER =
|
|
18
|
+
/["\\`*_[\]()<>#!|~\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/u;
|
|
19
|
+
|
|
20
|
+
function safeOperationValue(value: string): string {
|
|
21
|
+
const escaped = Array.from(value, (character) =>
|
|
22
|
+
UNSAFE_OPERATION_CHARACTER.test(character)
|
|
23
|
+
? `\\u{${character.codePointAt(0)!.toString(16)}}`
|
|
24
|
+
: character).join('');
|
|
25
|
+
return `"${escaped}"`;
|
|
26
|
+
}
|
|
27
|
+
|
|
3
28
|
export function formatLocalManageToolResult(error: unknown) {
|
|
29
|
+
if (error instanceof RoleSectionConflictError) {
|
|
30
|
+
const { roleId, action, heading, after } = error.operation;
|
|
31
|
+
const context = [
|
|
32
|
+
`action=${action}`,
|
|
33
|
+
`heading=${safeOperationValue(heading)}`,
|
|
34
|
+
...(action === 'insert'
|
|
35
|
+
? [`after=${after == null ? '<end>' : safeOperationValue(after)}`]
|
|
36
|
+
: []),
|
|
37
|
+
].join(', ');
|
|
38
|
+
return {
|
|
39
|
+
content: [{
|
|
40
|
+
type: 'text' as const,
|
|
41
|
+
text:
|
|
42
|
+
`[ROLE-SECTION-CONFLICT] The requested role section operation conflicts with the current role text.\n\n` +
|
|
43
|
+
`Requested operation: ${context}\n\n` +
|
|
44
|
+
'No role text was changed by this request. ' +
|
|
45
|
+
`Read the current role with \`borg_role\` using role id ${safeOperationValue(roleId)}, ` +
|
|
46
|
+
'then retry `borg_patch-role-section` against that current text.',
|
|
47
|
+
}],
|
|
48
|
+
isError: true as const,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
4
51
|
if (!(error instanceof LocalManageRequiredError)) return null;
|
|
5
52
|
|
|
6
53
|
return {
|
package/src/remote-client.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
} from './local-server-cursor.js';
|
|
55
55
|
import { readBoundedResponseBody } from './server-response.js';
|
|
56
56
|
import { resolveLocalLogRecipients } from './local-log-routing.js';
|
|
57
|
+
import { RoleSectionConflictError } from './local-manage-tool-result.js';
|
|
57
58
|
|
|
58
59
|
export interface RemoteConnection {
|
|
59
60
|
apiUrl: string;
|
|
@@ -71,6 +72,7 @@ export const LOCAL_SERVER_RESPONSE_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
|
71
72
|
// A typed auth-error envelope is tiny; anything larger is hostile and the
|
|
72
73
|
// bounded read throws → the 401 fails closed to non-destructive CREDENTIAL_REJECTED.
|
|
73
74
|
const AUTH_ERROR_ENVELOPE_LIMIT_BYTES = 64 * 1024;
|
|
75
|
+
const ROLE_SECTION_CONFLICT_CODE = 'ROLE_SECTION_CONFLICT';
|
|
74
76
|
export const LOCAL_SERVER_REQUEST_TIMEOUT_MS = 5_000;
|
|
75
77
|
const LOCAL_SERVER_RESPONSE_LIMIT_MESSAGE =
|
|
76
78
|
'Local Borg server response exceeded the response limit';
|
|
@@ -701,7 +703,30 @@ async function authedFetch(
|
|
|
701
703
|
AUTH_ERROR_ENVELOPE_LIMIT_BYTES,
|
|
702
704
|
'Local Borg server error response exceeded the response limit',
|
|
703
705
|
);
|
|
704
|
-
|
|
706
|
+
const parsed = JSON.parse(body);
|
|
707
|
+
try {
|
|
708
|
+
code = decodeProtocolErrorEnvelope(parsed).error.code;
|
|
709
|
+
} catch {
|
|
710
|
+
if (
|
|
711
|
+
parsed !== null && typeof parsed === 'object' &&
|
|
712
|
+
parsed.error !== null && typeof parsed.error === 'object' &&
|
|
713
|
+
parsed.error.code === ROLE_SECTION_CONFLICT_CODE
|
|
714
|
+
) {
|
|
715
|
+
// Shared 0.6.3 intentionally omits this server-local code. Re-validate the whole
|
|
716
|
+
// envelope through the strict shared decoder with only the recognized
|
|
717
|
+
// code substituted; no server-provided diagnostic is ever surfaced.
|
|
718
|
+
decodeProtocolErrorEnvelope({
|
|
719
|
+
...parsed,
|
|
720
|
+
error: {
|
|
721
|
+
...parsed.error,
|
|
722
|
+
code: ErrorCode.INVALID_INPUT,
|
|
723
|
+
message: 'Role section conflict.',
|
|
724
|
+
...(Object.hasOwn(parsed.error, 'details') ? { details: 'Redacted.' } : {}),
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
code = ROLE_SECTION_CONFLICT_CODE as ErrorCode;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
705
730
|
} catch {
|
|
706
731
|
code = undefined;
|
|
707
732
|
}
|
|
@@ -1410,19 +1435,36 @@ export async function patchRoleSection(
|
|
|
1410
1435
|
if (!active?.serverTrustIdentity) throw new Error('Selected Borg server authority state is missing or unreadable');
|
|
1411
1436
|
const cubeId = targetCubeId ?? active.cubeId;
|
|
1412
1437
|
assertUuidShape(cubeId, 'cube_id');
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1438
|
+
let result: { role: any } | null;
|
|
1439
|
+
try {
|
|
1440
|
+
result = await localManageRequest<{ role: any }>(
|
|
1441
|
+
active,
|
|
1442
|
+
`/api/cubes/${cubeId}/roles/${roleId}/section-patch`,
|
|
1443
|
+
'POST',
|
|
1444
|
+
{
|
|
1445
|
+
operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
|
|
1446
|
+
cubeName: cubeId === active.cubeId ? active.name : cubeId,
|
|
1447
|
+
noMutation: `No role section was ${op.action === 'insert' ? 'inserted' : op.action === 'replace' ? 'replaced' : 'deleted'}.`,
|
|
1448
|
+
},
|
|
1449
|
+
{ ...op },
|
|
1450
|
+
undefined,
|
|
1451
|
+
connectionOverride,
|
|
1452
|
+
);
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
if (
|
|
1455
|
+
error instanceof BorgServerHttpError &&
|
|
1456
|
+
error.status === 409 &&
|
|
1457
|
+
String(error.code) === ROLE_SECTION_CONFLICT_CODE
|
|
1458
|
+
) {
|
|
1459
|
+
throw new RoleSectionConflictError({
|
|
1460
|
+
roleId,
|
|
1461
|
+
action: op.action,
|
|
1462
|
+
heading: op.heading,
|
|
1463
|
+
...(op.action === 'insert' ? { after: op.after } : {}),
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
throw error;
|
|
1467
|
+
}
|
|
1426
1468
|
if (!result) throw new Error('Local Borg server returned an empty role response');
|
|
1427
1469
|
return result;
|
|
1428
1470
|
}
|
|
@@ -1554,11 +1596,12 @@ export async function getCube(cubeId: string, connection?: RemoteConnection): Pr
|
|
|
1554
1596
|
}
|
|
1555
1597
|
|
|
1556
1598
|
/**
|
|
1557
|
-
*
|
|
1558
|
-
*
|
|
1599
|
+
* Apply a named template through client-orchestrated, non-clobbering role and
|
|
1600
|
+
* taxonomy primitives. New roles are inserted; existing
|
|
1559
1601
|
* template-named roles get ADD fragments auto-applied (template
|
|
1560
1602
|
* sections/classes the cube lacks) but their EVOLVED (conflicting)
|
|
1561
|
-
* fragments are
|
|
1603
|
+
* fragments are kept, never overwritten. Primitive operations are sequential,
|
|
1604
|
+
* so earlier changes may remain committed if a later operation fails. Returns
|
|
1562
1605
|
* `{ created, updated }` counts. To selectively take template versions of
|
|
1563
1606
|
* conflicting fragments, use `syncRoles` with a `decisions` map instead.
|
|
1564
1607
|
*/
|
|
@@ -1605,7 +1648,7 @@ export async function applyTemplate(
|
|
|
1605
1648
|
}
|
|
1606
1649
|
|
|
1607
1650
|
/**
|
|
1608
|
-
*
|
|
1651
|
+
* Client-orchestrated, non-clobbering sync of a cube's roles and taxonomy
|
|
1609
1652
|
* against the current built-in template. Dry-run by default classifies
|
|
1610
1653
|
* each fragment (role-text SECTION / short_description / flags / taxonomy
|
|
1611
1654
|
* CLASS) as ADD / UNCHANGED / CONFLICT. Pass apply=true to commit:
|
|
@@ -1613,7 +1656,9 @@ export async function applyTemplate(
|
|
|
1613
1656
|
* ONLY when their stable key appears in `decisions` as 'accept'.
|
|
1614
1657
|
* Unspecified conflicts DEFAULT TO REJECT — the cube's evolved text is
|
|
1615
1658
|
* never silently overwritten. Custom roles (names not in template) are
|
|
1616
|
-
* never touched.
|
|
1659
|
+
* never touched. Applied primitives are sequential rather than atomic, so an
|
|
1660
|
+
* earlier operation may remain committed if a later operation fails. Returns a
|
|
1661
|
+
* NonClobberSyncResult.
|
|
1617
1662
|
*/
|
|
1618
1663
|
export async function syncRoles(
|
|
1619
1664
|
cubeId: string,
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NEW_CUBE_TEMPLATE_PRESENTATIONS,
|
|
3
|
+
LEGACY_DEFAULT_TEMPLATE_LABEL,
|
|
4
|
+
} from 'borgmcp-shared/templates';
|
|
5
|
+
import type {
|
|
6
|
+
CreateCubeRepository,
|
|
7
|
+
CreateCubeResponse,
|
|
8
|
+
CubeTemplate,
|
|
9
|
+
} from 'borgmcp-shared/protocol';
|
|
10
|
+
import { shellEscape } from './shell-escape.js';
|
|
11
|
+
import type {
|
|
12
|
+
GitRepositoryContext,
|
|
13
|
+
RepositoryAssociation,
|
|
14
|
+
} from './repository-identity.js';
|
|
15
|
+
|
|
16
|
+
export interface RepositoryCubeDetail {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
roles: any[];
|
|
20
|
+
drones?: Array<{ role_id: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RepositoryCubeCreation {
|
|
24
|
+
response: CreateCubeResponse;
|
|
25
|
+
cube: RepositoryCubeDetail;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RepositoryCubeInitFlags {
|
|
29
|
+
cubeName?: string;
|
|
30
|
+
template?: string;
|
|
31
|
+
noTemplate?: boolean;
|
|
32
|
+
yes?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RepositoryCubeInitDeps {
|
|
36
|
+
isTTY(): boolean;
|
|
37
|
+
prompt(message: string): Promise<string>;
|
|
38
|
+
write(text: string): void;
|
|
39
|
+
getIdentity(context: GitRepositoryContext): Promise<CreateCubeRepository>;
|
|
40
|
+
getAssociation(repository: CreateCubeRepository): Promise<RepositoryAssociation | null>;
|
|
41
|
+
saveAssociation(repository: CreateCubeRepository, association: RepositoryAssociation): Promise<void>;
|
|
42
|
+
getCube(cubeId: string): Promise<RepositoryCubeDetail>;
|
|
43
|
+
createCube(input: {
|
|
44
|
+
name: string;
|
|
45
|
+
workingRepoName: string;
|
|
46
|
+
repository: CreateCubeRepository;
|
|
47
|
+
template: Exclude<CubeTemplate, 'default'>;
|
|
48
|
+
}): Promise<RepositoryCubeCreation>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type RepositoryCubeInitResult =
|
|
52
|
+
| { kind: 'success'; creation: RepositoryCubeCreation; existing: boolean }
|
|
53
|
+
| { kind: 'stop'; code: number };
|
|
54
|
+
|
|
55
|
+
export class RepositoryAssociationSaveError extends Error {
|
|
56
|
+
constructor() {
|
|
57
|
+
super('repository association could not be saved');
|
|
58
|
+
this.name = 'RepositoryAssociationSaveError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class PromptInterruptedError extends Error {
|
|
63
|
+
constructor() {
|
|
64
|
+
super('prompt interrupted');
|
|
65
|
+
this.name = 'PromptInterruptedError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const NAME_ERROR = 'Use 1-120 letters, digits, spaces, dots, underscores, or hyphens, starting with a letter or digit.';
|
|
70
|
+
|
|
71
|
+
export function validRepositoryCubeName(value: string): boolean {
|
|
72
|
+
return Buffer.byteLength(value, 'utf8') <= 120 && /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function presentation(template: CubeTemplate) {
|
|
76
|
+
if (template === 'default') {
|
|
77
|
+
return { name: template, label: LEGACY_DEFAULT_TEMPLATE_LABEL };
|
|
78
|
+
}
|
|
79
|
+
return NEW_CUBE_TEMPLATE_PRESENTATIONS.find((candidate) => candidate.name === template)!;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function ask(deps: RepositoryCubeInitDeps, message: string): Promise<{ value: string } | { stop: number }> {
|
|
83
|
+
try {
|
|
84
|
+
return { value: await deps.prompt(message) };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error instanceof PromptInterruptedError) {
|
|
87
|
+
deps.write('\nCube creation cancelled. Nothing was changed.\n');
|
|
88
|
+
return { stop: 130 };
|
|
89
|
+
}
|
|
90
|
+
deps.write('Input ended before cube creation. Nothing was changed.\n');
|
|
91
|
+
return { stop: 1 };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderResult(
|
|
96
|
+
deps: RepositoryCubeInitDeps,
|
|
97
|
+
input: {
|
|
98
|
+
existing: boolean;
|
|
99
|
+
response: Pick<CreateCubeResponse, 'name' | 'working_repo_name' | 'template'>;
|
|
100
|
+
root: string;
|
|
101
|
+
serverOrigin: string;
|
|
102
|
+
mode: 'assimilate' | 'cube-init';
|
|
103
|
+
creationOptionsUnused: boolean;
|
|
104
|
+
},
|
|
105
|
+
): void {
|
|
106
|
+
const lines = [
|
|
107
|
+
input.existing ? 'Cube already initialized.' : 'Cube created.',
|
|
108
|
+
` Name: ${input.response.name}`,
|
|
109
|
+
` Template: ${presentation(input.response.template).label}`,
|
|
110
|
+
` Repository: ${input.root}`,
|
|
111
|
+
` Server: ${input.serverOrigin}`,
|
|
112
|
+
];
|
|
113
|
+
if (input.creationOptionsUnused) {
|
|
114
|
+
lines.push('Creation options were not used because this repository is already initialized.');
|
|
115
|
+
}
|
|
116
|
+
if (input.mode === 'cube-init') {
|
|
117
|
+
lines.push(
|
|
118
|
+
'No drone was created.',
|
|
119
|
+
`Next: borg assimilate --host ${shellEscape(input.serverOrigin)}`,
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
lines.push('Continuing with role and seat setup...');
|
|
123
|
+
}
|
|
124
|
+
deps.write(`${lines.join('\n')}\n`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function initializeRepositoryCube(input: {
|
|
128
|
+
mode: 'assimilate' | 'cube-init';
|
|
129
|
+
context: GitRepositoryContext;
|
|
130
|
+
serverOrigin: string;
|
|
131
|
+
flags: RepositoryCubeInitFlags;
|
|
132
|
+
}, deps: RepositoryCubeInitDeps): Promise<RepositoryCubeInitResult> {
|
|
133
|
+
const repository = await deps.getIdentity(input.context);
|
|
134
|
+
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
|
+
);
|
|
138
|
+
if (association) {
|
|
139
|
+
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
|
+
const response: CreateCubeResponse = {
|
|
149
|
+
result: 'resolved',
|
|
150
|
+
cube_id: association.cubeId,
|
|
151
|
+
name: cube.name,
|
|
152
|
+
working_repo_name: association.workingRepoName,
|
|
153
|
+
repository,
|
|
154
|
+
template: association.template,
|
|
155
|
+
human_seat_role_id: cube.roles.find((role) => role.is_human_seat)?.id ?? '',
|
|
156
|
+
default_worker_role_id: cube.roles.find((role) => role.is_default)?.id ?? '',
|
|
157
|
+
access: 'manage',
|
|
158
|
+
};
|
|
159
|
+
renderResult(deps, {
|
|
160
|
+
existing: true,
|
|
161
|
+
response,
|
|
162
|
+
root: input.context.root,
|
|
163
|
+
serverOrigin: input.serverOrigin,
|
|
164
|
+
mode: input.mode,
|
|
165
|
+
creationOptionsUnused,
|
|
166
|
+
});
|
|
167
|
+
return { kind: 'success', creation: { response, cube }, existing: true };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (input.flags.noTemplate) {
|
|
171
|
+
deps.write('--no-template is not supported for repository cube creation. Use --template software-dev or --template starter.\n');
|
|
172
|
+
return { kind: 'stop', code: 1 };
|
|
173
|
+
}
|
|
174
|
+
if (input.flags.template !== undefined && input.flags.template !== 'software-dev' && input.flags.template !== 'starter') {
|
|
175
|
+
const safe = input.flags.template.replace(/[\u0000-\u001f\u007f]/g, '?').slice(0, 120);
|
|
176
|
+
deps.write(`Unknown template '${safe}'. Use software-dev or starter.\n`);
|
|
177
|
+
return { kind: 'stop', code: 1 };
|
|
178
|
+
}
|
|
179
|
+
if (!deps.isTTY() && !input.flags.yes && (!input.flags.cubeName || !input.flags.template)) {
|
|
180
|
+
deps.write('Non-interactive cube creation requires --cube-name <name> and --template software-dev|starter, or --yes to use repository defaults.\n');
|
|
181
|
+
return { kind: 'stop', code: 1 };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
deps.write(
|
|
185
|
+
`Create a cube for this repository\n` +
|
|
186
|
+
`Repository: ${input.context.root}\n` +
|
|
187
|
+
`Server: ${input.serverOrigin}\n`,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
let name = input.flags.cubeName?.trim() ?? input.context.derivedName;
|
|
191
|
+
if (!input.flags.cubeName && deps.isTTY() && !input.flags.yes) {
|
|
192
|
+
while (true) {
|
|
193
|
+
const answer = await ask(deps, `Cube name [${input.context.derivedName}]: `);
|
|
194
|
+
if ('stop' in answer) return { kind: 'stop', code: answer.stop };
|
|
195
|
+
name = answer.value.trim() || input.context.derivedName;
|
|
196
|
+
if (validRepositoryCubeName(name)) break;
|
|
197
|
+
deps.write(`${NAME_ERROR}\n`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!validRepositoryCubeName(name)) {
|
|
201
|
+
deps.write(`${NAME_ERROR}\n`);
|
|
202
|
+
return { kind: 'stop', code: 1 };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let template = input.flags.template as 'software-dev' | 'starter' | undefined;
|
|
206
|
+
if (!template && deps.isTTY() && !input.flags.yes) {
|
|
207
|
+
let menu = 'Choose a template:\n';
|
|
208
|
+
for (let i = 0; i < NEW_CUBE_TEMPLATE_PRESENTATIONS.length; i += 1) {
|
|
209
|
+
const p = NEW_CUBE_TEMPLATE_PRESENTATIONS[i];
|
|
210
|
+
const suffix = i === 0 ? ' (recommended)' : '';
|
|
211
|
+
menu += ` ${i + 1}. ${p.label}${suffix}\n`;
|
|
212
|
+
menu += ` ${p.short_description}\n`;
|
|
213
|
+
}
|
|
214
|
+
deps.write(menu);
|
|
215
|
+
while (!template) {
|
|
216
|
+
const answer = await ask(deps, 'Template [1]: ');
|
|
217
|
+
if ('stop' in answer) return { kind: 'stop', code: answer.stop };
|
|
218
|
+
const selected = answer.value.trim();
|
|
219
|
+
if (selected === '' || selected === '1') template = 'software-dev';
|
|
220
|
+
else if (selected === '2') template = 'starter';
|
|
221
|
+
else deps.write('Choose 1 or 2.\n');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
template ??= 'software-dev';
|
|
225
|
+
|
|
226
|
+
if (deps.isTTY() && !input.flags.yes) {
|
|
227
|
+
deps.write(
|
|
228
|
+
`Create this cube?\n` +
|
|
229
|
+
` Name: ${name}\n` +
|
|
230
|
+
` Template: ${presentation(template).label}\n` +
|
|
231
|
+
` Repository: ${input.context.root}\n` +
|
|
232
|
+
` Server: ${input.serverOrigin}\n`,
|
|
233
|
+
);
|
|
234
|
+
let confirmed = false;
|
|
235
|
+
while (!confirmed) {
|
|
236
|
+
const answer = await ask(deps, 'Create cube? [Y/n]: ');
|
|
237
|
+
if ('stop' in answer) return { kind: 'stop', code: answer.stop };
|
|
238
|
+
const confirmation = answer.value.trim().toLowerCase();
|
|
239
|
+
if (confirmation === '' || confirmation === 'y' || confirmation === 'yes') {
|
|
240
|
+
confirmed = true;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
if (confirmation === 'n' || confirmation === 'no') {
|
|
244
|
+
deps.write('Cube creation cancelled. Nothing was changed.\n');
|
|
245
|
+
return { kind: 'stop', code: 0 };
|
|
246
|
+
}
|
|
247
|
+
deps.write('Enter y or n.\n');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
deps.write('Creating cube...\n');
|
|
252
|
+
const workingRepoName = input.context.derivedName;
|
|
253
|
+
const creation = await deps.createCube({ name, workingRepoName, repository, template });
|
|
254
|
+
try {
|
|
255
|
+
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,
|
|
260
|
+
});
|
|
261
|
+
} catch {
|
|
262
|
+
throw new RepositoryAssociationSaveError();
|
|
263
|
+
}
|
|
264
|
+
renderResult(deps, {
|
|
265
|
+
existing: creation.response.result === 'resolved',
|
|
266
|
+
response: creation.response,
|
|
267
|
+
root: input.context.root,
|
|
268
|
+
serverOrigin: input.serverOrigin,
|
|
269
|
+
mode: input.mode,
|
|
270
|
+
creationOptionsUnused: false,
|
|
271
|
+
});
|
|
272
|
+
return { kind: 'success', creation, existing: creation.response.result === 'resolved' };
|
|
273
|
+
}
|