borgmcp 2.0.11 → 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 (57) hide show
  1. package/README.md +11 -3
  2. package/dist/assimilate-cmd.d.ts +13 -4
  3. package/dist/assimilate-cmd.d.ts.map +1 -1
  4. package/dist/assimilate-cmd.js +134 -110
  5. package/dist/assimilate-cmd.js.map +1 -1
  6. package/dist/assimilate-deps.d.ts +7 -1
  7. package/dist/assimilate-deps.d.ts.map +1 -1
  8. package/dist/assimilate-deps.js +44 -18
  9. package/dist/assimilate-deps.js.map +1 -1
  10. package/dist/claude.d.ts +3 -1
  11. package/dist/claude.d.ts.map +1 -1
  12. package/dist/claude.js +26 -12
  13. package/dist/claude.js.map +1 -1
  14. package/dist/cli-help.d.ts +2 -0
  15. package/dist/cli-help.d.ts.map +1 -1
  16. package/dist/cli-help.js +22 -5
  17. package/dist/cli-help.js.map +1 -1
  18. package/dist/config.d.ts +7 -4
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +21 -4
  21. package/dist/config.js.map +1 -1
  22. package/dist/remote-client.js +1 -1
  23. package/dist/repository-cube-init.d.ts +57 -0
  24. package/dist/repository-cube-init.d.ts.map +1 -0
  25. package/dist/repository-cube-init.js +196 -0
  26. package/dist/repository-cube-init.js.map +1 -0
  27. package/dist/repository-identity.d.ts +29 -0
  28. package/dist/repository-identity.d.ts.map +1 -0
  29. package/dist/repository-identity.js +143 -0
  30. package/dist/repository-identity.js.map +1 -0
  31. package/dist/server-errors.d.ts +6 -0
  32. package/dist/server-errors.d.ts.map +1 -1
  33. package/dist/server-errors.js +12 -0
  34. package/dist/server-errors.js.map +1 -1
  35. package/dist/server-facade.d.ts +11 -1
  36. package/dist/server-facade.d.ts.map +1 -1
  37. package/dist/server-facade.js +33 -3
  38. package/dist/server-facade.js.map +1 -1
  39. package/dist/server-handshake.d.ts +8 -4
  40. package/dist/server-handshake.d.ts.map +1 -1
  41. package/dist/server-handshake.js +15 -6
  42. package/dist/server-handshake.js.map +1 -1
  43. package/docs/EXTRACTION_PROVENANCE.md +7 -7
  44. package/docs/LOCAL_SERVER.md +35 -6
  45. package/docs/RELEASING.md +11 -3
  46. package/package.json +2 -2
  47. package/src/assimilate-cmd.ts +166 -140
  48. package/src/assimilate-deps.ts +59 -17
  49. package/src/claude.ts +34 -14
  50. package/src/cli-help.ts +25 -5
  51. package/src/config.ts +30 -8
  52. package/src/remote-client.ts +1 -1
  53. package/src/repository-cube-init.ts +273 -0
  54. package/src/repository-identity.ts +199 -0
  55. package/src/server-errors.ts +14 -0
  56. package/src/server-facade.ts +47 -2
  57. package/src/server-handshake.ts +33 -7
@@ -61,8 +61,47 @@ import { prepareCodexRemoteLaunch, defaultCodexRemoteDeps } from './codex-remote
61
61
  import { findLoadedCodexThread } from './codex-app-server.js';
62
62
  import { defaultApprovalIo, resolveLaunchBorgApprovals } from './cli-tool-approval.js';
63
63
  import { ensurePrivateBorgConfigRoot } from './private-root.js';
64
+ import {
65
+ getOrCreateRepositoryIdentity,
66
+ getRepositoryAssociation,
67
+ resolveGitRepositoryContext,
68
+ saveRepositoryAssociation,
69
+ } from './repository-identity.js';
70
+ import { PromptInterruptedError } from './repository-cube-init.js';
64
71
 
65
- export function buildDefaultAssimilateDeps(): AssimilateDeps {
72
+ /**
73
+ * Wraps the readline question operation with the production interruption
74
+ * mapping. Tests inject the question operation; production uses readline.
75
+ */
76
+ export type PromptQuestion = (message: string) => Promise<string>;
77
+
78
+ async function defaultPromptQuestion(message: string): Promise<string> {
79
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
80
+ try {
81
+ return await rl.question(message);
82
+ } finally {
83
+ rl.close();
84
+ }
85
+ }
86
+
87
+ export function createPromptAdapter(
88
+ question: PromptQuestion = defaultPromptQuestion,
89
+ ): (message: string) => Promise<string> {
90
+ return async (message: string): Promise<string> => {
91
+ try {
92
+ return await question(message);
93
+ } catch (err) {
94
+ if (err instanceof Error && (err.message === 'SIGINT' || err.message === 'Interrupted by signal.')) {
95
+ throw new PromptInterruptedError();
96
+ }
97
+ throw err;
98
+ }
99
+ };
100
+ }
101
+
102
+ export function buildDefaultAssimilateDeps(
103
+ question: PromptQuestion = defaultPromptQuestion,
104
+ ): AssimilateDeps {
66
105
  return {
67
106
  runSync: (cmd, args, cwd) => {
68
107
  const r = spawnSync(cmd, args, { cwd, encoding: 'utf-8' });
@@ -97,14 +136,7 @@ export function buildDefaultAssimilateDeps(): AssimilateDeps {
97
136
 
98
137
  stderr: (line) => process.stderr.write(line),
99
138
  stdout: (line) => process.stdout.write(line),
100
- prompt: async (message: string): Promise<string> => {
101
- const rl = createInterface({ input: process.stdin, output: process.stdout });
102
- try {
103
- return await rl.question(message);
104
- } finally {
105
- rl.close();
106
- }
107
- },
139
+ prompt: createPromptAdapter(question),
108
140
  promptSecret: async (message: string): Promise<string> => {
109
141
  const result = await prompts({
110
142
  type: 'password',
@@ -178,6 +210,10 @@ export function buildDefaultAssimilateDeps(): AssimilateDeps {
178
210
  : { committed: false, reason: 'activation-failed' };
179
211
  },
180
212
  findProjectRoot: (cwd) => cubesFindProjectRoot(cwd),
213
+ resolveRepositoryContext: resolveGitRepositoryContext,
214
+ getRepositoryIdentity: getOrCreateRepositoryIdentity,
215
+ getRepositoryAssociation,
216
+ saveRepositoryAssociation,
181
217
 
182
218
  // gh#673 P2 (WI-1): project-local SessionStart hook for the launch root.
183
219
  installProjectSessionHook: (projectRoot) => {
@@ -237,14 +273,16 @@ export function buildDefaultAssimilateDeps(): AssimilateDeps {
237
273
  throw new Error('Selected Borg server authority state is missing or unreadable');
238
274
  }
239
275
  {
240
- if (!params.name || !params.projectRoot) {
241
- throw new Error('Local Borg server cube creation requires a repository name and root');
242
- }
243
276
  const created = await createLocalBorgServerCube(
244
277
  apiUrl,
245
278
  serverTrustIdentity,
246
279
  token,
247
- { projectRoot: params.projectRoot, name: params.name },
280
+ {
281
+ name: params.name,
282
+ workingRepoName: params.workingRepoName,
283
+ repository: params.repository,
284
+ template: params.template,
285
+ },
248
286
  );
249
287
  const cube = await remoteGetCube(created.cube_id, {
250
288
  apiUrl,
@@ -253,16 +291,20 @@ export function buildDefaultAssimilateDeps(): AssimilateDeps {
253
291
  });
254
292
  if (
255
293
  cube.id !== created.cube_id ||
294
+ cube.name !== created.name ||
256
295
  !Array.isArray(cube.roles) ||
257
296
  !cube.roles.some((role: { id?: string }) => role.id === created.default_worker_role_id)
258
297
  ) {
259
298
  throw new Error('Borg server returned cube details outside the creation result');
260
299
  }
261
300
  return {
262
- id: cube.id,
263
- name: cube.name,
264
- roles: cube.roles,
265
- drones: cube.drones ?? [],
301
+ response: created,
302
+ cube: {
303
+ id: cube.id,
304
+ name: cube.name,
305
+ roles: cube.roles,
306
+ drones: cube.drones ?? [],
307
+ },
266
308
  };
267
309
  }
268
310
  },
package/src/claude.ts CHANGED
@@ -19,8 +19,10 @@
19
19
 
20
20
  import { spawn } from 'child_process';
21
21
  import { randomUUID } from 'node:crypto';
22
+ import { realpathSync } from 'node:fs';
22
23
  import { basename } from 'node:path';
23
24
  import { createInterface } from 'node:readline/promises';
25
+ import { fileURLToPath } from 'node:url';
24
26
  import chalk from 'chalk';
25
27
  import { findProjectRoot, getActiveCube, inboxPathForDrone, setCodexWakeTarget, pruneDeadCodexWakeTargets } from './cubes.js';
26
28
  import { monitorStateRootForWorktree } from './inbox-monitor.js';
@@ -81,6 +83,23 @@ import { connectOpenCodeDrone, computeOpenCodePort, createOpenCodeLaunchKickoff,
81
83
  import { buildOpenCodeLaunchArgs, defaultApprovalIo, resolveLaunchBorgApprovals } from './cli-tool-approval.js';
82
84
  import { runEarlyServerFacade } from './server-facade.js';
83
85
 
86
+ export type AssimilateDepsBuilder = typeof buildDefaultAssimilateDeps;
87
+
88
+ export async function runAssimilateEntry(
89
+ args: readonly string[],
90
+ buildDeps: AssimilateDepsBuilder = buildDefaultAssimilateDeps,
91
+ ): Promise<number> {
92
+ const parsed = parseAssimilateArgs([...args]);
93
+ if (!parsed.ok) {
94
+ process.stderr.write(
95
+ chalk.red(`${consolePrefix()}◼ borg assimilate: ${parsed.error}\n`)
96
+ );
97
+ process.stderr.write(`Run \`borg --help\` for usage.\n`);
98
+ return 1;
99
+ }
100
+ return runAssimilate({ role: parsed.role, flags: parsed.flags }, buildDeps());
101
+ }
102
+
84
103
  async function main() {
85
104
  const serverExitCode = await runEarlyServerFacade(process.argv);
86
105
  if (serverExitCode !== null) process.exit(serverExitCode);
@@ -128,16 +147,7 @@ async function main() {
128
147
  process.stdout.write(assimilateHelpText(getPackageVersion()));
129
148
  process.exit(0);
130
149
  }
131
- const parsed = parseAssimilateArgs(process.argv.slice(3));
132
- if (!parsed.ok) {
133
- process.stderr.write(
134
- chalk.red(`${consolePrefix()}◼ borg assimilate: ${parsed.error}\n`)
135
- );
136
- process.stderr.write(`Run \`borg --help\` for usage.\n`);
137
- process.exit(1);
138
- }
139
- const deps = buildDefaultAssimilateDeps();
140
- const code = await runAssimilate({ role: parsed.role, flags: parsed.flags }, deps);
150
+ const code = await runAssimilateEntry(process.argv.slice(3));
141
151
  process.exit(code);
142
152
  }
143
153
  if (process.argv[2] === 'reset-local-seat') {
@@ -560,7 +570,17 @@ function ensureDetectedCliConfigured(): void {
560
570
  }
561
571
  }
562
572
 
563
- main().catch((error) => {
564
- console.error(`${consolePrefix()}${chalk.red(`\n◼ Error: ${error.message}\n`)}`);
565
- process.exit(1);
566
- });
573
+ function isEntryInvocation(): boolean {
574
+ try {
575
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
576
+ } catch {
577
+ return false;
578
+ }
579
+ }
580
+
581
+ if (isEntryInvocation()) {
582
+ main().catch((error) => {
583
+ console.error(`${consolePrefix()}${chalk.red(`\n◼ Error: ${error.message}\n`)}`);
584
+ process.exit(1);
585
+ });
586
+ }
package/src/cli-help.ts CHANGED
@@ -30,6 +30,7 @@ export function topLevelHelpText(version: string): string {
30
30
  ` borg assimilate [role] Join or create a cube\n` +
31
31
  ` borg assimilate --host <host> Join or create on an explicit server\n` +
32
32
  ` borg assimilate --worktree <name> Spawn a worktree drone (in ~/.borg/worktrees/<repo>/<name>)\n` +
33
+ ` borg server cube init Initialize this repository's cube without creating a drone\n` +
33
34
  ` borg reset-local-seat Clear ONLY this worktree's saved local seat (offline; after a rejection)\n` +
34
35
  ` borg sync [--prune] Sync this worktree's branch to origin/main\n` +
35
36
  ` borg cleanup [--prune] Report (or --prune) worktrees orphaned by evicted drones\n` +
@@ -53,11 +54,28 @@ export function serverHelpText(): string {
53
54
  ` stop Stop the managed local server.\n` +
54
55
  ` status Report verified runtime evidence.\n` +
55
56
  ` update Verify and activate a local server artifact.\n` +
56
- ` invite Create a single-use invitation in an interactive terminal.\n\n` +
57
+ ` invite Create a single-use invitation in an interactive terminal.\n` +
58
+ ` cube init Initialize this Git repository's cube; does not create a drone.\n\n` +
57
59
  `Run borg server <command> --help for server command options.\n`
58
60
  );
59
61
  }
60
62
 
63
+ /** Client-owned help for repository cube initialization without a drone. */
64
+ export function cubeInitHelpText(): string {
65
+ return (
66
+ `borg server cube init — initialize this Git repository's cube without creating a drone\n\n` +
67
+ `Usage:\n` +
68
+ ` borg server cube init [options]\n\n` +
69
+ `Options:\n` +
70
+ ` --host <host> Borg server host or URL (bare hosts default to HTTPS)\n` +
71
+ ` --enroll Prompt for a hidden enrollment invitation\n` +
72
+ ` --cube-name <name> Repository cube name (otherwise edit the proposed name)\n` +
73
+ ` --template software-dev|starter New-cube template (default: software-dev)\n` +
74
+ ` --yes, -y Skip confirmation prompts\n` +
75
+ ` --help, -h Show this help\n`
76
+ );
77
+ }
78
+
61
79
  /**
62
80
  * Help text for `borg assimilate --help` — the home for the full assimilate flag
63
81
  * set. Model/provider configuration belongs to the selected agent CLI.
@@ -76,15 +94,17 @@ export function assimilateHelpText(version: string): string {
76
94
  `Flags:\n` +
77
95
  ` --worktree <name> Create + launch the drone in a sibling git worktree\n` +
78
96
  ` --here Stay in the current worktree (no sibling spawn)\n` +
79
- ` --cube-name <name> Cube to join/create (otherwise confirm repo basename)\n` +
97
+ ` --cube-name <name> Repository cube name (otherwise edit the proposed name)\n` +
80
98
  ` --host <host> Borg server host or URL (bare hosts default to HTTPS)\n` +
81
99
  ` --enroll Prompt for a hidden enrollment invitation in the operator terminal\n` +
82
- ` --template <name> Bootstrap a new cube from a bundled role template\n` +
83
- ` --no-template Create the cube with no template roles\n` +
100
+ ` --template software-dev|starter New-cube template (default: software-dev)\n` +
101
+ ` --no-template Unsupported for repository cube creation\n` +
84
102
  ` --cli claude|codex|opencode Agent CLI to launch (default: claude)\n` +
85
103
  ` --model claude:<model> Legacy Claude model override (configure models in the agent CLI)\n` +
86
104
  ` --yes, -y Skip confirmation prompts\n\n` +
87
- `An enrolled owner client may create an idempotent repository cube; ordinary clients\n` +
105
+ `Creation shows repository context, name, template, and one confirmation. An existing\n` +
106
+ `repository association skips all prompts. An enrolled owner client may create an\n` +
107
+ `idempotent repository cube; ordinary clients\n` +
88
108
  `require an explicit cube grant. Agent seats begin only after enrollment. Preview only.\n` +
89
109
  `See docs/LOCAL_SERVER.md for self-hosted setup and current status.\n\n` +
90
110
  `For local or provider-specific models, configure the selected agent CLI directly.\n` +
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
  });
@@ -712,7 +712,7 @@ async function authedFetch(
712
712
  parsed.error !== null && typeof parsed.error === 'object' &&
713
713
  parsed.error.code === ROLE_SECTION_CONFLICT_CODE
714
714
  ) {
715
- // Shared 0.6.2 intentionally omits this server-local code. Re-validate the whole
715
+ // Shared 0.6.3 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({
@@ -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
+ }