vault-git-sync.mjs
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2
feat(auth): persistent login system + C7 session introspection
Human
minor
⚠ breaking
17 days ago
| 1 | /** |
| 2 | * Optional vault Git sync: when vault.git.auto_commit (and optionally auto_push) is enabled, |
| 3 | * run git add + commit (and push) after vault writes. Used by Hub and optionally CLI. |
| 4 | * Failures are logged but do not throw — so write/approve never fail because of git. |
| 5 | */ |
| 6 | |
| 7 | import fs from 'fs'; |
| 8 | import path from 'path'; |
| 9 | import { spawnSync } from 'child_process'; |
| 10 | import { readConnection } from './github-connection.mjs'; |
| 11 | |
| 12 | /** Git exits 1 with this text when there is nothing new to commit after `git add`. */ |
| 13 | function isBenignNoCommitOutput(text) { |
| 14 | return /nothing to commit|no changes added to commit|working tree clean/i.test(text || ''); |
| 15 | } |
| 16 | |
| 17 | /** Compare two remote URLs (https vs .git, trailing slash). */ |
| 18 | function normalizeRemoteUrl(u) { |
| 19 | if (!u || typeof u !== 'string') return ''; |
| 20 | let s = u.trim().replace(/\/+$/, ''); |
| 21 | if (s.toLowerCase().endsWith('.git')) s = s.slice(0, -4); |
| 22 | return s.toLowerCase(); |
| 23 | } |
| 24 | |
| 25 | /** |
| 26 | * Make `origin` match Hub-configured remote. If origin already pointed elsewhere (e.g. old |
| 27 | * backup repo), pushes were going to the wrong GitHub repository while the UI showed a new URL. |
| 28 | */ |
| 29 | function ensureOriginMatchesConfig(vaultPath, configuredRemote) { |
| 30 | const configured = (configuredRemote || '').trim(); |
| 31 | if (!configured) throw new Error('vault.git.remote is empty.'); |
| 32 | const maxBuffer = 10 * 1024 * 1024; |
| 33 | const cur = spawnSync('git', ['config', '--get', 'remote.origin.url'], { |
| 34 | cwd: vaultPath, |
| 35 | encoding: 'utf8', |
| 36 | maxBuffer, |
| 37 | }); |
| 38 | if (cur.status !== 0 || !cur.stdout.trim()) { |
| 39 | spawnGit(['remote', 'add', 'origin', configured], vaultPath); |
| 40 | return configured; |
| 41 | } |
| 42 | const existing = cur.stdout.trim(); |
| 43 | if (normalizeRemoteUrl(existing) !== normalizeRemoteUrl(configured)) { |
| 44 | spawnGit(['remote', 'set-url', 'origin', configured], vaultPath); |
| 45 | return configured; |
| 46 | } |
| 47 | return existing; |
| 48 | } |
| 49 | |
| 50 | function spawnGit(args, cwd) { |
| 51 | const r = spawnSync('git', args, { |
| 52 | cwd, |
| 53 | encoding: 'utf8', |
| 54 | maxBuffer: 10 * 1024 * 1024, |
| 55 | }); |
| 56 | if (r.error) throw r.error; |
| 57 | if (r.status !== 0) { |
| 58 | const errText = `${r.stderr || ''}\n${r.stdout || ''}`.trim(); |
| 59 | throw new Error(errText || `git ${args[0]} failed (exit ${r.status})`); |
| 60 | } |
| 61 | return r; |
| 62 | } |
| 63 | |
| 64 | function assertGitRepo(vaultPath) { |
| 65 | const gitDir = path.join(vaultPath, '.git'); |
| 66 | if (!fs.existsSync(gitDir)) { |
| 67 | throw new Error( |
| 68 | 'Vault folder is not a Git repository (no .git). From a terminal: cd to your vault path, run `git init`, add/commit once, then use Back up now. See hub/README.md (Git backup).' |
| 69 | ); |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * If config has vault_git.enabled and auto_commit, run git add, commit (and push if auto_push). |
| 75 | * @param {{ vault_path: string, vault_git?: { enabled?: boolean, remote?: string, auto_commit?: boolean, auto_push?: boolean } }} config - Loaded config (vault_path, vault_git) |
| 76 | * @param {{ log?: (msg: string) => void }} options - log defaults to console.error |
| 77 | */ |
| 78 | export function maybeAutoSync(config, options = {}) { |
| 79 | const log = options.log || ((msg) => console.error(msg)); |
| 80 | const vg = config.vault_git; |
| 81 | if (!vg?.enabled || !vg?.auto_commit) return; |
| 82 | |
| 83 | const vaultPath = config.vault_path; |
| 84 | if (!vaultPath) return; |
| 85 | |
| 86 | try { |
| 87 | if (!fs.existsSync(path.join(vaultPath, '.git'))) return; |
| 88 | spawnGit(['add', '-A'], vaultPath); |
| 89 | const autoMsg = 'vault auto-sync ' + new Date().toISOString().slice(0, 19).replace('T', ' '); |
| 90 | const commitR = spawnSync('git', ['commit', '-m', autoMsg], { |
| 91 | cwd: vaultPath, |
| 92 | encoding: 'utf8', |
| 93 | maxBuffer: 10 * 1024 * 1024, |
| 94 | }); |
| 95 | if (commitR.status !== 0) { |
| 96 | const out = `${commitR.stderr || ''}\n${commitR.stdout || ''}`; |
| 97 | if (!isBenignNoCommitOutput(out)) { |
| 98 | log('vault-git-sync: commit failed: ' + (out.trim() || commitR.error?.message || 'git commit')); |
| 99 | } |
| 100 | return; |
| 101 | } |
| 102 | if (vg.auto_push && vg.remote) { |
| 103 | try { |
| 104 | pushWithOptionalToken(vaultPath, vg.remote, config.data_dir); |
| 105 | } catch (e) { |
| 106 | log('vault-git-sync: push failed: ' + (e.message || e.stderr || e)); |
| 107 | } |
| 108 | } |
| 109 | } catch (e) { |
| 110 | log('vault-git-sync: ' + (e.message || e)); |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | function pushWithOptionalToken(vaultPath, remoteUrl, dataDir) { |
| 115 | const maxBuffer = 10 * 1024 * 1024; |
| 116 | const originUrl = ensureOriginMatchesConfig(vaultPath, remoteUrl); |
| 117 | |
| 118 | const conn = dataDir ? readConnection(dataDir) : null; |
| 119 | const token = conn?.access_token; |
| 120 | const cleanOrigin = originUrl; |
| 121 | const useAuth = Boolean(token && originUrl.startsWith('https://')); |
| 122 | |
| 123 | if (useAuth) { |
| 124 | const authUrl = originUrl.replace(/^https:\/\//, 'https://x-access-token:' + token + '@'); |
| 125 | spawnGit(['remote', 'set-url', 'origin', authUrl], vaultPath); |
| 126 | } |
| 127 | |
| 128 | try { |
| 129 | // `-u origin HEAD` sets upstream on first push (plain `git push` fails with "no upstream"). |
| 130 | let push = spawnSync('git', ['push', '-u', 'origin', 'HEAD'], { |
| 131 | cwd: vaultPath, |
| 132 | encoding: 'utf8', |
| 133 | maxBuffer, |
| 134 | }); |
| 135 | if (push.status !== 0) { |
| 136 | const errA = `${push.stderr || ''}\n${push.stdout || ''}`.trim(); |
| 137 | push = spawnSync('git', ['push'], { cwd: vaultPath, encoding: 'utf8', maxBuffer }); |
| 138 | if (push.status !== 0) { |
| 139 | const errB = `${push.stderr || ''}\n${push.stdout || ''}`.trim(); |
| 140 | throw new Error(errB || errA || 'git push failed'); |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // Detect false success: push exited 0 but nothing reachable on origin (wrong remote, etc.). |
| 145 | const verify = spawnSync('git', ['ls-remote', 'origin'], { |
| 146 | cwd: vaultPath, |
| 147 | encoding: 'utf8', |
| 148 | maxBuffer, |
| 149 | }); |
| 150 | if (verify.status !== 0) { |
| 151 | throw new Error( |
| 152 | `${verify.stderr || ''}\n${verify.stdout || ''}`.trim() || 'git ls-remote origin failed after push', |
| 153 | ); |
| 154 | } |
| 155 | if (!(verify.stdout || '').trim()) { |
| 156 | throw new Error( |
| 157 | 'Push reported success but `git ls-remote origin` returned no refs. ' + |
| 158 | 'Check that Settings → Backup → Git remote URL matches the GitHub repo you opened. ' + |
| 159 | 'In the vault folder run: git remote -v', |
| 160 | ); |
| 161 | } |
| 162 | } finally { |
| 163 | if (useAuth) { |
| 164 | try { |
| 165 | spawnGit(['remote', 'set-url', 'origin', cleanOrigin], vaultPath); |
| 166 | } catch (_) { |
| 167 | /* avoid masking push error if restore fails */ |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | /** |
| 174 | * Run full manual vault sync (add, commit, push). Used by CLI and Hub "Back up now". |
| 175 | * If data_dir has a stored GitHub token (Connect GitHub), uses it for push. |
| 176 | * @param {{ vault_path: string, data_dir?: string, vault_git?: { enabled?: boolean, remote?: string } }} config |
| 177 | * @returns {{ ok: true, message: string }} |
| 178 | * @throws Error if not configured or git fails |
| 179 | */ |
| 180 | export function runVaultSync(config) { |
| 181 | const vg = config.vault_git; |
| 182 | if (!vg?.enabled || !vg?.remote) { |
| 183 | throw new Error('vault.git.enabled and vault.git.remote must be set in config.'); |
| 184 | } |
| 185 | const vaultPath = config.vault_path; |
| 186 | if (!vaultPath) throw new Error('vault_path is required.'); |
| 187 | |
| 188 | assertGitRepo(vaultPath); |
| 189 | spawnGit(['add', '-A'], vaultPath); |
| 190 | const msg = 'vault sync ' + new Date().toISOString().slice(0, 10); |
| 191 | const commitR = spawnSync('git', ['commit', '-m', msg], { |
| 192 | cwd: vaultPath, |
| 193 | encoding: 'utf8', |
| 194 | maxBuffer: 10 * 1024 * 1024, |
| 195 | }); |
| 196 | let committed = false; |
| 197 | if (commitR.status === 0) { |
| 198 | committed = true; |
| 199 | } else { |
| 200 | const commitOut = `${commitR.stderr || ''}\n${commitR.stdout || ''}`; |
| 201 | if (!isBenignNoCommitOutput(commitOut)) { |
| 202 | throw new Error( |
| 203 | commitOut.trim() || |
| 204 | commitR.error?.message || |
| 205 | `git commit failed (exit ${commitR.status}). Configure user.name and user.email in this repo if Git asks for identity.`, |
| 206 | ); |
| 207 | } |
| 208 | } |
| 209 | pushWithOptionalToken(vaultPath, vg.remote, config.data_dir); |
| 210 | const sha = safeRevParse(vaultPath); |
| 211 | const message = committed |
| 212 | ? 'Synced' |
| 213 | : 'No new changes to commit; push completed (or remote already up to date).'; |
| 214 | return { ok: true, committed, pushed: true, sha, message }; |
| 215 | } |
| 216 | |
| 217 | function safeRevParse(vaultPath) { |
| 218 | const r = spawnSync('git', ['rev-parse', 'HEAD'], { |
| 219 | cwd: vaultPath, |
| 220 | encoding: 'utf8', |
| 221 | maxBuffer: 1024 * 1024, |
| 222 | }); |
| 223 | if (r.status !== 0) return null; |
| 224 | return (r.stdout || '').trim(); |
| 225 | } |
File History
2 commits
sha256:8d46372e39d2d5a54fd93a8b1c27922fe0d9b22a72197345f1d2c71701cc4ce2
feat(auth): persistent login system + C7 session introspection
Human
minor
⚠
17 days ago
sha256:6a102aafafdfe7e70a24f4e59740200f0ee713ce7915f1b53e9d4ba5ee8b4410
Initial Muse snapshot
Human
49 days ago