borgmcp 1.0.6 → 1.0.7
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.js +39 -511
- package/dist/assimilate-deps.js +3 -177
- package/dist/assimilate-welcome.js +2 -24
- package/dist/auth-env.js +1 -107
- package/dist/auth.js +23 -612
- package/dist/claude.js +11 -281
- package/dist/cli-help.js +29 -50
- package/dist/cli-platform.js +4 -94
- package/dist/codex-app-server.js +4 -228
- package/dist/codex-app-wake.js +2 -122
- package/dist/codex-launch.js +1 -81
- package/dist/codex-remote.js +1 -250
- package/dist/config-utils.js +3 -385
- package/dist/config.js +1 -190
- package/dist/console-prefix.js +1 -86
- package/dist/cube-name.js +1 -65
- package/dist/cubes.js +4 -269
- package/dist/debug.js +1 -71
- package/dist/device-auth.js +1 -167
- package/dist/direct-log.js +1 -11
- package/dist/health-beat.js +1 -168
- package/dist/inbox-monitor.js +1 -129
- package/dist/index.js +26 -1378
- package/dist/lifecycle-log-guard.js +2 -93
- package/dist/list-roles-render.js +6 -39
- package/dist/log-audit.js +3 -186
- package/dist/log-stream.js +9 -848
- package/dist/name-validator.js +1 -22
- package/dist/parse-assimilate-args.js +1 -82
- package/dist/postinstall.js +8 -22
- package/dist/regen-format.js +11 -337
- package/dist/regen.js +5 -83
- package/dist/remote-client.js +1 -695
- package/dist/role-resolver.js +1 -36
- package/dist/role-section.js +8 -208
- package/dist/roster-render.js +3 -96
- package/dist/setup.js +36 -251
- package/dist/shell-escape.js +1 -22
- package/dist/spawn.js +10 -29
- package/dist/stale-version-check.js +1 -102
- package/dist/stream-owner.js +2 -202
- package/dist/stream-status.js +3 -211
- package/dist/subscription-retry.js +1 -23
- package/dist/sync-roles-render.js +3 -118
- package/dist/sync.js +22 -286
- package/dist/templates.js +120 -626
- package/dist/terminal-title.js +1 -68
- package/dist/token-crypto.js +1 -91
- package/dist/token-store.js +1 -222
- package/dist/types.js +0 -5
- package/dist/version.js +2 -78
- package/dist/worktree-lifecycle.js +2 -173
- package/package.json +11 -2
- package/dist/assimilate-cmd.d.ts.map +0 -1
- package/dist/assimilate-cmd.js.map +0 -1
- package/dist/assimilate-deps.d.ts.map +0 -1
- package/dist/assimilate-deps.js.map +0 -1
- package/dist/assimilate-welcome.d.ts.map +0 -1
- package/dist/assimilate-welcome.js.map +0 -1
- package/dist/auth-env.d.ts.map +0 -1
- package/dist/auth-env.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/claude.d.ts.map +0 -1
- package/dist/claude.js.map +0 -1
- package/dist/cli-help.d.ts.map +0 -1
- package/dist/cli-help.js.map +0 -1
- package/dist/cli-platform.d.ts.map +0 -1
- package/dist/cli-platform.js.map +0 -1
- package/dist/codex-app-server.d.ts.map +0 -1
- package/dist/codex-app-server.js.map +0 -1
- package/dist/codex-app-wake.d.ts.map +0 -1
- package/dist/codex-app-wake.js.map +0 -1
- package/dist/codex-launch.d.ts.map +0 -1
- package/dist/codex-launch.js.map +0 -1
- package/dist/codex-remote.d.ts.map +0 -1
- package/dist/codex-remote.js.map +0 -1
- package/dist/config-utils.d.ts.map +0 -1
- package/dist/config-utils.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/console-prefix.d.ts.map +0 -1
- package/dist/console-prefix.js.map +0 -1
- package/dist/cube-name.d.ts.map +0 -1
- package/dist/cube-name.js.map +0 -1
- package/dist/cubes.d.ts.map +0 -1
- package/dist/cubes.js.map +0 -1
- package/dist/debug.d.ts.map +0 -1
- package/dist/debug.js.map +0 -1
- package/dist/device-auth.d.ts.map +0 -1
- package/dist/device-auth.js.map +0 -1
- package/dist/direct-log.d.ts.map +0 -1
- package/dist/direct-log.js.map +0 -1
- package/dist/health-beat.d.ts.map +0 -1
- package/dist/health-beat.js.map +0 -1
- package/dist/inbox-monitor.d.ts.map +0 -1
- package/dist/inbox-monitor.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lifecycle-log-guard.d.ts.map +0 -1
- package/dist/lifecycle-log-guard.js.map +0 -1
- package/dist/list-roles-render.d.ts.map +0 -1
- package/dist/list-roles-render.js.map +0 -1
- package/dist/log-audit.d.ts.map +0 -1
- package/dist/log-audit.js.map +0 -1
- package/dist/log-stream.d.ts.map +0 -1
- package/dist/log-stream.js.map +0 -1
- package/dist/name-validator.d.ts.map +0 -1
- package/dist/name-validator.js.map +0 -1
- package/dist/parse-assimilate-args.d.ts.map +0 -1
- package/dist/parse-assimilate-args.js.map +0 -1
- package/dist/postinstall.d.ts.map +0 -1
- package/dist/postinstall.js.map +0 -1
- package/dist/regen-format.d.ts.map +0 -1
- package/dist/regen-format.js.map +0 -1
- package/dist/regen.d.ts.map +0 -1
- package/dist/regen.js.map +0 -1
- package/dist/remote-client.d.ts.map +0 -1
- package/dist/remote-client.js.map +0 -1
- package/dist/role-resolver.d.ts.map +0 -1
- package/dist/role-resolver.js.map +0 -1
- package/dist/role-section.d.ts.map +0 -1
- package/dist/role-section.js.map +0 -1
- package/dist/roster-render.d.ts.map +0 -1
- package/dist/roster-render.js.map +0 -1
- package/dist/setup.d.ts.map +0 -1
- package/dist/setup.js.map +0 -1
- package/dist/shell-escape.d.ts.map +0 -1
- package/dist/shell-escape.js.map +0 -1
- package/dist/spawn.d.ts.map +0 -1
- package/dist/spawn.js.map +0 -1
- package/dist/stale-version-check.d.ts.map +0 -1
- package/dist/stale-version-check.js.map +0 -1
- package/dist/stream-owner.d.ts.map +0 -1
- package/dist/stream-owner.js.map +0 -1
- package/dist/stream-status.d.ts.map +0 -1
- package/dist/stream-status.js.map +0 -1
- package/dist/subscription-retry.d.ts.map +0 -1
- package/dist/subscription-retry.js.map +0 -1
- package/dist/sync-roles-render.d.ts.map +0 -1
- package/dist/sync-roles-render.js.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- package/dist/sync.js.map +0 -1
- package/dist/templates.d.ts.map +0 -1
- package/dist/templates.js.map +0 -1
- package/dist/terminal-title.d.ts.map +0 -1
- package/dist/terminal-title.js.map +0 -1
- package/dist/token-crypto.d.ts.map +0 -1
- package/dist/token-crypto.js.map +0 -1
- package/dist/token-store.d.ts.map +0 -1
- package/dist/token-store.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
- package/dist/worktree-lifecycle.d.ts.map +0 -1
- package/dist/worktree-lifecycle.js.map +0 -1
package/dist/terminal-title.js
CHANGED
|
@@ -1,68 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Terminal-title setter for borg drone sessions.
|
|
3
|
-
*
|
|
4
|
-
* Multiple Claude Code sessions across sibling worktrees are visually
|
|
5
|
-
* indistinguishable in Cmd-Tab / tab bars / Mission Control on macOS,
|
|
6
|
-
* and likewise on most Linux terminal emulators. Setting the terminal
|
|
7
|
-
* title via the OSC 0 / OSC 2 escape gives each window a free per-
|
|
8
|
-
* session identity.
|
|
9
|
-
*
|
|
10
|
-
* Format (Queen-specified):
|
|
11
|
-
* - Assimilated drone session: `borg · <label> · <cubeName>`
|
|
12
|
-
* - Unassimilated session: `borg · <repo-basename>`
|
|
13
|
-
*
|
|
14
|
-
* Why OSC 0 (`\x1b]0;…\x07`): sets both window title AND icon name on
|
|
15
|
-
* most terminals. OSC 2 sets only window title; OSC 1 sets only icon
|
|
16
|
-
* name. OSC 0 is the maximally-portable choice — works in iTerm2,
|
|
17
|
-
* macOS Terminal, kitty, alacritty, ghostty, GNOME Terminal, xterm.
|
|
18
|
-
*
|
|
19
|
-
* Lifetime: the escape is emitted once, before spawning Claude Code.
|
|
20
|
-
* Claude Code itself does not set its own window title (verified
|
|
21
|
-
* 2026-05-11), so the borg-set title persists for the whole session.
|
|
22
|
-
*
|
|
23
|
-
* Limitations (acceptable for v1; flagged for future):
|
|
24
|
-
* - Title doesn't update mid-session on `borg:assimilate`. The
|
|
25
|
-
* borgmcp client process can't write the escape post-spawn
|
|
26
|
-
* because stdio is owned by Claude Code at that point (and stdio
|
|
27
|
-
* to Claude is JSON-RPC — terminal escapes would be parsed as
|
|
28
|
-
* invalid messages).
|
|
29
|
-
* - Falls back to repo-basename for the unassimilated case; loses
|
|
30
|
-
* drone identity until the cube is joined. Typical pattern is
|
|
31
|
-
* "drone has been around long enough that cubes.json is already
|
|
32
|
-
* populated," so the assimilated path is the common case.
|
|
33
|
-
*/
|
|
34
|
-
/**
|
|
35
|
-
* Pure: compose the title string for a session. Exported so tests can
|
|
36
|
-
* exercise every branch without TTY / process / fs dependencies.
|
|
37
|
-
*
|
|
38
|
-
* @param activeDrone — `{label, cubeName}` if this project is
|
|
39
|
-
* assimilated to a cube, null otherwise.
|
|
40
|
-
* @param repoBasename — fallback identity for the unassimilated case
|
|
41
|
-
* (typically `basename(process.cwd())`).
|
|
42
|
-
*/
|
|
43
|
-
export function composeTerminalTitle(activeDrone, repoBasename) {
|
|
44
|
-
if (activeDrone) {
|
|
45
|
-
return `borg · ${activeDrone.label} · ${activeDrone.cubeName}`;
|
|
46
|
-
}
|
|
47
|
-
return `borg · ${repoBasename}`;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Side-effecting: emit the OSC 0 escape to stdout, but only if stdout
|
|
51
|
-
* is a TTY. When stdout is piped (CI, redirection, scripted
|
|
52
|
-
* invocation), emitting the raw escape would pollute the captured
|
|
53
|
-
* output without doing anything useful — so we no-op.
|
|
54
|
-
*
|
|
55
|
-
* Returns the title string that WOULD have been emitted, regardless of
|
|
56
|
-
* TTY state, so callers can log it independently for diagnostics.
|
|
57
|
-
*/
|
|
58
|
-
export function setTerminalTitle(activeDrone, repoBasename, stdout = process.stdout) {
|
|
59
|
-
const title = composeTerminalTitle(activeDrone, repoBasename);
|
|
60
|
-
if (stdout.isTTY) {
|
|
61
|
-
// OSC 0: ESC ] 0 ; <title> BEL
|
|
62
|
-
// (BEL terminator is honored by all OSC-supporting terminals;
|
|
63
|
-
// ST = `ESC \` is an alternative but BEL has wider compat.)
|
|
64
|
-
stdout.write(`\x1b]0;${title}\x07`);
|
|
65
|
-
}
|
|
66
|
-
return title;
|
|
67
|
-
}
|
|
68
|
-
//# sourceMappingURL=terminal-title.js.map
|
|
1
|
+
function n(e,r){return e?`borg \xB7 ${e.label} \xB7 ${e.cubeName}`:`borg \xB7 ${r}`}function o(e,r,t=process.stdout){const i=n(e,r);return t.isTTY&&t.write(`\x1B]0;${i}\x07`),i}export{n as composeTerminalTitle,o as setTerminalTitle};
|
package/dist/token-crypto.js
CHANGED
|
@@ -1,91 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* gh#557 — AES-256-GCM crypto for the keychain-less token store.
|
|
3
|
-
*
|
|
4
|
-
* ⚠ OBFUSCATION-GRADE, BY DESIGN. The encryption key is DERIVED from stable
|
|
5
|
-
* machine+user identifiers (hostname, username, platform) — there is no
|
|
6
|
-
* passphrase. This means:
|
|
7
|
-
*
|
|
8
|
-
* - It DEFENDS against casual/accidental exposure: a dotfile backup, an
|
|
9
|
-
* `scp -r ~`, a synced home directory, a shoulder-surfed `cat`. The
|
|
10
|
-
* on-disk bytes are ciphertext, not a readable token.
|
|
11
|
-
* - It does NOT defend against a same-uid process or root on the SAME
|
|
12
|
-
* machine: anything that can read ~/.borg/credentials can also read the
|
|
13
|
-
* same hostname/username/platform and re-derive the key. That is an
|
|
14
|
-
* accepted limitation (SR-endorsed, gh#557 ESCALATION 2) and matches
|
|
15
|
-
* gcloud's own at-rest posture for its credential files.
|
|
16
|
-
*
|
|
17
|
-
* The OS keychain (config.ts default) remains the real at-rest encryption
|
|
18
|
-
* path; this fallback only engages when no keychain is available (headless
|
|
19
|
-
* Linux without Secret Service, etc.).
|
|
20
|
-
*
|
|
21
|
-
* Machine identifiers are injected (MachineKeyInputs) rather than read here,
|
|
22
|
-
* both for testability and so the production caller can choose OS primitives
|
|
23
|
-
* that work in headless/container environments (os.hostname()/os.userInfo()
|
|
24
|
-
* never spawn a subprocess, unlike a hardware machine-id probe).
|
|
25
|
-
*/
|
|
26
|
-
import crypto from 'crypto';
|
|
27
|
-
/**
|
|
28
|
-
* Static application salt — domain-separates this key derivation from any
|
|
29
|
-
* other use of the same machine identifiers. NOT a secret (it ships in the
|
|
30
|
-
* published client); its only job is to make the derived key specific to
|
|
31
|
-
* borg-mcp token storage.
|
|
32
|
-
*/
|
|
33
|
-
const KEY_SALT = 'borg-mcp/token-store/v1';
|
|
34
|
-
const ENVELOPE_VERSION = 'v1';
|
|
35
|
-
const IV_BYTES = 12; // 96-bit nonce, the GCM standard
|
|
36
|
-
const KEY_BYTES = 32; // AES-256
|
|
37
|
-
/**
|
|
38
|
-
* Derive a stable 32-byte AES-256 key from machine+user identifiers.
|
|
39
|
-
* Deterministic for a given machine+user (so a token written today decrypts
|
|
40
|
-
* tomorrow) and distinct across machines/users.
|
|
41
|
-
*/
|
|
42
|
-
export function deriveMachineKey(inputs) {
|
|
43
|
-
const material = [inputs.hostname, inputs.username, inputs.platform, KEY_SALT].join('\0');
|
|
44
|
-
return crypto.createHash('sha256').update(material).digest(); // 32 bytes
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Encrypt a plaintext string under the given key. Returns a versioned,
|
|
48
|
-
* dot-delimited envelope: `v1.<base64(iv)>.<base64(tag)>.<base64(ct)>`.
|
|
49
|
-
* A fresh random IV per call means the same plaintext encrypts differently
|
|
50
|
-
* every time (no deterministic-ciphertext leak).
|
|
51
|
-
*/
|
|
52
|
-
export function encryptString(plaintext, key) {
|
|
53
|
-
if (key.length !== KEY_BYTES) {
|
|
54
|
-
throw new Error(`token-crypto: key must be ${KEY_BYTES} bytes`);
|
|
55
|
-
}
|
|
56
|
-
const iv = crypto.randomBytes(IV_BYTES);
|
|
57
|
-
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
58
|
-
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
59
|
-
const tag = cipher.getAuthTag();
|
|
60
|
-
return [
|
|
61
|
-
ENVELOPE_VERSION,
|
|
62
|
-
iv.toString('base64'),
|
|
63
|
-
tag.toString('base64'),
|
|
64
|
-
ciphertext.toString('base64'),
|
|
65
|
-
].join('.');
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Decrypt an envelope produced by encryptString. Throws on a malformed
|
|
69
|
-
* envelope, a wrong key, or a tampered ciphertext (the GCM auth tag fails
|
|
70
|
-
* verification) — fail-closed is correct for credential material.
|
|
71
|
-
*/
|
|
72
|
-
export function decryptString(envelope, key) {
|
|
73
|
-
if (key.length !== KEY_BYTES) {
|
|
74
|
-
throw new Error(`token-crypto: key must be ${KEY_BYTES} bytes`);
|
|
75
|
-
}
|
|
76
|
-
const parts = envelope.split('.');
|
|
77
|
-
if (parts.length !== 4 || parts[0] !== ENVELOPE_VERSION) {
|
|
78
|
-
throw new Error('token-crypto: malformed or unsupported envelope');
|
|
79
|
-
}
|
|
80
|
-
const iv = Buffer.from(parts[1], 'base64');
|
|
81
|
-
const tag = Buffer.from(parts[2], 'base64');
|
|
82
|
-
const ciphertext = Buffer.from(parts[3], 'base64');
|
|
83
|
-
if (iv.length !== IV_BYTES) {
|
|
84
|
-
throw new Error('token-crypto: malformed IV');
|
|
85
|
-
}
|
|
86
|
-
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
87
|
-
decipher.setAuthTag(tag);
|
|
88
|
-
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
89
|
-
return plaintext.toString('utf8');
|
|
90
|
-
}
|
|
91
|
-
//# sourceMappingURL=token-crypto.js.map
|
|
1
|
+
import n from"crypto";const u="borg-mcp/token-store/v1",f="v1",p=12,c=32;function m(e){const r=[e.hostname,e.username,e.platform,u].join("\0");return n.createHash("sha256").update(r).digest()}function l(e,r){if(r.length!==c)throw new Error(`token-crypto: key must be ${c} bytes`);const t=n.randomBytes(p),o=n.createCipheriv("aes-256-gcm",r,t),a=Buffer.concat([o.update(e,"utf8"),o.final()]),s=o.getAuthTag();return[f,t.toString("base64"),s.toString("base64"),a.toString("base64")].join(".")}function E(e,r){if(r.length!==c)throw new Error(`token-crypto: key must be ${c} bytes`);const t=e.split(".");if(t.length!==4||t[0]!==f)throw new Error("token-crypto: malformed or unsupported envelope");const o=Buffer.from(t[1],"base64"),a=Buffer.from(t[2],"base64"),s=Buffer.from(t[3],"base64");if(o.length!==p)throw new Error("token-crypto: malformed IV");const i=n.createDecipheriv("aes-256-gcm",r,o);return i.setAuthTag(a),Buffer.concat([i.update(s),i.final()]).toString("utf8")}export{E as decryptString,m as deriveMachineKey,l as encryptString};
|
package/dist/token-store.js
CHANGED
|
@@ -1,222 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* gh#557 — token storage backends + selection.
|
|
3
|
-
*
|
|
4
|
-
* config.ts exposes the public token API (storeIdToken/getIdToken/...). This
|
|
5
|
-
* module supplies the interchangeable storage engines it sits on top of:
|
|
6
|
-
*
|
|
7
|
-
* - KeychainBackend — OS keychain via @napi-rs/keyring (the default;
|
|
8
|
-
* real platform at-rest encryption).
|
|
9
|
-
* - EncryptedFileBackend — ~/.borg/credentials, all accounts in one
|
|
10
|
-
* AES-256-GCM blob, file 0600 / dir 0700. Engages
|
|
11
|
-
* only when no keychain is available. Obfuscation-
|
|
12
|
-
* grade (see token-crypto.ts).
|
|
13
|
-
* - caller-managed — BORG_TOKEN / BORG_TOKEN_FILE: an externally
|
|
14
|
-
* supplied id_token, used read-only with no store
|
|
15
|
-
* (the caller owns its lifecycle/freshness).
|
|
16
|
-
*
|
|
17
|
-
* Every engine takes its side-effecting dependencies (keyring entry factory,
|
|
18
|
-
* fs, machine key) by injection so the logic is unit-tested without a real
|
|
19
|
-
* keychain or disk.
|
|
20
|
-
*/
|
|
21
|
-
import path from 'path';
|
|
22
|
-
import crypto from 'crypto';
|
|
23
|
-
import { AsyncEntry } from '@napi-rs/keyring';
|
|
24
|
-
import { decryptString, encryptString } from './token-crypto.js';
|
|
25
|
-
const SERVICE_NAME = 'borg-mcp';
|
|
26
|
-
const defaultEntryFactory = (account) => new AsyncEntry(SERVICE_NAME, account);
|
|
27
|
-
/**
|
|
28
|
-
* Build the OS-keychain backend. Preserves config.ts's prior semantics:
|
|
29
|
-
* a missing entry reads as null, and delete is silent on a NoEntry error
|
|
30
|
-
* (idempotent clear) while other errors propagate (fail-loud).
|
|
31
|
-
*/
|
|
32
|
-
export function makeKeychainBackend(entryFactory = defaultEntryFactory) {
|
|
33
|
-
return {
|
|
34
|
-
name: 'keychain',
|
|
35
|
-
async get(account) {
|
|
36
|
-
return (await entryFactory(account).getPassword()) ?? null;
|
|
37
|
-
},
|
|
38
|
-
async set(account, value) {
|
|
39
|
-
await entryFactory(account).setPassword(value);
|
|
40
|
-
},
|
|
41
|
-
async delete(account) {
|
|
42
|
-
try {
|
|
43
|
-
await entryFactory(account).deletePassword();
|
|
44
|
-
}
|
|
45
|
-
catch (err) {
|
|
46
|
-
const msg = String(err?.message ?? '');
|
|
47
|
-
if (/no entry|not found|no matching/i.test(msg))
|
|
48
|
-
return;
|
|
49
|
-
throw err;
|
|
50
|
-
}
|
|
51
|
-
},
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
// gh#570 lock tuning. Token writes are tiny + infrequent, so a short retry
|
|
55
|
-
// cadence + a generous staleness window are right: the staleness window only
|
|
56
|
-
// has to exceed a real write (milliseconds), and a crashed lock-holder is
|
|
57
|
-
// reclaimed after it.
|
|
58
|
-
const LOCK_RETRY_DELAY_MS = 25;
|
|
59
|
-
const LOCK_MAX_WAIT_MS = 2000;
|
|
60
|
-
const LOCK_STALE_MS = 10_000;
|
|
61
|
-
function defaultSleep(ms) {
|
|
62
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
63
|
-
}
|
|
64
|
-
function defaultUniqueSuffix() {
|
|
65
|
-
// PID + random keeps two concurrent writers' temp files distinct so the
|
|
66
|
-
// temp write itself never races. crypto is already a client dependency.
|
|
67
|
-
return `${process.pid}.${crypto.randomBytes(6).toString('hex')}`;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Build the encrypted-file backend. All accounts live in one JSON object
|
|
71
|
-
* encrypted as a single AES-256-GCM envelope at `filePath`.
|
|
72
|
-
*
|
|
73
|
-
* A missing file reads as an empty map. A file that won't decrypt (wrong
|
|
74
|
-
* machine key after a hostname change, truncation, tampering) is ALSO
|
|
75
|
-
* treated as empty: the only consequence is the user re-runs `borg setup`,
|
|
76
|
-
* which is the right fail-safe for credential material — a hard crash on a
|
|
77
|
-
* corrupt dotfile would be worse UX than transparent re-auth.
|
|
78
|
-
*
|
|
79
|
-
* gh#570 — concurrency + atomicity. Multiple `borg` processes (e.g. sibling
|
|
80
|
-
* drone sessions on one host) can share `~/.borg/credentials`. Two fixes:
|
|
81
|
-
* - Anti-lost-update (load-bearing): `set`/`delete` serialize their whole
|
|
82
|
-
* read-modify-write cycle behind an O_EXCL lock file, so concurrent
|
|
83
|
-
* writers no longer each read a stale map and clobber each other.
|
|
84
|
-
* - Anti-corruption: every write goes to a unique temp file then `rename`s
|
|
85
|
-
* into place, so a reader (which is intentionally lock-FREE) always sees a
|
|
86
|
-
* complete old-or-new file, never a torn one.
|
|
87
|
-
*/
|
|
88
|
-
export function makeEncryptedFileBackend(deps) {
|
|
89
|
-
const { filePath, key, fs } = deps;
|
|
90
|
-
const sleep = deps.sleep ?? defaultSleep;
|
|
91
|
-
const now = deps.now ?? Date.now;
|
|
92
|
-
const uniqueSuffix = deps.uniqueSuffix ?? defaultUniqueSuffix;
|
|
93
|
-
const lockPath = `${filePath}.lock`;
|
|
94
|
-
async function readMap() {
|
|
95
|
-
let raw;
|
|
96
|
-
try {
|
|
97
|
-
raw = await fs.readFile(filePath);
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
return {}; // missing file → no stored tokens yet
|
|
101
|
-
}
|
|
102
|
-
try {
|
|
103
|
-
const json = decryptString(raw.trim(), key);
|
|
104
|
-
const parsed = JSON.parse(json);
|
|
105
|
-
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
106
|
-
}
|
|
107
|
-
catch {
|
|
108
|
-
return {}; // undecryptable / corrupt → fail safe to re-auth
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
async function writeMap(map) {
|
|
112
|
-
await fs.mkdir(path.dirname(filePath), 0o700);
|
|
113
|
-
// Atomic write: encrypt → temp → rename. rename preserves the temp's 0600.
|
|
114
|
-
const tmpPath = `${filePath}.${uniqueSuffix()}.tmp`;
|
|
115
|
-
await fs.writeFile(tmpPath, encryptString(JSON.stringify(map), key), 0o600);
|
|
116
|
-
try {
|
|
117
|
-
await fs.rename(tmpPath, filePath);
|
|
118
|
-
}
|
|
119
|
-
catch (err) {
|
|
120
|
-
// rename failed (disk full / permission). Remove the orphaned temp so
|
|
121
|
-
// repeated failures don't accumulate .tmp files, then rethrow so the
|
|
122
|
-
// caller still fails loud. Cleanup is best-effort — a failed unlink must
|
|
123
|
-
// not mask the original error.
|
|
124
|
-
try {
|
|
125
|
-
await fs.removeFile(tmpPath);
|
|
126
|
-
}
|
|
127
|
-
catch {
|
|
128
|
-
/* ignore cleanup failure — the original rename error takes precedence */
|
|
129
|
-
}
|
|
130
|
-
throw err;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Run `fn` while holding the O_EXCL lock, serializing read-modify-write
|
|
135
|
-
* across processes. A lock left by a crashed holder is reclaimed once it is
|
|
136
|
-
* older than LOCK_STALE_MS. If the lock can't be acquired within
|
|
137
|
-
* LOCK_MAX_WAIT_MS, proceed best-effort (steal it): the worst case is the
|
|
138
|
-
* original benign lost-update, never a stuck auth.
|
|
139
|
-
*/
|
|
140
|
-
async function withFileLock(fn) {
|
|
141
|
-
const deadline = now() + LOCK_MAX_WAIT_MS;
|
|
142
|
-
let held = false;
|
|
143
|
-
while (!held) {
|
|
144
|
-
held = await fs.createExclusive(lockPath, `${process.pid}@${now()}`);
|
|
145
|
-
if (held)
|
|
146
|
-
break;
|
|
147
|
-
const age = await fs.fileAgeMs(lockPath);
|
|
148
|
-
if (age !== null && age > LOCK_STALE_MS) {
|
|
149
|
-
await fs.removeFile(lockPath); // reclaim a stale (crashed-holder) lock
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
if (now() >= deadline) {
|
|
153
|
-
await fs.removeFile(lockPath); // last resort: steal + proceed best-effort
|
|
154
|
-
held = await fs.createExclusive(lockPath, `${process.pid}@${now()}`);
|
|
155
|
-
break;
|
|
156
|
-
}
|
|
157
|
-
await sleep(LOCK_RETRY_DELAY_MS);
|
|
158
|
-
}
|
|
159
|
-
try {
|
|
160
|
-
return await fn();
|
|
161
|
-
}
|
|
162
|
-
finally {
|
|
163
|
-
if (held)
|
|
164
|
-
await fs.removeFile(lockPath);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
return {
|
|
168
|
-
name: 'encrypted-file',
|
|
169
|
-
async get(account) {
|
|
170
|
-
// Lock-free: temp+rename guarantees readMap sees a complete file.
|
|
171
|
-
const map = await readMap();
|
|
172
|
-
return Object.prototype.hasOwnProperty.call(map, account) ? map[account] : null;
|
|
173
|
-
},
|
|
174
|
-
set(account, value) {
|
|
175
|
-
return withFileLock(async () => {
|
|
176
|
-
const map = await readMap();
|
|
177
|
-
map[account] = value;
|
|
178
|
-
await writeMap(map);
|
|
179
|
-
});
|
|
180
|
-
},
|
|
181
|
-
delete(account) {
|
|
182
|
-
return withFileLock(async () => {
|
|
183
|
-
const map = await readMap();
|
|
184
|
-
if (Object.prototype.hasOwnProperty.call(map, account)) {
|
|
185
|
-
delete map[account];
|
|
186
|
-
await writeMap(map);
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
},
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Select the persistent backend: a forced choice (BORG_TOKEN_STORE=keychain|file)
|
|
194
|
-
* wins and skips the probe; otherwise probe the keychain and fall back to the
|
|
195
|
-
* encrypted file when it's unavailable.
|
|
196
|
-
*/
|
|
197
|
-
export async function selectTokenBackend(deps) {
|
|
198
|
-
if (deps.forced === 'keychain')
|
|
199
|
-
return deps.makeKeychain();
|
|
200
|
-
if (deps.forced === 'file')
|
|
201
|
-
return deps.makeFile();
|
|
202
|
-
return (await deps.keyringAvailable()) ? deps.makeKeychain() : deps.makeFile();
|
|
203
|
-
}
|
|
204
|
-
/**
|
|
205
|
-
* Resolve an externally-supplied id_token (no storage). BORG_TOKEN takes
|
|
206
|
-
* precedence; otherwise BORG_TOKEN_FILE is read from disk. Returns null when
|
|
207
|
-
* neither is configured. The value is trimmed (env vars and files commonly
|
|
208
|
-
* carry trailing newlines). The caller owns this token's freshness, so it
|
|
209
|
-
* bypasses the keychain AND the expiry check in config.ts.
|
|
210
|
-
*/
|
|
211
|
-
export async function readCallerManagedIdToken(deps) {
|
|
212
|
-
const inline = deps.env.BORG_TOKEN?.trim();
|
|
213
|
-
if (inline)
|
|
214
|
-
return inline;
|
|
215
|
-
const file = deps.env.BORG_TOKEN_FILE?.trim();
|
|
216
|
-
if (file) {
|
|
217
|
-
const contents = await deps.readFile(file);
|
|
218
|
-
return contents.trim();
|
|
219
|
-
}
|
|
220
|
-
return null;
|
|
221
|
-
}
|
|
222
|
-
//# sourceMappingURL=token-store.js.map
|
|
1
|
+
import p from"path";import d from"crypto";import{AsyncEntry as h}from"@napi-rs/keyring";import{decryptString as k,encryptString as g}from"./token-crypto.js";const S="borg-mcp",E=t=>new h(S,t);function K(t=E){return{name:"keychain",async get(e){return await t(e).getPassword()??null},async set(e,o){await t(e).setPassword(o)},async delete(e){try{await t(e).deletePassword()}catch(o){const a=String(o?.message??"");if(/no entry|not found|no matching/i.test(a))return;throw o}}}}const O=25,_=2e3,F=1e4;function x(t){return new Promise(e=>setTimeout(e,t))}function v(){return`${process.pid}.${d.randomBytes(6).toString("hex")}`}function T(t){const{filePath:e,key:o,fs:a}=t,m=t.sleep??x,s=t.now??Date.now,y=t.uniqueSuffix??v,c=`${e}.lock`;async function l(){let r;try{r=await a.readFile(e)}catch{return{}}try{const n=k(r.trim(),o),i=JSON.parse(n);return i&&typeof i=="object"?i:{}}catch{return{}}}async function u(r){await a.mkdir(p.dirname(e),448);const n=`${e}.${y()}.tmp`;await a.writeFile(n,g(JSON.stringify(r),o),384);try{await a.rename(n,e)}catch(i){try{await a.removeFile(n)}catch{}throw i}}async function f(r){const n=s()+_;let i=!1;for(;!i&&(i=await a.createExclusive(c,`${process.pid}@${s()}`),!i);){const w=await a.fileAgeMs(c);if(w!==null&&w>F){await a.removeFile(c);continue}if(s()>=n){await a.removeFile(c),i=await a.createExclusive(c,`${process.pid}@${s()}`);break}await m(O)}try{return await r()}finally{i&&await a.removeFile(c)}}return{name:"encrypted-file",async get(r){const n=await l();return Object.prototype.hasOwnProperty.call(n,r)?n[r]:null},set(r,n){return f(async()=>{const i=await l();i[r]=n,await u(i)})},delete(r){return f(async()=>{const n=await l();Object.prototype.hasOwnProperty.call(n,r)&&(delete n[r],await u(n))})}}}async function b(t){return t.forced==="keychain"?t.makeKeychain():t.forced==="file"?t.makeFile():await t.keyringAvailable()?t.makeKeychain():t.makeFile()}async function L(t){const e=t.env.BORG_TOKEN?.trim();if(e)return e;const o=t.env.BORG_TOKEN_FILE?.trim();return o?(await t.readFile(o)).trim():null}export{T as makeEncryptedFileBackend,K as makeKeychainBackend,L as readCallerManagedIdToken,b as selectTokenBackend};
|
package/dist/types.js
CHANGED
package/dist/version.js
CHANGED
|
@@ -1,78 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Single source of truth for the borgmcp client version — read at runtime
|
|
5
|
-
* from `package.json` relative to `import.meta.url`, NOT hardcoded.
|
|
6
|
-
*
|
|
7
|
-
* Consumers:
|
|
8
|
-
* - `index.ts` — passed into the MCP `Server({ name, version })`
|
|
9
|
-
* constructor so Claude Code's `/mcp` view shows the real version
|
|
10
|
-
* instead of the long-standing hardcoded "0.1.0".
|
|
11
|
-
* - `claude.ts` / `setup.ts` / `regen.ts` / `log-audit.ts` — each
|
|
12
|
-
* binary supports a `--version` flag that prints `borgmcp X.Y.Z`
|
|
13
|
-
* and exits 0 before any side-effecting work begins.
|
|
14
|
-
*
|
|
15
|
-
* Implementation notes:
|
|
16
|
-
* - Uses `readFileSync` on the resolved path relative to this module's
|
|
17
|
-
* `import.meta.url`. The compiled `dist/version.js` sits one level
|
|
18
|
-
* above `package.json` at the package root, so `../package.json` is
|
|
19
|
-
* the relative resolution. This works under both `node dist/...`
|
|
20
|
-
* and `npm run start`; the path resolution is independent of CWD.
|
|
21
|
-
* - Result is cached at module-eval time. The package.json is part of
|
|
22
|
-
* the published tarball and immutable for any given install.
|
|
23
|
-
* - Falls back to `'unknown'` if the read fails (corrupted install,
|
|
24
|
-
* someone deleted package.json, etc.) — never throws, so a fresh
|
|
25
|
-
* `--version` invocation can't kill a CLI launch.
|
|
26
|
-
*/
|
|
27
|
-
import { readFileSync } from 'node:fs';
|
|
28
|
-
import { fileURLToPath } from 'node:url';
|
|
29
|
-
import { dirname, join } from 'node:path';
|
|
30
|
-
function readPackageVersion() {
|
|
31
|
-
try {
|
|
32
|
-
const here = fileURLToPath(import.meta.url);
|
|
33
|
-
const pkgPath = join(dirname(here), '..', 'package.json');
|
|
34
|
-
const raw = readFileSync(pkgPath, 'utf-8');
|
|
35
|
-
const parsed = JSON.parse(raw);
|
|
36
|
-
if (typeof parsed.version === 'string' && parsed.version.length > 0) {
|
|
37
|
-
return parsed.version;
|
|
38
|
-
}
|
|
39
|
-
return 'unknown';
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return 'unknown';
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
const VERSION = readPackageVersion();
|
|
46
|
-
/**
|
|
47
|
-
* Return the installed borgmcp version (the same string as
|
|
48
|
-
* `client/package.json`'s `version` field). Cached at module load.
|
|
49
|
-
*/
|
|
50
|
-
export function getPackageVersion() {
|
|
51
|
-
return VERSION;
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Standard `--version` handler — call near the top of any CLI entry
|
|
55
|
-
* point. If `process.argv` contains `--version` or `-v`, prints
|
|
56
|
-
* `borgmcp X.Y.Z` to stdout and exits 0. Otherwise returns silently
|
|
57
|
-
* so the caller can continue with normal CLI work.
|
|
58
|
-
*
|
|
59
|
-
* Examples:
|
|
60
|
-
* - `borg --version` → "borgmcp 0.6.0"
|
|
61
|
-
* - `borg-mcp -v` → "borgmcp 0.6.0"
|
|
62
|
-
* - `borg assimilate` → continues to assimilation flow
|
|
63
|
-
* - `borg-setup` → continues to interactive OAuth wizard
|
|
64
|
-
*/
|
|
65
|
-
/**
|
|
66
|
-
* gh#285: read the on-disk package.json version fresh (not cached).
|
|
67
|
-
* Used by the regen handler to detect post-upgrade version mismatch.
|
|
68
|
-
*/
|
|
69
|
-
export function getOnDiskVersion() {
|
|
70
|
-
return readPackageVersion();
|
|
71
|
-
}
|
|
72
|
-
export function handleVersionFlag() {
|
|
73
|
-
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
74
|
-
process.stdout.write(`borgmcp ${VERSION}\n`);
|
|
75
|
-
process.exit(0);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
//# sourceMappingURL=version.js.map
|
|
1
|
+
import{readFileSync as i}from"node:fs";import{fileURLToPath as c}from"node:url";import{dirname as a,join as p}from"node:path";function n(){try{const o=c(import.meta.url),t=p(a(o),"..","package.json"),s=i(t,"utf-8"),r=JSON.parse(s);return typeof r.version=="string"&&r.version.length>0?r.version:"unknown"}catch{return"unknown"}}const e=n();function m(){return e}function d(){return n()}function l(){(process.argv.includes("--version")||process.argv.includes("-v"))&&(process.stdout.write(`borgmcp ${e}
|
|
2
|
+
`),process.exit(0))}export{d as getOnDiskVersion,m as getPackageVersion,l as handleVersionFlag};
|
|
@@ -1,173 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Pure git-decision helpers behind an injected `runSync` seam (matching
|
|
5
|
-
* the `AssimilateDeps.runSync` shape), so every branch is unit-testable
|
|
6
|
-
* without a live repo. This module DECIDES + emits git command sequences;
|
|
7
|
-
* it never launches agents and never touches the cube API.
|
|
8
|
-
*
|
|
9
|
-
* Design spec: docs/superpowers/specs/2026-05-29-worktree-lifecycle-design.md
|
|
10
|
-
* Q-resolutions baked in (SPEC-APPROVED 3a80412d):
|
|
11
|
-
* Q1 branch naming — `wt-<suffix>` prefix-stripped, full-basename fallback.
|
|
12
|
-
* Q2 idle-sync — ff-only, clean-gated; never merge/rebase; never over dirty.
|
|
13
|
-
* Q3 post-merge — auto-return to wt-<basename>; ANNOUNCE the prunable
|
|
14
|
-
* merged branch, prune only when explicitly requested.
|
|
15
|
-
* Q4 uniform — no primary-worktree carve-out; main is never a working branch.
|
|
16
|
-
*/
|
|
17
|
-
/**
|
|
18
|
-
* Per-worktree branch name (Q1). Strips the repo basename prefix from the
|
|
19
|
-
* worktree dir basename for readability (`borg-mcp-codex-builder` ->
|
|
20
|
-
* `wt-codex-builder`); falls back to the full dir basename when there is
|
|
21
|
-
* no shared prefix (`myrepo-feature` under repo `otherrepo` ->
|
|
22
|
-
* `wt-myrepo-feature`).
|
|
23
|
-
*/
|
|
24
|
-
export function perWorktreeBranchName(worktreeBasename, repoBasename) {
|
|
25
|
-
const prefix = `${repoBasename}-`;
|
|
26
|
-
const suffix = worktreeBasename.startsWith(prefix)
|
|
27
|
-
? worktreeBasename.slice(prefix.length)
|
|
28
|
-
: worktreeBasename;
|
|
29
|
-
return `wt-${suffix}`;
|
|
30
|
-
}
|
|
31
|
-
/** True iff the working tree is clean (`git status --porcelain` empty). */
|
|
32
|
-
export function isCleanTree(runSync, cwd) {
|
|
33
|
-
const r = runSync('git', ['status', '--porcelain'], cwd);
|
|
34
|
-
return r.status === 0 && r.stdout.trim() === '';
|
|
35
|
-
}
|
|
36
|
-
const LOCAL_CONFIG_RE = /^\.claude\//;
|
|
37
|
-
/**
|
|
38
|
-
* Classify a dirty tree into staged / unstaged / untracked buckets, and
|
|
39
|
-
* flag local-config files separately. The STAGED bucket is load-bearing:
|
|
40
|
-
* the live UNBLOCK case (b15894be) had a *staged* leftover diff that
|
|
41
|
-
* blocked `pull --ff-only`, which an unstaged-only check would miss.
|
|
42
|
-
*/
|
|
43
|
-
export function classifyDirty(runSync, cwd) {
|
|
44
|
-
const r = runSync('git', ['status', '--porcelain'], cwd);
|
|
45
|
-
const out = { staged: [], unstaged: [], untracked: [], localConfig: [] };
|
|
46
|
-
if (r.status !== 0)
|
|
47
|
-
return out;
|
|
48
|
-
for (const line of r.stdout.split('\n')) {
|
|
49
|
-
if (!line.trim())
|
|
50
|
-
continue;
|
|
51
|
-
const path = line.slice(3);
|
|
52
|
-
if (line.startsWith('??')) {
|
|
53
|
-
out.untracked.push(path);
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
const x = line[0]; // staged (index) column
|
|
57
|
-
const y = line[1]; // unstaged (work-tree) column
|
|
58
|
-
if (x !== ' ' && x !== '?')
|
|
59
|
-
out.staged.push(path);
|
|
60
|
-
if (y !== ' ' && y !== '?')
|
|
61
|
-
out.unstaged.push(path);
|
|
62
|
-
}
|
|
63
|
-
if (LOCAL_CONFIG_RE.test(path))
|
|
64
|
-
out.localConfig.push(path);
|
|
65
|
-
}
|
|
66
|
-
return out;
|
|
67
|
-
}
|
|
68
|
-
/** True iff `branch` is an ancestor of `ref` — i.e. a clean fast-forward target. */
|
|
69
|
-
export function isFastForward(runSync, cwd, branch, ref) {
|
|
70
|
-
return runSync('git', ['merge-base', '--is-ancestor', branch, ref], cwd).status === 0;
|
|
71
|
-
}
|
|
72
|
-
/** True iff `branch`'s tip is an ancestor of `ref` — i.e. fully merged into it. */
|
|
73
|
-
export function isMerged(runSync, cwd, branch, ref) {
|
|
74
|
-
return runSync('git', ['merge-base', '--is-ancestor', branch, ref], cwd).status === 0;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Idle-sync the current per-worktree branch to `ref` (Q2). NEVER discards
|
|
78
|
-
* work: dirty -> skipped-dirty (no mutation). Only fast-forwards (no
|
|
79
|
-
* merge/rebase): diverged -> skipped-diverged. The caller fetches first.
|
|
80
|
-
*
|
|
81
|
-
* `already-current` when the branch tip already equals `ref` (the common
|
|
82
|
-
* no-op case on every launch).
|
|
83
|
-
*/
|
|
84
|
-
export function syncWorktree(runSync, cwd, branch, ref) {
|
|
85
|
-
if (!isCleanTree(runSync, cwd)) {
|
|
86
|
-
return {
|
|
87
|
-
action: 'skipped-dirty',
|
|
88
|
-
message: 'uncommitted changes present; sync skipped (nothing discarded)',
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
if (!isFastForward(runSync, cwd, branch, ref)) {
|
|
92
|
-
return {
|
|
93
|
-
action: 'skipped-diverged',
|
|
94
|
-
message: `${branch} has diverged from ${ref}; resolve manually (no auto-merge/rebase)`,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
// Already at ref? merge --ff-only is a no-op but we report it distinctly
|
|
98
|
-
// so callers can stay quiet on the common case.
|
|
99
|
-
const ahead = runSync('git', ['rev-list', '--count', `${branch}..${ref}`], cwd);
|
|
100
|
-
if (ahead.status === 0 && ahead.stdout.trim() === '0') {
|
|
101
|
-
return { action: 'already-current' };
|
|
102
|
-
}
|
|
103
|
-
const ff = runSync('git', ['merge', '--ff-only', ref], cwd);
|
|
104
|
-
if (ff.status !== 0) {
|
|
105
|
-
return { action: 'skipped-diverged', message: 'ff-only merge unexpectedly failed' };
|
|
106
|
-
}
|
|
107
|
-
return { action: 'fast-forwarded' };
|
|
108
|
-
}
|
|
109
|
-
/** True iff a local branch named `branch` already exists. */
|
|
110
|
-
export function localBranchExists(runSync, cwd, branch) {
|
|
111
|
-
return runSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], cwd).status === 0;
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Migration (Q4/Q5/§4.5): bring a detached/stale worktree onto
|
|
115
|
-
* `wt-<basename>` at `ref`. Idempotent: re-running on an already-adopted
|
|
116
|
-
* clean worktree is a lossless reset to `ref`. Never discards:
|
|
117
|
-
* - dirty work tree -> skipped-dirty (surface)
|
|
118
|
-
* - current HEAD unmerged -> blocked-unmerged (surface)
|
|
119
|
-
* - TARGET `branch` exists with commits not on `ref` -> blocked-target-
|
|
120
|
-
* unmerged (surface). This is load-bearing: the switch uses `-C`
|
|
121
|
-
* (force-create/reset), which would ORPHAN commits on a pre-existing
|
|
122
|
-
* `wt-` branch. The HEAD-merged check alone misses this when the
|
|
123
|
-
* target branch != HEAD (e.g. on `main` while a prior `wt-x` holds
|
|
124
|
-
* committed-but-unmerged work). gh#33 CR-v2 blocker 078d1630.
|
|
125
|
-
*/
|
|
126
|
-
export function adoptWorktree(runSync, cwd, branch, ref) {
|
|
127
|
-
if (!isCleanTree(runSync, cwd)) {
|
|
128
|
-
return {
|
|
129
|
-
action: 'skipped-dirty',
|
|
130
|
-
message: 'uncommitted changes present; not switching (nothing discarded)',
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
if (!isMerged(runSync, cwd, 'HEAD', ref)) {
|
|
134
|
-
return {
|
|
135
|
-
action: 'blocked-unmerged',
|
|
136
|
-
message: `current HEAD has commits not on ${ref}; commit/push or set aside before adopting`,
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
// Guard the TARGET branch ref before the `switch -C` force-reset. If
|
|
140
|
-
// `branch` already exists and is NOT an ancestor of `ref`, it carries
|
|
141
|
-
// committed-unmerged work that `-C` would discard — block instead.
|
|
142
|
-
// (Absent, or an ancestor of `ref` = a clean reset target → proceed.)
|
|
143
|
-
if (localBranchExists(runSync, cwd, branch) && !isMerged(runSync, cwd, branch, ref)) {
|
|
144
|
-
return {
|
|
145
|
-
action: 'blocked-target-unmerged',
|
|
146
|
-
message: `branch ${branch} exists with commits not on ${ref}; resolve before adopting (a force-switch would discard them)`,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
runSync('git', ['switch', '-C', branch, ref], cwd);
|
|
150
|
-
return { action: 'adopted' };
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* Post-merge cleanup (Q3): when `feature` is fully merged into `ref`,
|
|
154
|
-
* either ANNOUNCE it as prunable (default) or actually prune it with the
|
|
155
|
-
* safe `git branch -d` (which itself refuses to delete an unmerged
|
|
156
|
-
* branch — defense in depth against a stale local ref). Unmerged ->
|
|
157
|
-
* not-merged (never touched).
|
|
158
|
-
*/
|
|
159
|
-
export function cleanupMerged(runSync, cwd, feature, ref, opts = { prune: false }) {
|
|
160
|
-
if (!isMerged(runSync, cwd, feature, ref)) {
|
|
161
|
-
return { action: 'not-merged', branch: feature };
|
|
162
|
-
}
|
|
163
|
-
if (!opts.prune) {
|
|
164
|
-
return {
|
|
165
|
-
action: 'announced',
|
|
166
|
-
branch: feature,
|
|
167
|
-
message: `${feature} is merged into ${ref} and can be pruned: \`git branch -d ${feature}\` (or re-run with --prune)`,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
runSync('git', ['branch', '-d', feature], cwd);
|
|
171
|
-
return { action: 'pruned', branch: feature };
|
|
172
|
-
}
|
|
173
|
-
//# sourceMappingURL=worktree-lifecycle.js.map
|
|
1
|
+
function f(e,s){const t=`${s}-`;return`wt-${e.startsWith(t)?e.slice(t.length):e}`}function c(e,s){const t=e("git",["status","--porcelain"],s);return t.status===0&&t.stdout.trim()===""}const d=/^\.claude\//;function l(e,s){const t=e("git",["status","--porcelain"],s),i={staged:[],unstaged:[],untracked:[],localConfig:[]};if(t.status!==0)return i;for(const n of t.stdout.split(`
|
|
2
|
+
`)){if(!n.trim())continue;const r=n.slice(3);if(n.startsWith("??"))i.untracked.push(r);else{const a=n[0],u=n[1];a!==" "&&a!=="?"&&i.staged.push(r),u!==" "&&u!=="?"&&i.unstaged.push(r)}d.test(r)&&i.localConfig.push(r)}return i}function g(e,s,t,i){return e("git",["merge-base","--is-ancestor",t,i],s).status===0}function o(e,s,t,i){return e("git",["merge-base","--is-ancestor",t,i],s).status===0}function m(e,s,t,i){if(!c(e,s))return{action:"skipped-dirty",message:"uncommitted changes present; sync skipped (nothing discarded)"};if(!g(e,s,t,i))return{action:"skipped-diverged",message:`${t} has diverged from ${i}; resolve manually (no auto-merge/rebase)`};const n=e("git",["rev-list","--count",`${t}..${i}`],s);return n.status===0&&n.stdout.trim()==="0"?{action:"already-current"}:e("git",["merge","--ff-only",i],s).status!==0?{action:"skipped-diverged",message:"ff-only merge unexpectedly failed"}:{action:"fast-forwarded"}}function p(e,s,t){return e("git",["rev-parse","--verify","--quiet",`refs/heads/${t}`],s).status===0}function h(e,s,t,i){return c(e,s)?o(e,s,"HEAD",i)?p(e,s,t)&&!o(e,s,t,i)?{action:"blocked-target-unmerged",message:`branch ${t} exists with commits not on ${i}; resolve before adopting (a force-switch would discard them)`}:(e("git",["switch","-C",t,i],s),{action:"adopted"}):{action:"blocked-unmerged",message:`current HEAD has commits not on ${i}; commit/push or set aside before adopting`}:{action:"skipped-dirty",message:"uncommitted changes present; not switching (nothing discarded)"}}function x(e,s,t,i,n={prune:!1}){return o(e,s,t,i)?n.prune?(e("git",["branch","-d",t],s),{action:"pruned",branch:t}):{action:"announced",branch:t,message:`${t} is merged into ${i} and can be pruned: \`git branch -d ${t}\` (or re-run with --prune)`}:{action:"not-merged",branch:t}}export{h as adoptWorktree,l as classifyDirty,x as cleanupMerged,c as isCleanTree,g as isFastForward,o as isMerged,p as localBranchExists,f as perWorktreeBranchName,m as syncWorktree};
|