borgmcp 3.10.0 → 3.11.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/dist/assimilate-cmd.d.ts +14 -1
- package/dist/assimilate-cmd.d.ts.map +1 -1
- package/dist/assimilate-cmd.js +19 -12
- package/dist/assimilate-cmd.js.map +1 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +22 -0
- 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 +28 -1
- package/dist/cli-help.js.map +1 -1
- package/dist/clone-cmd.d.ts +28 -0
- package/dist/clone-cmd.d.ts.map +1 -0
- package/dist/clone-cmd.js +227 -0
- package/dist/clone-cmd.js.map +1 -0
- package/dist/clone-security.d.ts +9 -0
- package/dist/clone-security.d.ts.map +1 -0
- package/dist/clone-security.js +41 -0
- package/dist/clone-security.js.map +1 -0
- package/dist/launch-all-cmd.d.ts +11 -0
- package/dist/launch-all-cmd.d.ts.map +1 -1
- package/dist/launch-all-cmd.js +24 -2
- package/dist/launch-all-cmd.js.map +1 -1
- package/dist/parse-clone-args.d.ts +17 -0
- package/dist/parse-clone-args.d.ts.map +1 -0
- package/dist/parse-clone-args.js +38 -0
- package/dist/parse-clone-args.js.map +1 -0
- package/dist/parse-quickstart-args.d.ts +18 -0
- package/dist/parse-quickstart-args.d.ts.map +1 -0
- package/dist/parse-quickstart-args.js +43 -0
- package/dist/parse-quickstart-args.js.map +1 -0
- package/dist/quickstart-cmd.d.ts +23 -0
- package/dist/quickstart-cmd.d.ts.map +1 -0
- package/dist/quickstart-cmd.js +357 -0
- package/dist/quickstart-cmd.js.map +1 -0
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +2 -0
- package/dist/unknown-subcommand.js.map +1 -1
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +35 -12
- package/src/claude.ts +22 -0
- package/src/cli-help.ts +34 -1
- package/src/clone-cmd.ts +243 -0
- package/src/clone-security.ts +40 -0
- package/src/launch-all-cmd.ts +37 -2
- package/src/parse-clone-args.ts +44 -0
- package/src/parse-quickstart-args.ts +54 -0
- package/src/quickstart-cmd.ts +396 -0
- package/src/unknown-subcommand.ts +2 -0
package/src/claude.ts
CHANGED
|
@@ -42,8 +42,12 @@ import { runSpawn } from './spawn.js';
|
|
|
42
42
|
import { buildClaudeLaunchArgs } from './claude-launch-args.js';
|
|
43
43
|
import { parseCleanupArgs, runCleanup } from './cleanup-cmd.js';
|
|
44
44
|
import { parseAssimilateArgs } from './parse-assimilate-args.js';
|
|
45
|
+
import { parseQuickstartArgs } from './parse-quickstart-args.js';
|
|
46
|
+
import { parseCloneArgs, safeCloneParseError } from './parse-clone-args.js';
|
|
45
47
|
import { runAssimilate } from './assimilate-cmd.js';
|
|
46
48
|
import { buildDefaultAssimilateDeps } from './assimilate-deps.js';
|
|
49
|
+
import { buildDefaultQuickstartDeps, runQuickstart } from './quickstart-cmd.js';
|
|
50
|
+
import { buildDefaultCloneDeps, runClone } from './clone-cmd.js';
|
|
47
51
|
import {
|
|
48
52
|
parseResetLocalSeatArgs,
|
|
49
53
|
runResetLocalSeat,
|
|
@@ -251,6 +255,24 @@ async function main() {
|
|
|
251
255
|
const code = await runAssimilateEntry(process.argv.slice(3));
|
|
252
256
|
process.exit(code);
|
|
253
257
|
}
|
|
258
|
+
if (process.argv[2] === 'clone') {
|
|
259
|
+
const parsed = parseCloneArgs(process.argv.slice(3));
|
|
260
|
+
if (!parsed.ok) {
|
|
261
|
+
process.stderr.write(chalk.red(`${consolePrefix()}◼ borg clone: ${safeCloneParseError(parsed)}\n`));
|
|
262
|
+
process.stderr.write(`Run \`borg clone --help\` for usage.\n`);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
process.exit(await runClone(parsed.args, buildDefaultCloneDeps()));
|
|
266
|
+
}
|
|
267
|
+
if (process.argv[2] === 'quickstart') {
|
|
268
|
+
const parsed = parseQuickstartArgs(process.argv.slice(3));
|
|
269
|
+
if (!parsed.ok) {
|
|
270
|
+
process.stderr.write(chalk.red(`${consolePrefix()}◼ borg quickstart: ${parsed.error}\n`));
|
|
271
|
+
process.stderr.write(`Run \`borg quickstart --help\` for usage.\n`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
process.exit(await runQuickstart(parsed.args, buildDefaultQuickstartDeps()));
|
|
275
|
+
}
|
|
254
276
|
if (process.argv[2] === 'reset-local-connection') {
|
|
255
277
|
const parsed = parseResetLocalSeatArgs(process.argv.slice(3));
|
|
256
278
|
if (!parsed.ok) {
|
package/src/cli-help.ts
CHANGED
|
@@ -51,6 +51,35 @@ export function launchAllHelpText(version: string): string {
|
|
|
51
51
|
);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
export function quickstartHelpText(version: string): string {
|
|
55
|
+
return (
|
|
56
|
+
`borg quickstart (borgmcp ${version}) — create, staff, and launch this repository's cube\n\n` +
|
|
57
|
+
`Usage:\n` +
|
|
58
|
+
` borg quickstart [options]\n\n` +
|
|
59
|
+
`Options:\n` +
|
|
60
|
+
` --template ${NEW_CUBE_TEMPLATE_OPTIONS} Choose the new-cube template without prompting\n` +
|
|
61
|
+
` --role <slug>[:<count>] Fully specify the roster (repeatable)\n` +
|
|
62
|
+
` --yes, -y Accept the displayed plan\n` +
|
|
63
|
+
` --help, -h Show this help\n\n` +
|
|
64
|
+
`Quickstart requires a running Borg server and never starts one. Rerun the same\n` +
|
|
65
|
+
`command after a partial failure; existing drones are kept and skipped.\n`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function cloneHelpText(version: string): string {
|
|
70
|
+
return (
|
|
71
|
+
`borg clone (borgmcp ${version}) — clone a repository and run quickstart\n\n` +
|
|
72
|
+
`Usage:\n` +
|
|
73
|
+
` borg clone <repository-url> [directory] [--no-launch]\n\n` +
|
|
74
|
+
`Options:\n` +
|
|
75
|
+
` --no-launch Stop after the checkout is ready; do not create or launch a cube\n` +
|
|
76
|
+
` --help, -h Show this help\n\n` +
|
|
77
|
+
`A repeated command reuses a checkout only when its origin matches. Default\n` +
|
|
78
|
+
`destination names avoid non-repository collisions. Credential-bearing URLs are\n` +
|
|
79
|
+
`refused; use a Git credential helper or SSH configuration instead.\n`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
54
83
|
export function seatsHelpText(version: string): string {
|
|
55
84
|
return (
|
|
56
85
|
`borg drones (borgmcp ${version}) — list this machine's registered drones\n\n` +
|
|
@@ -91,7 +120,9 @@ export function clientSubcommandHelpText(
|
|
|
91
120
|
if (!args.some(isHelpFlag)) return null;
|
|
92
121
|
switch (command) {
|
|
93
122
|
case 'setup': return setupHelpText(version);
|
|
123
|
+
case 'clone': return cloneHelpText(version);
|
|
94
124
|
case 'assimilate': return assimilateHelpText(version);
|
|
125
|
+
case 'quickstart': return quickstartHelpText(version);
|
|
95
126
|
case 'reset-local-connection': return resetLocalSeatHelpText(version);
|
|
96
127
|
case 'recover-enrollment': return recoverEnrollmentHelpText(version);
|
|
97
128
|
case 'cleanup': return cleanupHelpText(version);
|
|
@@ -107,7 +138,7 @@ export function setupNextStepsText(): string {
|
|
|
107
138
|
return (
|
|
108
139
|
`◼ Next steps:\n` +
|
|
109
140
|
`1. Run \`borg server start\` and leave that terminal open.\n` +
|
|
110
|
-
`2. In a second terminal, cd into your project's Git repository and run \`borg
|
|
141
|
+
`2. In a second terminal, cd into your project's Git repository and run \`borg quickstart\`.\n`
|
|
111
142
|
);
|
|
112
143
|
}
|
|
113
144
|
|
|
@@ -129,6 +160,8 @@ export function topLevelHelpText(version: string): string {
|
|
|
129
160
|
` borg setup Set up borg MCP server + agent CLI integration\n` +
|
|
130
161
|
` borg update Update the client and installed local server together\n` +
|
|
131
162
|
` borg doctor Check agent hook commands, versions, configs, and the OpenCode plugin\n` +
|
|
163
|
+
` borg clone <url> [dir] Clone a repository, then create and launch its cube\n` +
|
|
164
|
+
` borg quickstart Create a cube and a drone for every role, then launch them\n` +
|
|
132
165
|
` borg assimilate [role] Join or create a cube\n` +
|
|
133
166
|
` borg assimilate --host <host> Join or create on an explicit server\n` +
|
|
134
167
|
` borg assimilate --worktree <name> Spawn a worktree drone (in ~/.borg/worktrees/<repo>/<name>)\n` +
|
package/src/clone-cmd.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { hasCloneCredentials, redactCloneSecrets } from './clone-security.js';
|
|
6
|
+
import type { CloneArgs } from './parse-clone-args.js';
|
|
7
|
+
import { buildDefaultQuickstartDeps, runQuickstart } from './quickstart-cmd.js';
|
|
8
|
+
import { shellEscape } from './shell-escape.js';
|
|
9
|
+
|
|
10
|
+
export interface GitRunResult {
|
|
11
|
+
status: number | null;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CloneDeps {
|
|
17
|
+
cwd: () => string;
|
|
18
|
+
chdir: (path: string) => void;
|
|
19
|
+
runSync: (cmd: string, args: string[], cwd?: string) => GitRunResult;
|
|
20
|
+
pathExists: (path: string) => boolean;
|
|
21
|
+
isDirectory: (path: string) => boolean;
|
|
22
|
+
readDirectory: (path: string) => string[];
|
|
23
|
+
createDirectory: (path: string) => boolean;
|
|
24
|
+
removeTree: (path: string) => void;
|
|
25
|
+
quickstart: (cwd: string) => Promise<number>;
|
|
26
|
+
stdout: (text: string) => void;
|
|
27
|
+
stderr: (text: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/;
|
|
31
|
+
const ALLOWED_SCHEMES = new Set(['file:', 'git:', 'git+ssh:', 'http:', 'https:', 'ssh:']);
|
|
32
|
+
const SCP_REMOTE_RE = /^[^@\s/:]+@[^:\s]+:[^\s]+$/;
|
|
33
|
+
|
|
34
|
+
function defaultRunSync(cmd: string, args: string[], cwd?: string): GitRunResult {
|
|
35
|
+
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
|
|
36
|
+
return {
|
|
37
|
+
status: result.status,
|
|
38
|
+
stdout: result.stdout ?? '',
|
|
39
|
+
stderr: result.stderr ?? (result.error instanceof Error ? result.error.message : ''),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildDefaultCloneDeps(): CloneDeps {
|
|
44
|
+
return {
|
|
45
|
+
cwd: () => process.cwd(),
|
|
46
|
+
chdir: (path) => process.chdir(path),
|
|
47
|
+
runSync: defaultRunSync,
|
|
48
|
+
pathExists: existsSync,
|
|
49
|
+
isDirectory: (path) => {
|
|
50
|
+
try { return statSync(path).isDirectory(); } catch { return false; }
|
|
51
|
+
},
|
|
52
|
+
readDirectory: (path) => readdirSync(path),
|
|
53
|
+
createDirectory: (path) => {
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(path);
|
|
57
|
+
return true;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') return false;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
removeTree: (path) => rmSync(path, { recursive: true, force: true }),
|
|
64
|
+
quickstart: async (cwd) => {
|
|
65
|
+
process.chdir(cwd);
|
|
66
|
+
return runQuickstart({ roles: [], yes: false }, buildDefaultQuickstartDeps());
|
|
67
|
+
},
|
|
68
|
+
stdout: (text) => process.stdout.write(text),
|
|
69
|
+
stderr: (text) => process.stderr.write(text),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function validateCloneRepositoryUrl(value: string): { ok: true } | { ok: false; error: string } {
|
|
74
|
+
if (!value || value.trim() !== value || /\s/.test(value) || CONTROL_RE.test(value)) {
|
|
75
|
+
return { ok: false, error: 'repository URL contains whitespace or control characters' };
|
|
76
|
+
}
|
|
77
|
+
if (value.startsWith('-')) return { ok: false, error: 'repository URL must not start with a hyphen' };
|
|
78
|
+
if (hasCloneCredentials(value)) {
|
|
79
|
+
return { ok: false, error: 'credential-bearing repository URLs are not accepted; use a credential helper or SSH configuration instead' };
|
|
80
|
+
}
|
|
81
|
+
if (SCP_REMOTE_RE.test(value)) return { ok: true };
|
|
82
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1];
|
|
83
|
+
if (!scheme) return { ok: true }; // Local path.
|
|
84
|
+
if (!value.toLowerCase().startsWith(`${scheme.toLowerCase()}://`) && scheme.toLowerCase() !== 'file') {
|
|
85
|
+
return { ok: false, error: 'repository URL is not a supported hierarchical URL' };
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const parsed = new URL(value);
|
|
89
|
+
if (!ALLOWED_SCHEMES.has(parsed.protocol)) return { ok: false, error: `unsupported repository URL scheme ${parsed.protocol}` };
|
|
90
|
+
if (parsed.search || parsed.hash) return { ok: false, error: 'repository URLs with query strings or fragments are not accepted' };
|
|
91
|
+
if (parsed.protocol !== 'file:' && !parsed.hostname) return { ok: false, error: 'repository URL must include a host' };
|
|
92
|
+
if (!parsed.pathname || parsed.pathname === '/') return { ok: false, error: 'repository URL must include a repository path' };
|
|
93
|
+
return { ok: true };
|
|
94
|
+
} catch {
|
|
95
|
+
return { ok: false, error: 'repository URL is not valid' };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sourceName(value: string): string {
|
|
100
|
+
let leaf = value;
|
|
101
|
+
try {
|
|
102
|
+
const parsed = new URL(value);
|
|
103
|
+
leaf = parsed.protocol === 'file:' ? fileURLToPath(parsed) : parsed.pathname;
|
|
104
|
+
} catch {
|
|
105
|
+
if (SCP_REMOTE_RE.test(value)) leaf = value.slice(value.indexOf(':') + 1);
|
|
106
|
+
}
|
|
107
|
+
const name = basename(leaf.replace(/\/+$/, '')).replace(/\.git$/i, '')
|
|
108
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
109
|
+
return name || 'repository';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function remoteKey(value: string, base: string): string {
|
|
113
|
+
if (SCP_REMOTE_RE.test(value)) {
|
|
114
|
+
const colon = value.indexOf(':');
|
|
115
|
+
const login = value.slice(0, colon);
|
|
116
|
+
const at = login.lastIndexOf('@');
|
|
117
|
+
const user = login.slice(0, at);
|
|
118
|
+
const host = login.slice(at + 1).toLowerCase();
|
|
119
|
+
return `ssh://${user}@${host}/${value.slice(colon + 1).replace(/\/+$/, '').replace(/\.git$/i, '')}`;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const parsed = new URL(value);
|
|
123
|
+
if (parsed.protocol === 'file:') return `file:${resolve(fileURLToPath(parsed))}`;
|
|
124
|
+
const user = parsed.username ? `${parsed.username}@` : '';
|
|
125
|
+
const port = parsed.port ? `:${parsed.port}` : '';
|
|
126
|
+
return `${parsed.protocol}//${user}${parsed.hostname.toLowerCase()}${port}${parsed.pathname.replace(/\/+$/, '').replace(/\.git$/i, '')}`;
|
|
127
|
+
} catch {
|
|
128
|
+
return `file:${resolve(base, value)}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readOrigin(deps: CloneDeps, directory: string): string | null {
|
|
133
|
+
const result = deps.runSync('git', ['remote', 'get-url', 'origin'], directory);
|
|
134
|
+
return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function repositoryAt(deps: CloneDeps, directory: string): boolean {
|
|
138
|
+
return deps.runSync('git', ['rev-parse', '--is-inside-work-tree'], directory).status === 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validateDestination(value: string): string | null {
|
|
142
|
+
if (!value || value.trim() !== value || CONTROL_RE.test(value) || value.startsWith('-') || hasCloneCredentials(value)) {
|
|
143
|
+
return 'destination contains an unsafe path value';
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function chooseDestination(deps: CloneDeps, args: CloneArgs): { path: string } | { error: string } {
|
|
149
|
+
const base = deps.cwd();
|
|
150
|
+
if (args.destination !== undefined) {
|
|
151
|
+
const error = validateDestination(args.destination);
|
|
152
|
+
return error ? { error } : { path: resolve(base, args.destination) };
|
|
153
|
+
}
|
|
154
|
+
const name = sourceName(args.repositoryUrl);
|
|
155
|
+
for (let n = 1; n < 10_000; n += 1) {
|
|
156
|
+
const candidate = resolve(base, n === 1 ? name : `${name}-${n}`);
|
|
157
|
+
if (!deps.pathExists(candidate)) return { path: candidate };
|
|
158
|
+
if (deps.isDirectory(candidate) && repositoryAt(deps, candidate)) {
|
|
159
|
+
const origin = readOrigin(deps, candidate);
|
|
160
|
+
if (origin && !hasCloneCredentials(origin) && remoteKey(origin, candidate) === remoteKey(args.repositoryUrl, base)) {
|
|
161
|
+
return { path: candidate };
|
|
162
|
+
}
|
|
163
|
+
if (n === 1) return { error: `existing checkout at ${candidate} points at another remote; choose an explicit destination` };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { error: `could not find a collision-safe destination for ${name}` };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function runClone(args: CloneArgs, rawDeps: CloneDeps): Promise<number> {
|
|
170
|
+
const deps: CloneDeps = {
|
|
171
|
+
...rawDeps,
|
|
172
|
+
stdout: (text) => rawDeps.stdout(redactCloneSecrets(text)),
|
|
173
|
+
stderr: (text) => rawDeps.stderr(redactCloneSecrets(text)),
|
|
174
|
+
};
|
|
175
|
+
const valid = validateCloneRepositoryUrl(args.repositoryUrl);
|
|
176
|
+
if (!valid.ok) {
|
|
177
|
+
deps.stderr(`borg clone: ${valid.error}.\n`);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
const selected = chooseDestination(deps, args);
|
|
181
|
+
if ('error' in selected) {
|
|
182
|
+
deps.stderr(`borg clone: ${selected.error}.\n`);
|
|
183
|
+
return 1;
|
|
184
|
+
}
|
|
185
|
+
const destination = selected.path;
|
|
186
|
+
let cloned = false;
|
|
187
|
+
const destinationExisted = deps.pathExists(destination);
|
|
188
|
+
const emptyDestination = destinationExisted && deps.isDirectory(destination) && deps.readDirectory(destination).length === 0;
|
|
189
|
+
if (destinationExisted && !emptyDestination) {
|
|
190
|
+
if (!deps.isDirectory(destination) || !repositoryAt(deps, destination)) {
|
|
191
|
+
deps.stderr(`borg clone: destination ${destination} exists and is not a Git checkout; it was left untouched.\n`);
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
const origin = readOrigin(deps, destination);
|
|
195
|
+
if (!origin || hasCloneCredentials(origin)) {
|
|
196
|
+
deps.stderr(`borg clone: the existing checkout has no safe, readable origin remote; it was left untouched.\n`);
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
if (remoteKey(origin, destination) !== remoteKey(args.repositoryUrl, deps.cwd())) {
|
|
200
|
+
deps.stderr(`borg clone: remote mismatch at ${destination}; existing ${origin}, requested ${args.repositoryUrl}. The checkout was left untouched.\n`);
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
deps.stdout(`Reusing existing checkout at ${destination}; its origin remote matches.\n`);
|
|
204
|
+
} else {
|
|
205
|
+
const createdDestination = destinationExisted ? false : deps.createDirectory(destination);
|
|
206
|
+
if (!destinationExisted && !createdDestination) {
|
|
207
|
+
deps.stderr(`borg clone: destination ${destination} appeared while preparing the clone; it was left untouched. Retry with another directory.\n`);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
const result = deps.runSync('git', ['clone', '--', args.repositoryUrl, destination], deps.cwd());
|
|
211
|
+
if (result.status !== 0) {
|
|
212
|
+
if (createdDestination && deps.pathExists(destination)) {
|
|
213
|
+
deps.removeTree(destination);
|
|
214
|
+
}
|
|
215
|
+
deps.stderr(
|
|
216
|
+
`borg clone: clone failed${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}.\n` +
|
|
217
|
+
`Rollback: ${destinationExisted
|
|
218
|
+
? `the pre-existing empty destination ${destination} was preserved; inspect it for partial Git files`
|
|
219
|
+
: deps.pathExists(destination) ? `partial checkout remains at ${destination}` : `the directory borg created at ${destination} was removed`}.\n`,
|
|
220
|
+
);
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
cloned = true;
|
|
224
|
+
deps.stdout(`Cloned ${sourceName(args.repositoryUrl)} into ${destination}.\n`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (args.noLaunch) {
|
|
228
|
+
deps.stdout(
|
|
229
|
+
`Checkout ready at ${destination}. No cube or drone was created.\n` +
|
|
230
|
+
`Next: cd ${shellEscape(destination)} && borg quickstart\n`,
|
|
231
|
+
);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
deps.chdir(destination);
|
|
235
|
+
const code = await deps.quickstart(destination);
|
|
236
|
+
if (code !== 0) {
|
|
237
|
+
deps.stderr(
|
|
238
|
+
`The checkout${cloned ? '' : ' you already had'} is ready at ${destination}, but quickstart did not finish. ` +
|
|
239
|
+
`It was left untouched; cd there and run \`borg quickstart\` again.\n`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return code;
|
|
243
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Redaction and fail-closed credential detection for untrusted Git remotes. */
|
|
2
|
+
|
|
3
|
+
const URL_USERINFO_RE = /([a-z][a-z0-9+.-]*:\/\/)([^/\s@]+)@/gi;
|
|
4
|
+
const URL_SUFFIX_RE = /([a-z][a-z0-9+.-]*:\/\/[^\s?#)]+)([?#])[^\s)]*/gi;
|
|
5
|
+
const NAMED_SECRET_RE = /([?&](?:access[_-]?token|api[_-]?key|auth(?:entication)?|credential|password|passwd|private[_-]?key|secret|token)=)[^&#\s)]+/gi;
|
|
6
|
+
const NAMED_SECRET_TEST_RE = new RegExp(NAMED_SECRET_RE.source, 'i');
|
|
7
|
+
const OPTION_SECRET_RE = /((?:^|[\s("'`])--(?:access[_-]?token|api[_-]?key|auth(?:entication)?|credential|password|passwd|private[_-]?key|secret|token)=)[^\s)]*/gi;
|
|
8
|
+
const SCP_REMOTE_RE = /^[^@\s/:]+@[^:\s]+:[^\s]+$/;
|
|
9
|
+
|
|
10
|
+
export function redactCloneSecrets(value: string): string {
|
|
11
|
+
return value
|
|
12
|
+
.replace(URL_USERINFO_RE, '$1<credentials>@')
|
|
13
|
+
.replace(URL_SUFFIX_RE, '$1$2<redacted>')
|
|
14
|
+
.replace(NAMED_SECRET_RE, '$1<redacted>')
|
|
15
|
+
.replace(OPTION_SECRET_RE, '$1<redacted>')
|
|
16
|
+
.replace(/(^|[\s("'`])[^/\s:@]+:[^@\s/]+@(?=[^\s/])/g, '$1<credentials>@');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* True means the value must never reach subprocess argv, output, or Git config.
|
|
21
|
+
* Opaque `scheme:...@...` forms fail closed; URL() accepts those but exposes no
|
|
22
|
+
* username/password fields, which is precisely the class that escaped the old flow.
|
|
23
|
+
*/
|
|
24
|
+
export function hasCloneCredentials(value: string): boolean {
|
|
25
|
+
if (NAMED_SECRET_TEST_RE.test(value)) return true;
|
|
26
|
+
if (SCP_REMOTE_RE.test(value)) return false;
|
|
27
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1].toLowerCase();
|
|
28
|
+
if (scheme && !value.toLowerCase().startsWith(`${scheme}://`) && scheme !== 'file') {
|
|
29
|
+
return value.includes('@');
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const parsed = new URL(value);
|
|
33
|
+
if (parsed.protocol === 'ssh:' || parsed.protocol === 'git+ssh:') {
|
|
34
|
+
return parsed.password.length > 0;
|
|
35
|
+
}
|
|
36
|
+
return parsed.username.length > 0 || parsed.password.length > 0;
|
|
37
|
+
} catch {
|
|
38
|
+
return /[^/\s:@]+:[^@\s/]+@/.test(value);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/launch-all-cmd.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { runPastelistBackend } from './backends/launch-all-pastelist.js';
|
|
|
16
16
|
|
|
17
17
|
type Backend = 'tmux' | 'terminals' | 'pastelist';
|
|
18
18
|
|
|
19
|
+
/** Internal strict-seam result: requested sessions were not dispatched. */
|
|
20
|
+
export const LAUNCH_ALL_NO_DISPATCH_EXIT_CODE = 2;
|
|
21
|
+
|
|
19
22
|
const TMUX_INSTALL_HINT =
|
|
20
23
|
'borg launch-all: tmux not found.\n' +
|
|
21
24
|
' macOS: brew install tmux\n' +
|
|
@@ -239,6 +242,12 @@ export interface RunLaunchAllOptions {
|
|
|
239
242
|
sleep?: (ms: number) => Promise<void>;
|
|
240
243
|
nowISO?: () => string;
|
|
241
244
|
borgPath?: string;
|
|
245
|
+
/** Internal quickstart filter: launch only the requested staffed roster. */
|
|
246
|
+
droneIds?: readonly string[];
|
|
247
|
+
/** Fail when any requested registered drone cannot be discovered or launched. */
|
|
248
|
+
requireAllRequested?: boolean;
|
|
249
|
+
/** Internal resolved target avoids ambiguous same-name lookup during composition. */
|
|
250
|
+
targetCube?: { cubeId: string; name: string };
|
|
242
251
|
}
|
|
243
252
|
|
|
244
253
|
export async function runLaunchAll(
|
|
@@ -253,7 +262,7 @@ export async function runLaunchAll(
|
|
|
253
262
|
const launchDelayMs = resolveLaunchDelayMs(args.flags.launchDelayMs, deps.getEnv('BORG_LAUNCH_DELAY_MS'));
|
|
254
263
|
|
|
255
264
|
// 1. resolve target cube
|
|
256
|
-
const resolved = await resolveTargetCube(args, deps);
|
|
265
|
+
const resolved = opts.targetCube ?? await resolveTargetCube(args, deps);
|
|
257
266
|
if ('error' in resolved) {
|
|
258
267
|
deps.stderr(`borg launch-all: ${resolved.error}\n`);
|
|
259
268
|
return 1;
|
|
@@ -264,7 +273,18 @@ export async function runLaunchAll(
|
|
|
264
273
|
sweepStaleLocks(deps, cubeId, now());
|
|
265
274
|
|
|
266
275
|
// 3. discover candidates
|
|
267
|
-
|
|
276
|
+
let discovered = await discoverDroneCandidates({ targetCubeId: cubeId, only: args.flags.only }, deps);
|
|
277
|
+
if (opts.droneIds !== undefined) {
|
|
278
|
+
const requested = new Set(opts.droneIds);
|
|
279
|
+
discovered = discovered.filter((candidate) => requested.has(candidate.droneId));
|
|
280
|
+
if (opts.requireAllRequested && discovered.length !== requested.size) {
|
|
281
|
+
deps.stderr(
|
|
282
|
+
`borg launch-all: found ${discovered.length} of ${requested.size} requested registered drone worktrees; ` +
|
|
283
|
+
'nothing was launched. Run `borg drones` to inspect the missing local registration.\n',
|
|
284
|
+
);
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
268
288
|
if (discovered.length === 0) {
|
|
269
289
|
if (args.flags.only !== undefined) {
|
|
270
290
|
deps.stdout(`No worktrees matched --only '${args.flags.only}' for cube '${cubeName}'\n`);
|
|
@@ -392,6 +412,14 @@ export async function runLaunchAll(
|
|
|
392
412
|
}
|
|
393
413
|
launchable.push(c);
|
|
394
414
|
}
|
|
415
|
+
const terminalSkipCount = evictedCount + revokedCount + rejectedCount + trustMismatchCount + credentialRejectedCount;
|
|
416
|
+
if (opts.requireAllRequested && terminalSkipCount > 0) {
|
|
417
|
+
deps.stderr(
|
|
418
|
+
`borg launch-all: ${terminalSkipCount} requested registered drone(s) are not launchable; ` +
|
|
419
|
+
'nothing was launched. Resolve the cause reported above, then retry.\n',
|
|
420
|
+
);
|
|
421
|
+
return 1;
|
|
422
|
+
}
|
|
395
423
|
if (launchable.length === 0) {
|
|
396
424
|
// Accurate cause counts — an all-rejected sweep must NOT claim "evicted"
|
|
397
425
|
// (and vice-versa). Only name a cause when at least one seat hit it.
|
|
@@ -441,6 +469,13 @@ export async function runLaunchAll(
|
|
|
441
469
|
deps.stderr(sel.hardFail);
|
|
442
470
|
return 1;
|
|
443
471
|
}
|
|
472
|
+
if (sel.backend === 'pastelist' && opts.requireAllRequested) {
|
|
473
|
+
deps.stderr(
|
|
474
|
+
'borg launch-all: pastelist mode prints commands for manual use but does not launch sessions; ' +
|
|
475
|
+
'nothing was launched. Run `borg launch-all` separately to print the commands.\n',
|
|
476
|
+
);
|
|
477
|
+
return LAUNCH_ALL_NO_DISPATCH_EXIT_CODE;
|
|
478
|
+
}
|
|
444
479
|
const sessionName = sanitizeSessionName(cubeName);
|
|
445
480
|
const launchStartISO = nowISO();
|
|
446
481
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { redactCloneSecrets } from './clone-security.js';
|
|
2
|
+
|
|
3
|
+
export interface CloneArgs {
|
|
4
|
+
repositoryUrl: string;
|
|
5
|
+
destination?: string;
|
|
6
|
+
noLaunch: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ParseCloneResult =
|
|
10
|
+
| { ok: true; args: CloneArgs }
|
|
11
|
+
| { ok: false; error: string };
|
|
12
|
+
|
|
13
|
+
export function parseCloneArgs(rawArgs: readonly string[]): ParseCloneResult {
|
|
14
|
+
let repositoryUrl: string | undefined;
|
|
15
|
+
let destination: string | undefined;
|
|
16
|
+
let noLaunch = false;
|
|
17
|
+
for (const arg of rawArgs) {
|
|
18
|
+
if (arg === '--no-launch') {
|
|
19
|
+
if (noLaunch) return { ok: false, error: '--no-launch was provided more than once' };
|
|
20
|
+
noLaunch = true;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (arg.startsWith('-')) {
|
|
24
|
+
const option = arg.startsWith('--') ? arg.split('=', 1)[0] : arg.slice(0, 2);
|
|
25
|
+
return { ok: false, error: `unknown option ${option}; the only option is --no-launch` };
|
|
26
|
+
}
|
|
27
|
+
if (repositoryUrl === undefined) repositoryUrl = arg;
|
|
28
|
+
else if (destination === undefined) destination = arg;
|
|
29
|
+
else return { ok: false, error: 'unexpected extra argument' };
|
|
30
|
+
}
|
|
31
|
+
if (!repositoryUrl) return { ok: false, error: 'a repository URL is required' };
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
args: {
|
|
35
|
+
repositoryUrl,
|
|
36
|
+
...(destination === undefined ? {} : { destination }),
|
|
37
|
+
noLaunch,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function safeCloneParseError(result: Extract<ParseCloneResult, { ok: false }>): string {
|
|
43
|
+
return redactCloneSecrets(result.error);
|
|
44
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { NEW_CUBE_TEMPLATE_PRESENTATIONS } from 'borgmcp-shared/templates';
|
|
2
|
+
|
|
3
|
+
export interface QuickstartRoleRequest {
|
|
4
|
+
slug: string;
|
|
5
|
+
count: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface QuickstartArgs {
|
|
9
|
+
template?: string;
|
|
10
|
+
roles: QuickstartRoleRequest[];
|
|
11
|
+
yes: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ParseQuickstartResult =
|
|
15
|
+
| { ok: true; args: QuickstartArgs }
|
|
16
|
+
| { ok: false; error: string };
|
|
17
|
+
|
|
18
|
+
const templates = new Set<string>(NEW_CUBE_TEMPLATE_PRESENTATIONS.map(({ name }) => name));
|
|
19
|
+
|
|
20
|
+
function parseRole(value: string): QuickstartRoleRequest | null {
|
|
21
|
+
const match = /^([a-z0-9]+(?:-[a-z0-9]+)*)(?::([1-9]\d*))?$/.exec(value);
|
|
22
|
+
if (!match) return null;
|
|
23
|
+
const count = match[2] === undefined ? 1 : Number(match[2]);
|
|
24
|
+
return Number.isSafeInteger(count) ? { slug: match[1], count } : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseQuickstartArgs(rawArgs: readonly string[]): ParseQuickstartResult {
|
|
28
|
+
const args: QuickstartArgs = { roles: [], yes: false };
|
|
29
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
30
|
+
const arg = rawArgs[i];
|
|
31
|
+
if (arg === '--yes' || arg === '-y') {
|
|
32
|
+
args.yes = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (arg === '--template') {
|
|
36
|
+
const value = rawArgs[++i];
|
|
37
|
+
if (!value) return { ok: false, error: '--template requires software-dev, starter, or local-model' };
|
|
38
|
+
if (!templates.has(value)) return { ok: false, error: `unknown template '${value}'; choose software-dev, starter, or local-model` };
|
|
39
|
+
if (args.template !== undefined) return { ok: false, error: '--template was provided more than once' };
|
|
40
|
+
args.template = value;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (arg === '--role') {
|
|
44
|
+
const value = rawArgs[++i];
|
|
45
|
+
if (!value) return { ok: false, error: '--role requires <slug>[:<count>]' };
|
|
46
|
+
const role = parseRole(value);
|
|
47
|
+
if (!role) return { ok: false, error: `invalid role '${value}'; use <slug>[:<positive-count>]` };
|
|
48
|
+
args.roles.push(role);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
return { ok: false, error: `unknown option: ${arg}. Supported: --template, --role, --yes/-y` };
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, args };
|
|
54
|
+
}
|