borgmcp 1.0.5 → 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.
Files changed (157) hide show
  1. package/dist/assimilate-cmd.js +39 -497
  2. package/dist/assimilate-deps.js +3 -177
  3. package/dist/assimilate-welcome.js +2 -24
  4. package/dist/auth-env.js +1 -107
  5. package/dist/auth.js +23 -612
  6. package/dist/claude.js +11 -281
  7. package/dist/cli-help.js +29 -50
  8. package/dist/cli-platform.js +4 -94
  9. package/dist/codex-app-server.js +4 -228
  10. package/dist/codex-app-wake.js +2 -122
  11. package/dist/codex-launch.js +1 -81
  12. package/dist/codex-remote.js +1 -250
  13. package/dist/config-utils.js +3 -385
  14. package/dist/config.js +1 -190
  15. package/dist/console-prefix.js +1 -86
  16. package/dist/cube-name.js +1 -65
  17. package/dist/cubes.js +4 -269
  18. package/dist/debug.js +1 -71
  19. package/dist/device-auth.js +1 -167
  20. package/dist/direct-log.js +1 -11
  21. package/dist/health-beat.js +1 -168
  22. package/dist/inbox-monitor.js +1 -129
  23. package/dist/index.js +26 -1378
  24. package/dist/lifecycle-log-guard.js +2 -93
  25. package/dist/list-roles-render.js +6 -39
  26. package/dist/log-audit.js +3 -186
  27. package/dist/log-stream.js +9 -848
  28. package/dist/name-validator.js +1 -22
  29. package/dist/parse-assimilate-args.js +1 -82
  30. package/dist/postinstall.js +8 -22
  31. package/dist/regen-format.js +11 -329
  32. package/dist/regen.js +5 -83
  33. package/dist/remote-client.js +1 -695
  34. package/dist/role-resolver.js +1 -36
  35. package/dist/role-section.js +8 -208
  36. package/dist/roster-render.js +3 -96
  37. package/dist/setup.js +36 -251
  38. package/dist/shell-escape.js +1 -22
  39. package/dist/spawn.js +10 -29
  40. package/dist/stale-version-check.js +1 -102
  41. package/dist/stream-owner.js +2 -202
  42. package/dist/stream-status.js +3 -211
  43. package/dist/subscription-retry.js +1 -23
  44. package/dist/sync-roles-render.js +3 -118
  45. package/dist/sync.js +22 -286
  46. package/dist/templates.js +120 -563
  47. package/dist/terminal-title.js +1 -68
  48. package/dist/token-crypto.js +1 -91
  49. package/dist/token-store.js +1 -222
  50. package/dist/types.js +0 -5
  51. package/dist/version.js +2 -78
  52. package/dist/worktree-lifecycle.js +2 -173
  53. package/package.json +11 -2
  54. package/dist/assimilate-cmd.d.ts.map +0 -1
  55. package/dist/assimilate-cmd.js.map +0 -1
  56. package/dist/assimilate-deps.d.ts.map +0 -1
  57. package/dist/assimilate-deps.js.map +0 -1
  58. package/dist/assimilate-welcome.d.ts.map +0 -1
  59. package/dist/assimilate-welcome.js.map +0 -1
  60. package/dist/auth-env.d.ts.map +0 -1
  61. package/dist/auth-env.js.map +0 -1
  62. package/dist/auth.d.ts.map +0 -1
  63. package/dist/auth.js.map +0 -1
  64. package/dist/claude.d.ts.map +0 -1
  65. package/dist/claude.js.map +0 -1
  66. package/dist/cli-help.d.ts.map +0 -1
  67. package/dist/cli-help.js.map +0 -1
  68. package/dist/cli-platform.d.ts.map +0 -1
  69. package/dist/cli-platform.js.map +0 -1
  70. package/dist/codex-app-server.d.ts.map +0 -1
  71. package/dist/codex-app-server.js.map +0 -1
  72. package/dist/codex-app-wake.d.ts.map +0 -1
  73. package/dist/codex-app-wake.js.map +0 -1
  74. package/dist/codex-launch.d.ts.map +0 -1
  75. package/dist/codex-launch.js.map +0 -1
  76. package/dist/codex-remote.d.ts.map +0 -1
  77. package/dist/codex-remote.js.map +0 -1
  78. package/dist/config-utils.d.ts.map +0 -1
  79. package/dist/config-utils.js.map +0 -1
  80. package/dist/config.d.ts.map +0 -1
  81. package/dist/config.js.map +0 -1
  82. package/dist/console-prefix.d.ts.map +0 -1
  83. package/dist/console-prefix.js.map +0 -1
  84. package/dist/cube-name.d.ts.map +0 -1
  85. package/dist/cube-name.js.map +0 -1
  86. package/dist/cubes.d.ts.map +0 -1
  87. package/dist/cubes.js.map +0 -1
  88. package/dist/debug.d.ts.map +0 -1
  89. package/dist/debug.js.map +0 -1
  90. package/dist/device-auth.d.ts.map +0 -1
  91. package/dist/device-auth.js.map +0 -1
  92. package/dist/direct-log.d.ts.map +0 -1
  93. package/dist/direct-log.js.map +0 -1
  94. package/dist/health-beat.d.ts.map +0 -1
  95. package/dist/health-beat.js.map +0 -1
  96. package/dist/inbox-monitor.d.ts.map +0 -1
  97. package/dist/inbox-monitor.js.map +0 -1
  98. package/dist/index.d.ts.map +0 -1
  99. package/dist/index.js.map +0 -1
  100. package/dist/lifecycle-log-guard.d.ts.map +0 -1
  101. package/dist/lifecycle-log-guard.js.map +0 -1
  102. package/dist/list-roles-render.d.ts.map +0 -1
  103. package/dist/list-roles-render.js.map +0 -1
  104. package/dist/log-audit.d.ts.map +0 -1
  105. package/dist/log-audit.js.map +0 -1
  106. package/dist/log-stream.d.ts.map +0 -1
  107. package/dist/log-stream.js.map +0 -1
  108. package/dist/name-validator.d.ts.map +0 -1
  109. package/dist/name-validator.js.map +0 -1
  110. package/dist/parse-assimilate-args.d.ts.map +0 -1
  111. package/dist/parse-assimilate-args.js.map +0 -1
  112. package/dist/postinstall.d.ts.map +0 -1
  113. package/dist/postinstall.js.map +0 -1
  114. package/dist/regen-format.d.ts.map +0 -1
  115. package/dist/regen-format.js.map +0 -1
  116. package/dist/regen.d.ts.map +0 -1
  117. package/dist/regen.js.map +0 -1
  118. package/dist/remote-client.d.ts.map +0 -1
  119. package/dist/remote-client.js.map +0 -1
  120. package/dist/role-resolver.d.ts.map +0 -1
  121. package/dist/role-resolver.js.map +0 -1
  122. package/dist/role-section.d.ts.map +0 -1
  123. package/dist/role-section.js.map +0 -1
  124. package/dist/roster-render.d.ts.map +0 -1
  125. package/dist/roster-render.js.map +0 -1
  126. package/dist/setup.d.ts.map +0 -1
  127. package/dist/setup.js.map +0 -1
  128. package/dist/shell-escape.d.ts.map +0 -1
  129. package/dist/shell-escape.js.map +0 -1
  130. package/dist/spawn.d.ts.map +0 -1
  131. package/dist/spawn.js.map +0 -1
  132. package/dist/stale-version-check.d.ts.map +0 -1
  133. package/dist/stale-version-check.js.map +0 -1
  134. package/dist/stream-owner.d.ts.map +0 -1
  135. package/dist/stream-owner.js.map +0 -1
  136. package/dist/stream-status.d.ts.map +0 -1
  137. package/dist/stream-status.js.map +0 -1
  138. package/dist/subscription-retry.d.ts.map +0 -1
  139. package/dist/subscription-retry.js.map +0 -1
  140. package/dist/sync-roles-render.d.ts.map +0 -1
  141. package/dist/sync-roles-render.js.map +0 -1
  142. package/dist/sync.d.ts.map +0 -1
  143. package/dist/sync.js.map +0 -1
  144. package/dist/templates.d.ts.map +0 -1
  145. package/dist/templates.js.map +0 -1
  146. package/dist/terminal-title.d.ts.map +0 -1
  147. package/dist/terminal-title.js.map +0 -1
  148. package/dist/token-crypto.d.ts.map +0 -1
  149. package/dist/token-crypto.js.map +0 -1
  150. package/dist/token-store.d.ts.map +0 -1
  151. package/dist/token-store.js.map +0 -1
  152. package/dist/types.d.ts.map +0 -1
  153. package/dist/types.js.map +0 -1
  154. package/dist/version.d.ts.map +0 -1
  155. package/dist/version.js.map +0 -1
  156. package/dist/worktree-lifecycle.d.ts.map +0 -1
  157. package/dist/worktree-lifecycle.js.map +0 -1
@@ -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};
@@ -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};
@@ -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
@@ -1,5 +0,0 @@
1
- /**
2
- * Type definitions for Borg MCP Client
3
- */
4
- export {};
5
- //# sourceMappingURL=types.js.map
package/dist/version.js CHANGED
@@ -1,78 +1,2 @@
1
- /**
2
- * Runtime version reader.
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
- * gh#33 worktree lifecycle as product behavior.
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};