borgmcp 3.9.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/SECURITY.md +1 -1
- 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 +25 -3
- 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 +33 -6
- 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/cubes.js +2 -2
- package/dist/cubes.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- 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/log-stream.d.ts +6 -2
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +36 -11
- package/dist/log-stream.js.map +1 -1
- package/dist/opencode-drone.d.ts +7 -2
- package/dist/opencode-drone.d.ts.map +1 -1
- package/dist/opencode-drone.js +291 -64
- package/dist/opencode-drone.js.map +1 -1
- package/dist/opencode-plugin.d.ts.map +1 -1
- package/dist/opencode-plugin.js +12 -2
- package/dist/opencode-plugin.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/remote-client.d.ts +13 -0
- package/dist/remote-client.d.ts.map +1 -1
- package/dist/remote-client.js +25 -0
- package/dist/remote-client.js.map +1 -1
- package/dist/seat-commands.js +12 -12
- package/dist/seat-commands.js.map +1 -1
- package/dist/stream-status.d.ts.map +1 -1
- package/dist/stream-status.js +1 -0
- package/dist/stream-status.js.map +1 -1
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -1
- package/dist/unknown-subcommand.js +3 -1
- package/dist/unknown-subcommand.js.map +1 -1
- package/docs/RELEASING.md +94 -23
- package/package.json +1 -1
- package/src/assimilate-cmd.ts +35 -12
- package/src/claude.ts +25 -3
- package/src/cli-help.ts +39 -6
- package/src/clone-cmd.ts +243 -0
- package/src/clone-security.ts +40 -0
- package/src/cubes.ts +2 -2
- package/src/index.ts +4 -1
- package/src/launch-all-cmd.ts +37 -2
- package/src/log-stream.ts +49 -12
- package/src/opencode-drone.ts +328 -65
- package/src/opencode-plugin.ts +13 -1
- 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/remote-client.ts +36 -0
- package/src/seat-commands.ts +12 -12
- package/src/stream-status.ts +3 -0
- package/src/unknown-subcommand.ts +3 -1
package/docs/RELEASING.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Publishing `borgmcp`
|
|
2
2
|
|
|
3
|
-
The GitHub Actions workflow
|
|
4
|
-
from a protected annotated tag.
|
|
5
|
-
|
|
3
|
+
The GitHub Actions workflow submits one immutable, reviewed `borgmcp` tarball to
|
|
4
|
+
npm staged publishing from a protected annotated tag. A designated operator
|
|
5
|
+
later approves that stage to make the version live. The protected stage job uses
|
|
6
|
+
npm Trusted Publishing; no long-lived npm token is stored or exposed.
|
|
6
7
|
|
|
7
8
|
## Release Integrity
|
|
8
9
|
|
|
@@ -22,9 +23,9 @@ Before creating the release tag, independently verify all of these conditions:
|
|
|
22
23
|
- the exact audited registry dependency `borgmcp-shared@0.11.0` remains locked to
|
|
23
24
|
its canonical tarball and integrity
|
|
24
25
|
`sha512-I8mixCbSrLKyOAAyqEI/HZJ8cML2rz3r812Up8pr547OdAk9LxZevdCo7ojG42ZwrUmS5u7iKQPg7Vk1XvtX1g==`;
|
|
25
|
-
- the coupled
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
- the coupled shared/server/client candidates have all been built against the
|
|
27
|
+
same exact `borgmcp-shared` version and passed the complete local dogfood gate
|
|
28
|
+
before any stage is approved;
|
|
28
29
|
- the selected stable client version is unused and the exact release commit is
|
|
29
30
|
on protected `main`;
|
|
30
31
|
- the repository and protected npm environment settings pass an operator audit;
|
|
@@ -54,7 +55,9 @@ Before preparing a candidate, independently verify:
|
|
|
54
55
|
existing `borgmcp` package. It must contain no npm token.
|
|
55
56
|
2. npm Trusted Publishing is configured for organization `Byte-Ventures`,
|
|
56
57
|
repository `borg-mcp-client`, workflow `publish.yml`, and environment
|
|
57
|
-
`npm-publish`.
|
|
58
|
+
`npm-publish`. Its allowed actions enable `npm stage publish` and disable
|
|
59
|
+
direct `npm publish`; package publishing requires two-factor authentication
|
|
60
|
+
and disallows tokens.
|
|
58
61
|
3. `refs/tags/v*.*.*` cannot be updated, deleted, or force-moved. Release tags
|
|
59
62
|
are annotated, match the package version, and point to a commit on protected
|
|
60
63
|
`main`.
|
|
@@ -292,40 +295,97 @@ The unprivileged `verify` job performs one sequence:
|
|
|
292
295
|
release artifact.
|
|
293
296
|
|
|
294
297
|
After `verify` succeeds, the designated Queen operator alone approves the
|
|
295
|
-
`npm-publish` environment. There is no separate pre-
|
|
298
|
+
`npm-publish` environment. There is no separate pre-stage exact-artifact
|
|
296
299
|
Security gate: the verify job is the mechanical authority for the exact bytes
|
|
297
|
-
that the
|
|
298
|
-
|
|
300
|
+
that the stage job consumes. Environment approval authorizes submitting those
|
|
301
|
+
bytes to npm's private staged-publishing service; it does not make the version
|
|
302
|
+
public, permit a rerun or rebuilt artifact, or authorize approval by another
|
|
303
|
+
actor.
|
|
299
304
|
|
|
300
305
|
The protected `publish` job alone receives `id-token: write`. It downloads the
|
|
301
306
|
same-run artifact and rejects a report whose package name or version differs
|
|
302
307
|
from the release, a version that already exists, an unclaimed package, or an
|
|
303
308
|
owner set that differs from `NPM_EXPECTED_OWNER`. It requires the GitHub OIDC
|
|
304
|
-
request context, rejects a legacy `NODE_AUTH_TOKEN`, and
|
|
305
|
-
|
|
309
|
+
request context, rejects a legacy `NODE_AUTH_TOKEN`, and stages the exact tarball
|
|
310
|
+
path once with lifecycle scripts disabled and provenance enabled. It
|
|
306
311
|
does not install project dependencies, rebuild, retest, repack, or reverify the
|
|
307
312
|
package.
|
|
308
313
|
|
|
309
|
-
Successful completion of
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
314
|
+
Successful completion of the workflow means npm accepted the immutable staged
|
|
315
|
+
tarball. It does not mean the version is public. Stage acceptance consumes the
|
|
316
|
+
tagged attempt and version under the burned-version rule, but must not trigger
|
|
317
|
+
release announcements, issue closure, consumer pins, site synchronization, or
|
|
318
|
+
claims that the version was published. There is no workflow registry readback:
|
|
319
|
+
stage inspection and approval require an interactive npm identity and cannot use
|
|
320
|
+
the workflow's OIDC credential.
|
|
321
|
+
|
|
322
|
+
### Coupled stage approval
|
|
323
|
+
|
|
324
|
+
Before approving anything, the operator requires successful stage workflows for
|
|
325
|
+
`borgmcp-shared`, `borgmcp-server`, and `borgmcp`. Use authenticated
|
|
326
|
+
`npm stage list` and `npm stage view <UUID>` to record and inspect all three stage
|
|
327
|
+
UUIDs and verify each package, version, and eventual `latest` tag. For every
|
|
328
|
+
package, `npm stage download <UUID>` is mandatory; compute its SHA-512 SRI and
|
|
329
|
+
require an exact match with that workflow run's same-run artifact report. Bind
|
|
330
|
+
the source annotated tag object, commit, and workflow run separately from GitHub
|
|
331
|
+
evidence, because npm stage inspection does not expose them. Exercise the exact
|
|
332
|
+
downloaded coupled set before approval. Confirm that public `latest`, public
|
|
333
|
+
`versions`, and client update resolution still expose the prior coherent release
|
|
334
|
+
set. If a pending stage appears on any public surface, approve nothing and halt
|
|
335
|
+
this mechanism before consumers can observe it.
|
|
336
|
+
|
|
337
|
+
Approve the verified UUIDs in one operator session, using interactive 2FA, in
|
|
338
|
+
this exact order:
|
|
339
|
+
|
|
340
|
+
1. `npm stage approve <shared-stage-uuid>`
|
|
341
|
+
2. `npm stage approve <server-stage-uuid>`
|
|
342
|
+
3. `npm stage approve <client-stage-uuid>`
|
|
343
|
+
|
|
344
|
+
The approvals are not atomic. Shared approval leaves existing client and server
|
|
345
|
+
pins coherent. Server approval opens a bounded mismatch window between public
|
|
346
|
+
server `latest` and client `latest`; client approval closes it. After an approval
|
|
347
|
+
returns successfully, continue only while the next stage remains valid. For an
|
|
348
|
+
ambiguous result, inspect authenticated stage state and canonical public package
|
|
349
|
+
version/integrity before acting; never repeat an approval blindly.
|
|
350
|
+
|
|
351
|
+
The terminal release boundary is successful interactive stage approval followed
|
|
352
|
+
by canonical registry visibility, integrity verification, and registry
|
|
353
|
+
provenance-attestation inspection. Stage inspection cannot establish provenance
|
|
354
|
+
before approval; Trusted Publishing carries it to the live package, where the
|
|
355
|
+
registry attestation is authoritative. Only then may the release be announced,
|
|
356
|
+
issues closed, consumer pins or site data synchronized, or the version described
|
|
357
|
+
as published.
|
|
358
|
+
|
|
359
|
+
After all three packages cross that boundary, create their GitHub Releases in
|
|
360
|
+
the same shared → server → client operator session. In each package repository,
|
|
361
|
+
run:
|
|
362
|
+
|
|
363
|
+
```sh
|
|
364
|
+
GITHUB_TOKEN="$(gh auth token)" node scripts/create-github-release.mjs <version>
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
The script binds the annotated tag, merged release pull request, successful tag
|
|
368
|
+
workflow artifact, and live npm integrity before creating the Release. It copies
|
|
369
|
+
the merged release pull request body verbatim and refuses an existing Release.
|
|
313
370
|
|
|
314
371
|
Separately, once the release is installable from the canonical registry, install
|
|
315
372
|
it into an isolated prefix and exercise the real user update path end to end.
|
|
316
|
-
This is product verification, not
|
|
373
|
+
This is product verification, not candidate validation: failure routes a new
|
|
317
374
|
reviewed fix and never invalidates, rebuilds, retags, or reruns the immutable
|
|
318
|
-
release. Do not repeat byte comparisons,
|
|
319
|
-
|
|
320
|
-
|
|
375
|
+
release. Do not repeat byte comparisons, packed-version checks, source-tree
|
|
376
|
+
verification, dist-tag readback, or provenance reconstruction that the
|
|
377
|
+
exact-artifact `verify` and stage jobs already completed. The canonical
|
|
378
|
+
live-version integrity check is required because it establishes the approval
|
|
379
|
+
boundary; it is not a duplicate candidate verification.
|
|
321
380
|
|
|
322
381
|
No separate checksum file is needed: the tarball verifier records canonical
|
|
323
382
|
SHA-512 SRI in the artifact report. GitHub's same-run artifact transport and the
|
|
324
383
|
report bind the reviewed candidate without repeated SHA512 choreography.
|
|
325
384
|
|
|
326
|
-
Rely on npm Trusted Publishing.
|
|
327
|
-
|
|
328
|
-
locally
|
|
385
|
+
Rely on npm Trusted Publishing. Perform the one authoritative registry
|
|
386
|
+
provenance-attestation inspection at the approved-live boundary; do not
|
|
387
|
+
reconstruct DSSE, in-toto, SLSA, workflow-ref, or builder statements locally or
|
|
388
|
+
add redundant provenance readback.
|
|
329
389
|
Do not add cross-run tuple variables, cross-run artifact selection, duplicate
|
|
330
390
|
builds, duplicate package verification, checksum bundles, or SBOM ceremony.
|
|
331
391
|
|
|
@@ -336,3 +396,14 @@ authorization evidence is missing or inconsistent. Never move or reuse a failed
|
|
|
336
396
|
tag, rerun a failed release workflow, overwrite an npm version, unpublish to hide
|
|
337
397
|
a failure, or substitute a local rebuild. Recovery starts from a fresh reviewed
|
|
338
398
|
source change and, after any registry mutation, a separately authorized version.
|
|
399
|
+
|
|
400
|
+
Before any approval, missing or inconsistent stage evidence means approve none.
|
|
401
|
+
Reject all three stages only when abandoning the coupled candidate; Borg treats
|
|
402
|
+
every rejected or unusable tagged stage as burned, even though npm permits some
|
|
403
|
+
re-staging. Replacement versions require fresh review, tags, and stage runs.
|
|
404
|
+
After shared approval but before server approval, stopping is compatibility-safe
|
|
405
|
+
for the existing client/server pair, but shared is immutable and any repair uses
|
|
406
|
+
fresh versions. After server approval, prioritize the already-verified client
|
|
407
|
+
approval because the public pair is mismatched. If that client stage cannot be
|
|
408
|
+
approved, do not reject it or rerun the tag; prepare newly reviewed matching
|
|
409
|
+
server/client recovery versions and state the live mismatch window explicitly.
|
package/package.json
CHANGED
package/src/assimilate-cmd.ts
CHANGED
|
@@ -126,6 +126,21 @@ export interface AssimilateArgs {
|
|
|
126
126
|
mode?: 'assimilate' | 'cube-init';
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
export interface PreparedAssimilation {
|
|
130
|
+
cubeId: string;
|
|
131
|
+
cubeName: string;
|
|
132
|
+
droneId: string;
|
|
133
|
+
droneLabel: string;
|
|
134
|
+
roleName: string;
|
|
135
|
+
worktree: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface RunAssimilateOptions {
|
|
139
|
+
/** Internal composition seam for quickstart; the public assimilate command always launches. */
|
|
140
|
+
launch?: boolean;
|
|
141
|
+
onPrepared?: (prepared: PreparedAssimilation) => void;
|
|
142
|
+
}
|
|
143
|
+
|
|
129
144
|
export interface CubeSummary {
|
|
130
145
|
id: string;
|
|
131
146
|
name: string;
|
|
@@ -702,7 +717,8 @@ function diagnoseSessionTermination(
|
|
|
702
717
|
|
|
703
718
|
export async function runAssimilate(
|
|
704
719
|
args: AssimilateArgs,
|
|
705
|
-
deps: AssimilateDeps
|
|
720
|
+
deps: AssimilateDeps,
|
|
721
|
+
options: RunAssimilateOptions = {},
|
|
706
722
|
): Promise<number> {
|
|
707
723
|
const mode = args.mode ?? 'assimilate';
|
|
708
724
|
// ----- Input validation (before any subprocess work) -----
|
|
@@ -2033,6 +2049,24 @@ export async function runAssimilate(
|
|
|
2033
2049
|
/* gh#793: orphan GC is best-effort — never block or fail the assimilate */
|
|
2034
2050
|
}
|
|
2035
2051
|
|
|
2052
|
+
// The project hook belongs to a prepared drone, not to the terminal handoff.
|
|
2053
|
+
// Quickstart suppresses only that handoff and later launches through launch-all.
|
|
2054
|
+
try {
|
|
2055
|
+
deps.installProjectSessionHook(agentCwd);
|
|
2056
|
+
} catch {
|
|
2057
|
+
deps.stderr(`warning: could not install the project-local SessionStart hook in ${agentCwd}; it will be re-attempted on the next borg launch\n`);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
options.onPrepared?.({
|
|
2061
|
+
cubeId: result.cube_id,
|
|
2062
|
+
cubeName: cubeDetail.name,
|
|
2063
|
+
droneId: result.drone_id,
|
|
2064
|
+
droneLabel: result.drone_label,
|
|
2065
|
+
roleName: assignedRole.name,
|
|
2066
|
+
worktree: seatWorktree,
|
|
2067
|
+
});
|
|
2068
|
+
if (options.launch === false) return 0;
|
|
2069
|
+
|
|
2036
2070
|
// ----- Step 8: Launch selected agent CLI -----
|
|
2037
2071
|
// Mirrors the kickoff invocation from claude.ts (no-args path): the agent
|
|
2038
2072
|
// picks up the newly-persisted ActiveCube via the MCP stdio server on
|
|
@@ -2060,17 +2094,6 @@ export async function runAssimilate(
|
|
|
2060
2094
|
),
|
|
2061
2095
|
);
|
|
2062
2096
|
|
|
2063
|
-
// gh#673 P2 (WI-1): install the project-local SessionStart orientation
|
|
2064
|
-
// hook into the launch root — covers BOTH the freshly-spawned sibling
|
|
2065
|
-
// worktree (agentCwd = the new worktree post-chdir) and the in-place /
|
|
2066
|
-
// --here path. Best-effort: a hook-install failure must never block
|
|
2067
|
-
// the assimilate (the bare-`borg` launcher re-ensures it).
|
|
2068
|
-
try {
|
|
2069
|
-
deps.installProjectSessionHook(agentCwd);
|
|
2070
|
-
} catch {
|
|
2071
|
-
deps.stderr(`warning: could not install the project-local SessionStart hook in ${agentCwd}; it will be re-attempted on the next borg launch\n`);
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
2097
|
// BUG-5 / v0.9.3: probe MCP readiness before launching claude so
|
|
2075
2098
|
// the launched session sees tools at startup. Non-blocking: probe
|
|
2076
2099
|
// failure surfaces a stderr warning but the launch proceeds (the
|
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) {
|
|
@@ -301,11 +323,11 @@ async function main() {
|
|
|
301
323
|
const code = await runCleanup({}, parsed.options);
|
|
302
324
|
process.exit(code);
|
|
303
325
|
}
|
|
304
|
-
if (process.argv[2] === '
|
|
326
|
+
if (process.argv[2] === 'drones') {
|
|
305
327
|
const parsed = parseSeatsArgs(process.argv.slice(3));
|
|
306
328
|
if (!parsed.ok) {
|
|
307
|
-
process.stderr.write(chalk.red(`${consolePrefix()}◼ borg
|
|
308
|
-
process.stderr.write(`Run \`borg
|
|
329
|
+
process.stderr.write(chalk.red(`${consolePrefix()}◼ borg drones: ${parsed.error}\n`));
|
|
330
|
+
process.stderr.write(`Run \`borg drones --help\` for usage.\n`);
|
|
309
331
|
process.exit(1);
|
|
310
332
|
}
|
|
311
333
|
process.exit(await runSeats(buildDefaultSeatCommandDeps()));
|
package/src/cli-help.ts
CHANGED
|
@@ -51,12 +51,41 @@ 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
|
-
`borg
|
|
85
|
+
`borg drones (borgmcp ${version}) — list this machine's registered drones\n\n` +
|
|
57
86
|
`Usage:\n` +
|
|
58
|
-
` borg
|
|
59
|
-
` borg
|
|
87
|
+
` borg drones Show drone, cube, worktree, agent CLI, and local state\n` +
|
|
88
|
+
` borg drones --help Show this help\n\n` +
|
|
60
89
|
`The local registry belongs to this machine only.\n`
|
|
61
90
|
);
|
|
62
91
|
}
|
|
@@ -91,11 +120,13 @@ 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);
|
|
98
|
-
case '
|
|
129
|
+
case 'drones': return seatsHelpText(version);
|
|
99
130
|
case 'launch': return launchSeatHelpText(version);
|
|
100
131
|
case 'launch-all': return launchAllHelpText(version);
|
|
101
132
|
case 'doctor': return doctorHelpText(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` +
|
|
@@ -136,7 +169,7 @@ export function topLevelHelpText(version: string): string {
|
|
|
136
169
|
` borg reset-local-connection Clear ONLY this worktree's saved connection to its cube (offline; after a rejection)\n` +
|
|
137
170
|
` borg recover-enrollment Restore or clear ONLY one failed server enrollment transaction\n` +
|
|
138
171
|
` borg cleanup [--prune] Report (or --prune) worktrees orphaned by evicted drones\n` +
|
|
139
|
-
` borg
|
|
172
|
+
` borg drones List this machine's registered drones and worktrees\n` +
|
|
140
173
|
` borg launch <drone-label-or-id-prefix> Reopen one registered drone from its worktree\n` +
|
|
141
174
|
` borg launch-all [cube] Launch all drone worktrees of a cube (default: active cube)\n` +
|
|
142
175
|
` borg server <command> [arguments]\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
|
+
}
|