@openparachute/vault 0.4.8 → 0.4.9-rc.10

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 (40) hide show
  1. package/core/src/hooks.test.ts +320 -1
  2. package/core/src/hooks.ts +243 -38
  3. package/core/src/mcp.ts +35 -0
  4. package/core/src/portable-md.test.ts +252 -1
  5. package/core/src/portable-md.ts +370 -2
  6. package/core/src/schema.ts +51 -2
  7. package/core/src/store.ts +68 -2
  8. package/package.json +1 -1
  9. package/src/auth.ts +29 -1
  10. package/src/auto-transcribe.test.ts +7 -2
  11. package/src/auto-transcribe.ts +6 -2
  12. package/src/export-watch.test.ts +74 -0
  13. package/src/export-watch.ts +108 -7
  14. package/src/github-device-flow.test.ts +404 -0
  15. package/src/github-device-flow.ts +415 -0
  16. package/src/mcp-http.ts +24 -36
  17. package/src/mcp-tools.ts +286 -2
  18. package/src/mirror-config.test.ts +184 -14
  19. package/src/mirror-config.ts +220 -24
  20. package/src/mirror-credentials.test.ts +450 -0
  21. package/src/mirror-credentials.ts +577 -0
  22. package/src/mirror-deps.ts +42 -1
  23. package/src/mirror-import.test.ts +550 -0
  24. package/src/mirror-import.ts +484 -0
  25. package/src/mirror-manager.test.ts +423 -12
  26. package/src/mirror-manager.ts +579 -62
  27. package/src/mirror-routes.test.ts +966 -10
  28. package/src/mirror-routes.ts +1096 -5
  29. package/src/module-config.ts +11 -5
  30. package/src/routing.test.ts +92 -1
  31. package/src/routing.ts +165 -1
  32. package/src/server.ts +21 -8
  33. package/src/token-store.ts +158 -5
  34. package/src/transcription-worker.ts +9 -4
  35. package/src/triggers.ts +16 -3
  36. package/src/vault.test.ts +380 -1
  37. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  38. package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -0,0 +1,577 @@
1
+ /**
2
+ * Mirror credentials store — UI-configurable git push credentials.
3
+ *
4
+ * After vault#382 (event-driven mirror), `auto_push: true` works only if the
5
+ * operator's shell has the right git credential plumbing wired (SSH key,
6
+ * GH_TOKEN, system credential helper). For self-hosted users that pattern is
7
+ * a non-starter — most have never opened a terminal. This module owns the
8
+ * UI-configurable alternative.
9
+ *
10
+ * Two surfaces:
11
+ * - **GitHub OAuth Device Flow** — the recommended path. Operator opens a
12
+ * modal, vault calls GitHub's device-code endpoint, the operator types
13
+ * a code at github.com/login/device, vault polls until granted, the
14
+ * resulting `gho_*` token is stored and embedded in the mirror's git
15
+ * remote URL so bare `git push` works. Same UX as `gh auth login`.
16
+ * **Why Device Flow, not Web Flow:** Web Flow needs a pre-registered
17
+ * callback URL per OAuth app; self-hosted vaults have unpredictable
18
+ * origins (localhost:1940, random Tailscale FQDN, custom domain). Device
19
+ * Flow needs only a public `client_id` and works against any vault
20
+ * origin without infrastructure.
21
+ * - **Personal Access Token (PAT) fallback** — provider-agnostic. Operator
22
+ * pastes a token + a remote URL with HTTPS auth; vault stores both and
23
+ * embeds them in the mirror's remote URL. Works against GitHub, GitLab,
24
+ * Codeberg, Gitea, anything that accepts an HTTPS token in the URL.
25
+ *
26
+ * **Storage:** `<configDir>/vault/.mirror-credentials.yaml`, perms `0o600`,
27
+ * **not encrypted at rest**. Rationale: encryption-at-rest with the key on
28
+ * the same disk doesn't add real security; OS perms ARE the protection. Same
29
+ * trust model as `~/.git-credentials` (which most operators already use).
30
+ * The file is documented as sensitive; redaction in logs is enforced by
31
+ * `sanitizeCredentials` + a discipline of "never log the raw token."
32
+ *
33
+ * **One credential set per vault.** Multi-credential ("I want repo A pushed
34
+ * with token X, repo B with token Y") isn't supported — vault#382 ships one
35
+ * mirror per vault server today; one credential set per vault matches.
36
+ */
37
+
38
+ import {
39
+ chmodSync,
40
+ existsSync,
41
+ mkdirSync,
42
+ readFileSync,
43
+ renameSync,
44
+ statSync,
45
+ unlinkSync,
46
+ writeFileSync,
47
+ } from "node:fs";
48
+ import { dirname, join } from "node:path";
49
+ import { homedir } from "node:os";
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Types
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /**
56
+ * Which credential surface is currently active. Null when none configured.
57
+ * - `github_oauth` — populated `github_oauth` block.
58
+ * - `pat` — populated `pat` block (Personal Access Token + remote URL).
59
+ */
60
+ export type ActiveMethod = "github_oauth" | "pat" | null;
61
+
62
+ /**
63
+ * GitHub OAuth Device Flow result. Stored verbatim after a successful poll
64
+ * returns `granted`. The `access_token` is what gets embedded in the git
65
+ * remote URL at push time (via `x-access-token:<TOKEN>@github.com/...`).
66
+ */
67
+ export interface GitHubOAuthCredential {
68
+ /** The `gho_*` token returned by GitHub's `/login/oauth/access_token`. */
69
+ access_token: string;
70
+ /** Scope string GitHub granted (typically "repo"). */
71
+ scope: string;
72
+ /** ISO timestamp captured at the moment we saved the token. */
73
+ authorized_at: string;
74
+ /** GitHub login (`@octocat`). */
75
+ user_login: string;
76
+ /** GitHub numeric user id — stable across login renames. */
77
+ user_id: number;
78
+ }
79
+
80
+ /**
81
+ * Personal Access Token fallback. The operator pastes both the token AND
82
+ * the remote URL — vault doesn't try to guess one from the other (GitHub
83
+ * uses `https://x-access-token:<token>@github.com/...`, GitLab uses
84
+ * `https://oauth2:<token>@gitlab.com/...`, etc., and there's no generic
85
+ * rule). The stored URL is what gets set as the mirror's remote.
86
+ */
87
+ export interface PATCredential {
88
+ /** Bearer token (ghp_*, glpat-*, etc.). */
89
+ token: string;
90
+ /**
91
+ * Full HTTPS remote URL with auth embedded, e.g.
92
+ * `https://x-access-token:ghp_abc@github.com/owner/repo.git`. The operator
93
+ * supplies this; we don't synthesize.
94
+ */
95
+ remote_url: string;
96
+ /** Operator-visible label, e.g. "GitHub PAT for backup". */
97
+ label: string;
98
+ }
99
+
100
+ /**
101
+ * The on-disk + on-the-wire shape. One file per vault server (matches the
102
+ * "one mirror per vault server today" invariant from mirror-config.ts).
103
+ */
104
+ export interface MirrorCredentials {
105
+ /**
106
+ * Which credential method is active. Read paths check this; if null the
107
+ * mirror runs with no embedded credentials (bare `git push` inherits
108
+ * the shell — back-compat with pre-PR operators).
109
+ */
110
+ active_method: ActiveMethod;
111
+ github_oauth: GitHubOAuthCredential | null;
112
+ pat: PATCredential | null;
113
+ }
114
+
115
+ /**
116
+ * Redacted view of credentials, safe for logs / API responses. Masks tokens
117
+ * to first-4 + last-4 chars; preserves user metadata so the operator can
118
+ * verify "yes, this is the right account/repo" without re-authenticating.
119
+ */
120
+ export interface MirrorCredentialsPublic {
121
+ active_method: ActiveMethod;
122
+ github_oauth: {
123
+ user_login: string;
124
+ user_id: number;
125
+ scope: string;
126
+ authorized_at: string;
127
+ token_preview: string;
128
+ } | null;
129
+ pat: {
130
+ label: string;
131
+ remote_url: string;
132
+ token_preview: string;
133
+ } | null;
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Path resolution
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /**
141
+ * Path to the per-vault-server credentials file.
142
+ *
143
+ * Note: this is a SERVER-wide credentials file (`<configDir>/vault/.mirror-credentials.yaml`),
144
+ * not a per-vault file. The mirror manager itself is server-wide (one mirror
145
+ * per vault server today) so the credentials follow that scope. When multi-
146
+ * vault mirroring lands (open question 2 in the design doc), this becomes
147
+ * per-vault and gets keyed by name. Today's shape: one file.
148
+ *
149
+ * Path resolution mirrors `config.ts:vaultHomePath()` — re-reads
150
+ * `PARACHUTE_HOME` on every call so test sandboxes (`PARACHUTE_VAULT_HOME`
151
+ * is a vault-internal var that doesn't override here — we use the canonical
152
+ * `PARACHUTE_HOME` that the rest of vault honors).
153
+ */
154
+ export function mirrorCredentialsPath(): string {
155
+ const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
156
+ return join(root, "vault", ".mirror-credentials.yaml");
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Defaults
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /** Empty credentials — what readCredentials returns when the file is absent. */
164
+ export function emptyCredentials(): MirrorCredentials {
165
+ return {
166
+ active_method: null,
167
+ github_oauth: null,
168
+ pat: null,
169
+ };
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // YAML — hand-rolled to match the pattern in mirror-config.ts. No new dep.
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Serialize credentials as YAML. Keeps the file hand-editable for operators
178
+ * who want to rotate a token by `vim`-ing the file.
179
+ *
180
+ * Format:
181
+ *
182
+ * active_method: github_oauth
183
+ * github_oauth:
184
+ * access_token: gho_...
185
+ * scope: repo
186
+ * authorized_at: 2026-05-28T03:14:15.000Z
187
+ * user_login: aaron
188
+ * user_id: 12345
189
+ * pat:
190
+ * token: ghp_...
191
+ * remote_url: https://github.com/aaron/my-vault.git
192
+ * label: "GitHub PAT"
193
+ */
194
+ export function serializeCredentials(creds: MirrorCredentials): string {
195
+ const lines: string[] = [];
196
+ lines.push(`active_method: ${creds.active_method === null ? "null" : creds.active_method}`);
197
+ if (creds.github_oauth) {
198
+ lines.push("github_oauth:");
199
+ lines.push(` access_token: ${quoteIfNeeded(creds.github_oauth.access_token)}`);
200
+ lines.push(` scope: ${quoteIfNeeded(creds.github_oauth.scope)}`);
201
+ lines.push(` authorized_at: ${quoteIfNeeded(creds.github_oauth.authorized_at)}`);
202
+ lines.push(` user_login: ${quoteIfNeeded(creds.github_oauth.user_login)}`);
203
+ lines.push(` user_id: ${creds.github_oauth.user_id}`);
204
+ } else {
205
+ lines.push("github_oauth: null");
206
+ }
207
+ if (creds.pat) {
208
+ lines.push("pat:");
209
+ lines.push(` token: ${quoteIfNeeded(creds.pat.token)}`);
210
+ lines.push(` remote_url: ${quoteIfNeeded(creds.pat.remote_url)}`);
211
+ lines.push(` label: ${quoteIfNeeded(creds.pat.label)}`);
212
+ } else {
213
+ lines.push("pat: null");
214
+ }
215
+ return lines.join("\n") + "\n";
216
+ }
217
+
218
+ /**
219
+ * Quote a YAML scalar when it contains characters that confuse parsers.
220
+ * `gho_*` / `ghp_*` tokens never carry newlines or special chars, but the
221
+ * operator-supplied `label` field has no such guarantee — a label with a
222
+ * literal `\n` would break the parser's per-line section logic. Escape
223
+ * newlines + carriage returns + backslash + quote inside the quoted form.
224
+ * Reviewer-flagged on vault#384.
225
+ */
226
+ function quoteIfNeeded(value: string): string {
227
+ if (/[:#"'\\\n\r]/.test(value) || value.trim() !== value || value.length === 0) {
228
+ return `"${value
229
+ .replace(/\\/g, "\\\\")
230
+ .replace(/"/g, '\\"')
231
+ .replace(/\n/g, "\\n")
232
+ .replace(/\r/g, "\\r")}"`;
233
+ }
234
+ return value;
235
+ }
236
+
237
+ /** Parse a YAML scalar that may be quoted; otherwise return the trimmed value. */
238
+ function parseScalar(raw: string): string {
239
+ const trimmed = raw.trim();
240
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
241
+ return trimmed
242
+ .slice(1, -1)
243
+ .replace(/\\n/g, "\n")
244
+ .replace(/\\r/g, "\r")
245
+ .replace(/\\"/g, '"')
246
+ .replace(/\\\\/g, "\\");
247
+ }
248
+ return trimmed;
249
+ }
250
+
251
+ /**
252
+ * Parse the credentials YAML file. Lenient — unknown fields ignored, missing
253
+ * blocks default to null. Returns `emptyCredentials()` if the file is empty
254
+ * or contains nothing recognized.
255
+ */
256
+ export function parseCredentials(yaml: string): MirrorCredentials {
257
+ const result = emptyCredentials();
258
+ const lines = yaml.split("\n");
259
+ let section: "github_oauth" | "pat" | null = null;
260
+ // Buffer per-section scalars so we can validate as a block before commit.
261
+ let oauth: Partial<GitHubOAuthCredential> = {};
262
+ let pat: Partial<PATCredential> = {};
263
+
264
+ const commitSection = () => {
265
+ if (section === "github_oauth") {
266
+ if (
267
+ oauth.access_token &&
268
+ oauth.scope !== undefined &&
269
+ oauth.authorized_at &&
270
+ oauth.user_login &&
271
+ typeof oauth.user_id === "number"
272
+ ) {
273
+ result.github_oauth = oauth as GitHubOAuthCredential;
274
+ }
275
+ oauth = {};
276
+ } else if (section === "pat") {
277
+ if (pat.token && pat.remote_url && pat.label !== undefined) {
278
+ result.pat = pat as PATCredential;
279
+ }
280
+ pat = {};
281
+ }
282
+ };
283
+
284
+ for (const line of lines) {
285
+ if (line.match(/^\s*$/)) continue;
286
+
287
+ // Top-level scalars / section headers.
288
+ if (line.match(/^\S/)) {
289
+ // Close the previous section before starting a new one.
290
+ commitSection();
291
+ section = null;
292
+ const activeMatch = line.match(/^active_method:\s*(.*)$/);
293
+ if (activeMatch) {
294
+ const v = parseScalar(activeMatch[1]!);
295
+ if (v === "github_oauth" || v === "pat") result.active_method = v;
296
+ else result.active_method = null;
297
+ continue;
298
+ }
299
+ if (line.match(/^github_oauth:\s*null\s*$/)) {
300
+ result.github_oauth = null;
301
+ continue;
302
+ }
303
+ if (line.match(/^pat:\s*null\s*$/)) {
304
+ result.pat = null;
305
+ continue;
306
+ }
307
+ if (line.match(/^github_oauth:\s*$/)) {
308
+ section = "github_oauth";
309
+ continue;
310
+ }
311
+ if (line.match(/^pat:\s*$/)) {
312
+ section = "pat";
313
+ continue;
314
+ }
315
+ continue;
316
+ }
317
+
318
+ // Indented section field.
319
+ const fieldMatch = line.match(/^\s+(\w+):\s*(.*)$/);
320
+ if (!fieldMatch) continue;
321
+ const [, key, rawVal] = fieldMatch;
322
+ if (section === "github_oauth") {
323
+ if (key === "access_token") oauth.access_token = parseScalar(rawVal!);
324
+ else if (key === "scope") oauth.scope = parseScalar(rawVal!);
325
+ else if (key === "authorized_at") oauth.authorized_at = parseScalar(rawVal!);
326
+ else if (key === "user_login") oauth.user_login = parseScalar(rawVal!);
327
+ else if (key === "user_id") {
328
+ const n = Number(parseScalar(rawVal!));
329
+ if (Number.isFinite(n)) oauth.user_id = n;
330
+ }
331
+ } else if (section === "pat") {
332
+ if (key === "token") pat.token = parseScalar(rawVal!);
333
+ else if (key === "remote_url") pat.remote_url = parseScalar(rawVal!);
334
+ else if (key === "label") pat.label = parseScalar(rawVal!);
335
+ }
336
+ }
337
+
338
+ commitSection();
339
+ return result;
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // File I/O
344
+ // ---------------------------------------------------------------------------
345
+
346
+ /**
347
+ * Read credentials from disk. Returns `null` when the file doesn't exist
348
+ * (operator hasn't connected anything yet); throws when the file is present
349
+ * but unreadable (a permission error is a loud configuration problem).
350
+ */
351
+ export function readCredentials(): MirrorCredentials | null {
352
+ const path = mirrorCredentialsPath();
353
+ if (!existsSync(path)) return null;
354
+ const raw = readFileSync(path, "utf8");
355
+ return parseCredentials(raw);
356
+ }
357
+
358
+ /**
359
+ * Persist credentials atomically:
360
+ * 1. Write to `<path>.tmp` with perms 0600 (write + read by owner only).
361
+ * 2. Rename onto the final path (atomic on POSIX; mostly atomic on Windows).
362
+ * 3. Re-apply 0600 perms in case the rename clobbered them (defense in
363
+ * depth — `mv` shouldn't alter perms, but the test surface is wide).
364
+ *
365
+ * Fails loudly: any errno propagates. Callers (the route handler) catch +
366
+ * surface a 500 with the underlying message — quietly losing credentials
367
+ * would be worse than crashing the request.
368
+ */
369
+ export function writeCredentials(creds: MirrorCredentials): void {
370
+ const path = mirrorCredentialsPath();
371
+ const dir = dirname(path);
372
+ // Vault home may not exist yet (tests, fresh installs); create it.
373
+ if (!existsSync(dir)) {
374
+ mkdirSync(dir, { recursive: true });
375
+ }
376
+ const tmp = `${path}.tmp`;
377
+ const serialized = serializeCredentials(creds);
378
+ writeFileSync(tmp, serialized, { mode: 0o600 });
379
+ // Belt-and-braces: writeFileSync's `mode` is only honored on file
380
+ // CREATION. If the temp file already existed (interrupted prior write),
381
+ // the existing perms persist. Re-chmod to be sure.
382
+ chmodSync(tmp, 0o600);
383
+ renameSync(tmp, path);
384
+ // Some filesystems preserve the old file's perms across rename; force
385
+ // 0600 on the final path. No-op on the common case.
386
+ chmodSync(path, 0o600);
387
+
388
+ // Defensive verification — if the perms are wrong on disk, throw so the
389
+ // caller surfaces the misconfiguration to the operator. A world-readable
390
+ // credentials file would silently leak the OAuth token.
391
+ const stat = statSync(path);
392
+ const perms = stat.mode & 0o777;
393
+ if (perms !== 0o600) {
394
+ throw new Error(
395
+ `Mirror credentials file at ${path} has perms ${perms.toString(8)}, expected 0600. Refusing to leave a world-readable token on disk.`,
396
+ );
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Delete the credentials file. Idempotent — missing file is a no-op (the
402
+ * Disconnect UX should succeed even if the file was already removed).
403
+ */
404
+ export function deleteCredentials(): void {
405
+ const path = mirrorCredentialsPath();
406
+ if (existsSync(path)) unlinkSync(path);
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Redaction
411
+ // ---------------------------------------------------------------------------
412
+
413
+ /**
414
+ * Mask a token to first-4 + last-4 chars with a fixed-width middle. Designed
415
+ * to be safe to log + display in the UI's status section (operator can verify
416
+ * "yes, this is the token I authorized" without revealing the secret).
417
+ *
418
+ * Short tokens (< 12 chars) get fully masked rather than partially revealed
419
+ * — anything that short isn't a real production token, but defense in depth
420
+ * costs nothing.
421
+ */
422
+ export function previewToken(token: string): string {
423
+ if (token.length < 12) return "***";
424
+ return `${token.slice(0, 4)}…${token.slice(-4)}`;
425
+ }
426
+
427
+ /**
428
+ * Produce a redacted view of credentials. Use this anywhere credentials
429
+ * leave the trust boundary — logs, HTTP responses, UI state. The full
430
+ * shape lives only in memory + on disk.
431
+ */
432
+ export function sanitizeCredentials(
433
+ creds: MirrorCredentials | null,
434
+ ): MirrorCredentialsPublic {
435
+ if (!creds) {
436
+ return { active_method: null, github_oauth: null, pat: null };
437
+ }
438
+ return {
439
+ active_method: creds.active_method,
440
+ github_oauth: creds.github_oauth
441
+ ? {
442
+ user_login: creds.github_oauth.user_login,
443
+ user_id: creds.github_oauth.user_id,
444
+ scope: creds.github_oauth.scope,
445
+ authorized_at: creds.github_oauth.authorized_at,
446
+ token_preview: previewToken(creds.github_oauth.access_token),
447
+ }
448
+ : null,
449
+ pat: creds.pat
450
+ ? {
451
+ label: creds.pat.label,
452
+ remote_url: redactRemoteUrl(creds.pat.remote_url),
453
+ token_preview: previewToken(creds.pat.token),
454
+ }
455
+ : null,
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Mask the userinfo portion of a remote URL — the operator might have
461
+ * pasted `https://user:token@host/...` and we don't want the raw token
462
+ * leaking via the redacted view. Returns the URL with `user:***@` swapped
463
+ * in for the entire userinfo, leaving the host + path intact.
464
+ */
465
+ export function redactRemoteUrl(url: string): string {
466
+ try {
467
+ const u = new URL(url);
468
+ if (u.username || u.password) {
469
+ u.username = "***";
470
+ u.password = "";
471
+ return u.toString();
472
+ }
473
+ return url;
474
+ } catch {
475
+ // Non-URL string — return a generic placeholder.
476
+ return "***";
477
+ }
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // Git remote URL helpers
482
+ // ---------------------------------------------------------------------------
483
+
484
+ /**
485
+ * Build the HTTPS remote URL with an embedded GitHub OAuth token for `owner/repo`.
486
+ *
487
+ * https://x-access-token:<TOKEN>@github.com/<owner>/<repo>.git
488
+ *
489
+ * The `x-access-token` username convention is GitHub-specific. PATs work
490
+ * through the same shape (GitHub treats `gho_*` and `ghp_*` identically at
491
+ * the credential-helper layer); GitLab / Codeberg use different conventions,
492
+ * so the PAT path stores the full URL operator-supplied rather than
493
+ * synthesizing.
494
+ */
495
+ export function githubAuthedRemoteUrl(
496
+ token: string,
497
+ owner: string,
498
+ repo: string,
499
+ ): string {
500
+ // GitHub repo names allow `.`, `-`, `_`. URL-escape just in case
501
+ // someone has a weird name; the path-component encoder is too aggressive
502
+ // here (it would escape `.`), so we trust GitHub's naming rules.
503
+ return `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
504
+ }
505
+
506
+ /**
507
+ * Set the embedded-credential remote URL on a mirror repo's git config.
508
+ *
509
+ * Idempotent: calling on an already-configured remote replaces the URL
510
+ * (which is what we want — token rotation should "just work" when the
511
+ * stored credentials update). Adds the remote if it doesn't exist; updates
512
+ * it if it does.
513
+ *
514
+ * **Logs are scrubbed.** We never log the URL itself (it carries the
515
+ * token). We log a redacted form via `redactRemoteUrl` instead.
516
+ */
517
+ export async function applyToGitRemote(
518
+ repoDir: string,
519
+ remoteUrl: string,
520
+ ): Promise<{ ok: boolean; error?: string }> {
521
+ // Probe for an existing `origin`. `git remote get-url origin` returns
522
+ // exit 0 if it exists, non-zero otherwise.
523
+ const probe = Bun.spawn(["git", "remote", "get-url", "origin"], {
524
+ cwd: repoDir,
525
+ stdout: "pipe",
526
+ stderr: "pipe",
527
+ });
528
+ const probeCode = await probe.exited;
529
+ const verb = probeCode === 0 ? "set-url" : "add";
530
+ const cmd =
531
+ verb === "set-url"
532
+ ? ["git", "remote", "set-url", "origin", remoteUrl]
533
+ : ["git", "remote", "add", "origin", remoteUrl];
534
+ const proc = Bun.spawn(cmd, {
535
+ cwd: repoDir,
536
+ stdout: "pipe",
537
+ stderr: "pipe",
538
+ });
539
+ const exitCode = await proc.exited;
540
+ if (exitCode !== 0) {
541
+ const stderr = new TextDecoder()
542
+ .decode(await new Response(proc.stderr).arrayBuffer())
543
+ .trim();
544
+ return { ok: false, error: stderr };
545
+ }
546
+ return { ok: true };
547
+ }
548
+
549
+ /**
550
+ * Remove the embedded-credential remote (when credentials are cleared).
551
+ * Idempotent: missing remote is fine — the operator might never have had
552
+ * one set up.
553
+ */
554
+ export async function unsetGitRemote(
555
+ repoDir: string,
556
+ ): Promise<{ ok: boolean; error?: string }> {
557
+ const probe = Bun.spawn(["git", "remote", "get-url", "origin"], {
558
+ cwd: repoDir,
559
+ stdout: "pipe",
560
+ stderr: "pipe",
561
+ });
562
+ const probeCode = await probe.exited;
563
+ if (probeCode !== 0) return { ok: true }; // nothing to unset
564
+ const proc = Bun.spawn(["git", "remote", "remove", "origin"], {
565
+ cwd: repoDir,
566
+ stdout: "pipe",
567
+ stderr: "pipe",
568
+ });
569
+ const exitCode = await proc.exited;
570
+ if (exitCode !== 0) {
571
+ const stderr = new TextDecoder()
572
+ .decode(await new Response(proc.stderr).arrayBuffer())
573
+ .trim();
574
+ return { ok: false, error: stderr };
575
+ }
576
+ return { ok: true };
577
+ }
@@ -7,8 +7,9 @@
7
7
  * vault-store + portable-md.
8
8
  */
9
9
 
10
- import { exportVaultToDir } from "../core/src/portable-md.ts";
10
+ import { exportVaultToDir, hasSchemaContent, pruneOrphans } from "../core/src/portable-md.ts";
11
11
 
12
+ import { defaultHookRegistry } from "../core/src/hooks.ts";
12
13
  import { readGlobalConfig, writeGlobalConfig, readVaultConfig } from "./config.ts";
13
14
  import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
14
15
  import type { MirrorDeps } from "./mirror-manager.ts";
@@ -42,6 +43,41 @@ export function buildMirrorDeps(vaultName: string): MirrorDeps {
42
43
  });
43
44
  return { notes: stats.notes };
44
45
  },
46
+ runPrune: async ({ outDir }) => {
47
+ const store = getVaultStore(vaultName);
48
+ // Build the valid-id sets the prune sweep needs. Single-query
49
+ // walk per dimension; cheap on typical vaults.
50
+ const allNotes = await store.queryNotes({ limit: 1_000_000, sort: "asc" });
51
+ const validNoteIds = new Set(allNotes.map((n) => n.id));
52
+ // Tag names with schema content drive the schema sidecars. Filter
53
+ // through `hasSchemaContent` — a tag whose schema content was wiped
54
+ // via `deleteTagSchema` keeps its tags-table row (bare name), so a
55
+ // map-by-name set would leave the stale sidecar in the mirror
56
+ // indefinitely. Only schema-bearing tags belong in this set.
57
+ // Reviewer-flagged on vault#382 (Critical #1).
58
+ const tagRecords = await store.listTagRecords();
59
+ const validTagNames = new Set(
60
+ tagRecords.filter((t) => hasSchemaContent(t)).map((t) => t.tag),
61
+ );
62
+ // Attachment IDs across all notes (the prune sweep keys on id).
63
+ const validAttachmentIds = new Set<string>();
64
+ for (const note of allNotes) {
65
+ const atts = await store.getAttachments(note.id);
66
+ for (const a of atts) validAttachmentIds.add(a.id);
67
+ }
68
+ const stats = pruneOrphans({
69
+ outDir,
70
+ validNoteIds,
71
+ validTagNames,
72
+ validAttachmentIds,
73
+ });
74
+ return {
75
+ notes_removed: stats.notes_removed,
76
+ sidecars_removed: stats.sidecars_removed,
77
+ schemas_removed: stats.schemas_removed,
78
+ attachment_dirs_removed: stats.attachment_dirs_removed,
79
+ };
80
+ },
45
81
  firstChangedNoteTitle: async (cursor) => {
46
82
  if (!cursor) return "";
47
83
  try {
@@ -62,6 +98,11 @@ export function buildMirrorDeps(vaultName: string): MirrorDeps {
62
98
  global.mirror = config;
63
99
  writeGlobalConfig(global);
64
100
  },
101
+ // Share the process-wide hook registry so mirror's subscriptions land
102
+ // on the same event bus that `BunSqliteStore` dispatches on. This is
103
+ // load-bearing for the event-driven path; without it, the manager
104
+ // falls back to safety-net polling only.
105
+ hooks: defaultHookRegistry,
65
106
  };
66
107
  }
67
108