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.
- package/README.md +11 -3
- 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/remote-client.js +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/docs/EXTRACTION_PROVENANCE.md +7 -7
- package/docs/LOCAL_SERVER.md +35 -6
- package/docs/RELEASING.md +11 -3
- 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/remote-client.ts +1 -1
- 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/assimilate-deps.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
|
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>
|
|
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
|
|
83
|
-
` --no-template
|
|
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
|
-
`
|
|
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 =
|
|
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/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
|
-
// Shared 0.6.
|
|
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
|
+
}
|