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.
Files changed (73) hide show
  1. package/README.md +11 -3
  2. package/SECURITY.md +1 -1
  3. package/dist/assimilate-cmd.d.ts +13 -4
  4. package/dist/assimilate-cmd.d.ts.map +1 -1
  5. package/dist/assimilate-cmd.js +134 -110
  6. package/dist/assimilate-cmd.js.map +1 -1
  7. package/dist/assimilate-deps.d.ts +7 -1
  8. package/dist/assimilate-deps.d.ts.map +1 -1
  9. package/dist/assimilate-deps.js +44 -18
  10. package/dist/assimilate-deps.js.map +1 -1
  11. package/dist/claude.d.ts +3 -1
  12. package/dist/claude.d.ts.map +1 -1
  13. package/dist/claude.js +26 -12
  14. package/dist/claude.js.map +1 -1
  15. package/dist/cli-help.d.ts +2 -0
  16. package/dist/cli-help.d.ts.map +1 -1
  17. package/dist/cli-help.js +22 -5
  18. package/dist/cli-help.js.map +1 -1
  19. package/dist/config.d.ts +7 -4
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +21 -4
  22. package/dist/config.js.map +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -8
  25. package/dist/index.js.map +1 -1
  26. package/dist/local-manage-tool-result.d.ts +10 -0
  27. package/dist/local-manage-tool-result.d.ts.map +1 -1
  28. package/dist/local-manage-tool-result.js +36 -0
  29. package/dist/local-manage-tool-result.js.map +1 -1
  30. package/dist/remote-client.d.ts +8 -5
  31. package/dist/remote-client.d.ts.map +1 -1
  32. package/dist/remote-client.js +54 -11
  33. package/dist/remote-client.js.map +1 -1
  34. package/dist/repository-cube-init.d.ts +57 -0
  35. package/dist/repository-cube-init.d.ts.map +1 -0
  36. package/dist/repository-cube-init.js +196 -0
  37. package/dist/repository-cube-init.js.map +1 -0
  38. package/dist/repository-identity.d.ts +29 -0
  39. package/dist/repository-identity.d.ts.map +1 -0
  40. package/dist/repository-identity.js +143 -0
  41. package/dist/repository-identity.js.map +1 -0
  42. package/dist/server-errors.d.ts +6 -0
  43. package/dist/server-errors.d.ts.map +1 -1
  44. package/dist/server-errors.js +12 -0
  45. package/dist/server-errors.js.map +1 -1
  46. package/dist/server-facade.d.ts +11 -1
  47. package/dist/server-facade.d.ts.map +1 -1
  48. package/dist/server-facade.js +33 -3
  49. package/dist/server-facade.js.map +1 -1
  50. package/dist/server-handshake.d.ts +8 -4
  51. package/dist/server-handshake.d.ts.map +1 -1
  52. package/dist/server-handshake.js +15 -6
  53. package/dist/server-handshake.js.map +1 -1
  54. package/dist/tool-manifest.js +2 -2
  55. package/dist/tool-manifest.js.map +1 -1
  56. package/docs/EXTRACTION_PROVENANCE.md +8 -8
  57. package/docs/LOCAL_SERVER.md +35 -6
  58. package/docs/RELEASING.md +25 -14
  59. package/package.json +2 -2
  60. package/src/assimilate-cmd.ts +166 -140
  61. package/src/assimilate-deps.ts +59 -17
  62. package/src/claude.ts +34 -14
  63. package/src/cli-help.ts +25 -5
  64. package/src/config.ts +30 -8
  65. package/src/index.ts +9 -8
  66. package/src/local-manage-tool-result.ts +47 -0
  67. package/src/remote-client.ts +64 -19
  68. package/src/repository-cube-init.ts +273 -0
  69. package/src/repository-identity.ts +199 -0
  70. package/src/server-errors.ts +14 -0
  71. package/src/server-facade.ts +47 -2
  72. package/src/server-handshake.ts +33 -7
  73. 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 = 1 as const;
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
- template: 'default';
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
- template: 'default';
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 (input.projectRoot.length < 1 || input.projectRoot.length > 4096 || /[\u0000-\u001f\u007f]/.test(input.projectRoot)) {
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').update(input.projectRoot).digest('hex');
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
- ...record,
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
- * gh#473 PR2 delegates to the NON-CLOBBERING server route. New roles
143
- * are inserted; existing template-named roles get ADD fragments (template
144
- * sections/classes the cube lacks) auto-applied, but EVOLVED (conflicting)
145
- * fragments are surfaced server-side and KEPT — never silently
146
- * overwritten. The old per-role blanket `updateRole`/whole-taxonomy
147
- * `updateCube` overwrite path is removed. Operators who want to take the
148
- * template version of a conflicting fragment use `borg_sync-roles` with a
149
- * `decisions` map. Returns `{ created, updated }` for the caller's toast.
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 {
@@ -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
- code = decodeProtocolErrorEnvelope(JSON.parse(body)).error.code;
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
- const result = await localManageRequest<{ role: any }>(
1414
- active,
1415
- `/api/cubes/${cubeId}/roles/${roleId}/section-patch`,
1416
- 'POST',
1417
- {
1418
- operation: `${op.action} section ${manageCopyValue(op.heading)} ${op.action === 'delete' ? 'from' : 'in'} role ${manageCopyValue(roleId)} in cube ${manageCopyValue(cubeId === active.cubeId ? active.name : cubeId)}`,
1419
- cubeName: cubeId === active.cubeId ? active.name : cubeId,
1420
- noMutation: `No role section was ${op.action === 'insert' ? 'inserted' : op.action === 'replace' ? 'replaced' : 'deleted'}.`,
1421
- },
1422
- { ...op },
1423
- undefined,
1424
- connectionOverride,
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
- * gh#473 PR2 — apply a named template to an existing cube via the
1558
- * NON-CLOBBERING server route. New roles are inserted; existing
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 surfaced server-side and KEPT, never overwritten. Returns
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
- * gh#473 PR2 — NON-CLOBBERING sync of a cube's roles + message_taxonomy
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. Returns a NonClobberSyncResult.
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
+ }