@openparachute/vault 0.4.8 → 0.4.9-rc.11
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/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
package/src/mirror-manager.ts
CHANGED
|
@@ -1,37 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mirror lifecycle manager — boot-time bootstrap +
|
|
2
|
+
* Mirror lifecycle manager — boot-time bootstrap + event-driven exports.
|
|
3
3
|
*
|
|
4
4
|
* This is the persistent counterpart to vault#346's CLI watch+commit mode.
|
|
5
|
+
* Post-event-driven shift (vault#382, this PR), the manager replaces the
|
|
6
|
+
* original `setInterval` polling loop with in-process hook subscriptions.
|
|
7
|
+
* Every note / tag / attachment mutation fires an event; the manager
|
|
8
|
+
* debounces them (~500ms) into a single export pass. A background
|
|
9
|
+
* safety-net poll runs once per `safety_net_seconds` (default 1h) to
|
|
10
|
+
* catch anything the event path missed — direct SQL writes, dropped
|
|
11
|
+
* hook dispatches, restart gaps.
|
|
12
|
+
*
|
|
5
13
|
* Responsibilities:
|
|
6
14
|
*
|
|
7
15
|
* - On vault server boot: read mirror config, resolve mirror path,
|
|
8
16
|
* bootstrap (mkdir + git init + initial commit) when internal + new,
|
|
9
17
|
* trigger an initial export to bring the mirror to current state,
|
|
10
|
-
* and — if `
|
|
18
|
+
* and — if `sync_mode: events` — subscribe to hooks + arm the
|
|
19
|
+
* safety-net poll.
|
|
11
20
|
*
|
|
12
21
|
* - On config change (via `PUT /admin/mirror` or operator-triggered
|
|
13
|
-
* reload):
|
|
14
|
-
* with the new shape.
|
|
22
|
+
* reload): tear down all subscriptions + timers, re-resolve,
|
|
23
|
+
* restart with the new shape.
|
|
24
|
+
*
|
|
25
|
+
* - On vault server shutdown: unsubscribe, cancel any pending
|
|
26
|
+
* debounce timer, cancel safety-net poll, let an in-flight export
|
|
27
|
+
* finish via the soft settle window.
|
|
15
28
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
29
|
+
* One `MirrorManager` instance PER VAULT (vault#400). The per-vault
|
|
30
|
+
* registry (`mirror-registry.ts`) holds them keyed by vault name; boot
|
|
31
|
+
* stands up a manager for each vault whose config is enabled, and the
|
|
32
|
+
* routes resolve a manager by the URL's vault name. Tests instantiate
|
|
33
|
+
* `MirrorManager` directly with fake deps to exercise lifecycle
|
|
34
|
+
* transitions without spawning a full vault server.
|
|
18
35
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* vault server.
|
|
36
|
+
* Each manager is bound to its vault via `deps.vaultName` — its config
|
|
37
|
+
* read/write, credential reads, and resolved mirror path are all
|
|
38
|
+
* scoped to that one vault, so vault A's mirror never touches vault B's.
|
|
23
39
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* in
|
|
40
|
+
* ## Race-condition contract
|
|
41
|
+
*
|
|
42
|
+
* - **Burst collapse** — two events within DEBOUNCE_MS produce one
|
|
43
|
+
* export pass. The first event arms the timer; subsequent events
|
|
44
|
+
* re-arm it.
|
|
45
|
+
* - **Event during in-flight export** — flag `dirtyDuringFlush`; after
|
|
46
|
+
* the in-flight pass completes, run another. No second concurrent
|
|
47
|
+
* pass; no missed events.
|
|
48
|
+
* - **Stop() during in-flight** — like the legacy soft-settle window,
|
|
49
|
+
* give the export ~250ms to finish before returning. Subscriptions
|
|
50
|
+
* come down BEFORE the settle wait so no further events queue.
|
|
51
|
+
* - **Safety net during event-driven path** — the poll's tick first
|
|
52
|
+
* checks the dirty bit. If clear, it runs a full sweep anyway (catches
|
|
53
|
+
* missed events). If set, it skips — the debounce timer's already
|
|
54
|
+
* coming.
|
|
30
55
|
*/
|
|
31
56
|
|
|
32
57
|
import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
33
58
|
|
|
34
59
|
import {
|
|
60
|
+
DEFAULT_SAFETY_NET_SECONDS,
|
|
35
61
|
defaultMirrorConfig,
|
|
36
62
|
resolveMirrorPath,
|
|
37
63
|
type MirrorConfig,
|
|
@@ -39,10 +65,25 @@ import {
|
|
|
39
65
|
import {
|
|
40
66
|
gitAddAll,
|
|
41
67
|
gitCommit,
|
|
68
|
+
gitPush,
|
|
42
69
|
isGitRepo,
|
|
70
|
+
redactToken,
|
|
43
71
|
runGitCommitCycle,
|
|
44
72
|
} from "./export-watch.ts";
|
|
45
73
|
import { vaultDir } from "./config.ts";
|
|
74
|
+
import {
|
|
75
|
+
applyToGitRemote,
|
|
76
|
+
readCredentials,
|
|
77
|
+
} from "./mirror-credentials.ts";
|
|
78
|
+
import type { HookRegistry } from "../core/src/hooks.ts";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Debounce window between an event arrival and the export pass it
|
|
82
|
+
* triggers. 500ms is the sweet spot: long enough to collapse a burst
|
|
83
|
+
* (typing in a frontend, a batch import) into one export, short enough
|
|
84
|
+
* that the mirror feels live during normal editing.
|
|
85
|
+
*/
|
|
86
|
+
const DEBOUNCE_MS = 500;
|
|
46
87
|
|
|
47
88
|
// ---------------------------------------------------------------------------
|
|
48
89
|
// Types
|
|
@@ -56,11 +97,18 @@ import { vaultDir } from "./config.ts";
|
|
|
56
97
|
export interface MirrorStatus {
|
|
57
98
|
/** True iff `mirror.enabled` is true AND bootstrap succeeded. */
|
|
58
99
|
enabled: boolean;
|
|
59
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* True iff hook subscriptions are currently active.
|
|
102
|
+
*
|
|
103
|
+
* Pre-event-driven this was named after the polling loop; we retain
|
|
104
|
+
* the name for SPA-shape stability — the SPA's "Watch loop" label
|
|
105
|
+
* now reflects "events subscribed + safety-net armed". The semantics
|
|
106
|
+
* shifted, not the API.
|
|
107
|
+
*/
|
|
60
108
|
watch_running: boolean;
|
|
61
109
|
/** Resolved mirror path on disk, or null if disabled / unresolved. */
|
|
62
110
|
mirror_path: string | null;
|
|
63
|
-
/** ISO timestamp of the most recent export pass (initial or
|
|
111
|
+
/** ISO timestamp of the most recent export pass (initial or event-driven or safety-net). */
|
|
64
112
|
last_export_at: string | null;
|
|
65
113
|
/** Notes touched by the most recent export pass. */
|
|
66
114
|
last_export_notes_count: number | null;
|
|
@@ -68,6 +116,28 @@ export interface MirrorStatus {
|
|
|
68
116
|
last_commit_sha: string | null;
|
|
69
117
|
/** Last error message (if any). Cleared on the next successful pass. */
|
|
70
118
|
last_error: string | null;
|
|
119
|
+
/**
|
|
120
|
+
* Cut 5: push observability — surfaced via GET /admin/mirror so the
|
|
121
|
+
* operator (and the SPA's Status card) can see whether pushes are
|
|
122
|
+
* actually landing without grepping `[git-commit]` log lines.
|
|
123
|
+
*
|
|
124
|
+
* - `last_push_at` — ISO timestamp of the most recent SUCCESSFUL push.
|
|
125
|
+
* Null until the first successful push.
|
|
126
|
+
* - `last_push_sha` — sha that landed on the remote at `last_push_at`.
|
|
127
|
+
* Null when last_push_at is null.
|
|
128
|
+
* - `last_push_error` — message from the most recent FAILED push.
|
|
129
|
+
* Cleared (back to null) on the next successful push. Tokens are
|
|
130
|
+
* redacted before this field is set (push paths use the
|
|
131
|
+
* `gho_`/`ghp_`/userinfo regex to scrub).
|
|
132
|
+
* - `commits_unpushed` — count of local commits ahead of upstream
|
|
133
|
+
* (`git rev-list --count @{u}..HEAD`). Null when no upstream tracking
|
|
134
|
+
* exists yet (first push hasn't fired). 0 when the mirror is fully
|
|
135
|
+
* synced; positive when commits are queued.
|
|
136
|
+
*/
|
|
137
|
+
last_push_at: string | null;
|
|
138
|
+
last_push_sha: string | null;
|
|
139
|
+
last_push_error: string | null;
|
|
140
|
+
commits_unpushed: number | null;
|
|
71
141
|
}
|
|
72
142
|
|
|
73
143
|
/**
|
|
@@ -88,6 +158,18 @@ export interface MirrorDeps {
|
|
|
88
158
|
outDir: string;
|
|
89
159
|
sinceCursor?: string;
|
|
90
160
|
}) => Promise<{ notes: number }>;
|
|
161
|
+
/**
|
|
162
|
+
* Run an orphan-prune sweep against the mirror dir. Returns counts so
|
|
163
|
+
* the manager can log + surface them in status. Optional — when
|
|
164
|
+
* undefined, the manager skips the prune step (test seam; production
|
|
165
|
+
* always wires it through `mirror-deps.ts`).
|
|
166
|
+
*/
|
|
167
|
+
runPrune?: (opts: { outDir: string }) => Promise<{
|
|
168
|
+
notes_removed: number;
|
|
169
|
+
sidecars_removed: number;
|
|
170
|
+
schemas_removed: number;
|
|
171
|
+
attachment_dirs_removed: number;
|
|
172
|
+
}>;
|
|
91
173
|
/**
|
|
92
174
|
* Resolve the first-changed-note title since `cursor`, for the
|
|
93
175
|
* `{{first_note_title}}` commit-template variable. Best-effort — empty
|
|
@@ -101,6 +183,14 @@ export interface MirrorDeps {
|
|
|
101
183
|
* via the standard writer — used by `PUT /admin/mirror`.
|
|
102
184
|
*/
|
|
103
185
|
writeMirrorConfig: (config: MirrorConfig) => void;
|
|
186
|
+
/**
|
|
187
|
+
* Shared in-process hook registry. The manager calls `.onNote()` /
|
|
188
|
+
* `.onTag()` / `.onAttachment()` on this registry to drive
|
|
189
|
+
* event-driven exports. Optional — when undefined the manager runs
|
|
190
|
+
* with only the safety-net poll (back-compat shape for tests that
|
|
191
|
+
* predate the event-driven path).
|
|
192
|
+
*/
|
|
193
|
+
hooks?: HookRegistry;
|
|
104
194
|
}
|
|
105
195
|
|
|
106
196
|
// ---------------------------------------------------------------------------
|
|
@@ -221,19 +311,64 @@ export async function bootstrapInternalMirror(
|
|
|
221
311
|
// Manager
|
|
222
312
|
// ---------------------------------------------------------------------------
|
|
223
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Cut 5: count local commits ahead of upstream tracking. Implementation:
|
|
316
|
+
* `git rev-list --count @{u}..HEAD` returns N when N commits are ahead,
|
|
317
|
+
* 0 when synced, and exit-nonzero when no upstream tracking exists
|
|
318
|
+
* (the branch has never been pushed with `-u`). The non-zero case maps
|
|
319
|
+
* to `null` — distinct from "0 ahead" so the SPA can render "no remote
|
|
320
|
+
* tracking yet" vs "fully synced" differently if desired.
|
|
321
|
+
*/
|
|
322
|
+
async function readCommitsUnpushed(repoDir: string): Promise<number | null> {
|
|
323
|
+
const proc = Bun.spawn(
|
|
324
|
+
["git", "rev-list", "--count", "@{u}..HEAD"],
|
|
325
|
+
{
|
|
326
|
+
cwd: repoDir,
|
|
327
|
+
stdout: "pipe",
|
|
328
|
+
stderr: "pipe",
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
const exitCode = await proc.exited;
|
|
332
|
+
if (exitCode !== 0) return null; // no upstream configured yet
|
|
333
|
+
const out = new TextDecoder()
|
|
334
|
+
.decode(await new Response(proc.stdout).arrayBuffer())
|
|
335
|
+
.trim();
|
|
336
|
+
const n = Number(out);
|
|
337
|
+
return Number.isFinite(n) ? n : null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Read the current `origin` URL from a git repo's config, or null when no
|
|
342
|
+
* origin is set. Module-local helper for the credential-application path.
|
|
343
|
+
*/
|
|
344
|
+
async function readCurrentOrigin(repoDir: string): Promise<string | null> {
|
|
345
|
+
const proc = Bun.spawn(["git", "remote", "get-url", "origin"], {
|
|
346
|
+
cwd: repoDir,
|
|
347
|
+
stdout: "pipe",
|
|
348
|
+
stderr: "pipe",
|
|
349
|
+
});
|
|
350
|
+
const exitCode = await proc.exited;
|
|
351
|
+
if (exitCode !== 0) return null;
|
|
352
|
+
const url = new TextDecoder()
|
|
353
|
+
.decode(await new Response(proc.stdout).arrayBuffer())
|
|
354
|
+
.trim();
|
|
355
|
+
return url.length > 0 ? url : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
224
358
|
/**
|
|
225
359
|
* Singleton lifecycle controller. Holds the active mirror config, the
|
|
226
|
-
* resolved path,
|
|
360
|
+
* resolved path, hook subscriptions (when sync_mode=events), the
|
|
361
|
+
* safety-net poll timer, the debounce timer, and the rolling status.
|
|
227
362
|
*
|
|
228
363
|
* State transitions:
|
|
229
364
|
*
|
|
230
|
-
* constructed → start() → [enabled? bootstrap+initial-export+
|
|
365
|
+
* constructed → start() → [enabled? bootstrap + initial-export +
|
|
366
|
+
* subscribe-to-hooks + arm-safety-net?]
|
|
231
367
|
* ↓ ↓
|
|
232
|
-
* stop() reload() —
|
|
368
|
+
* stop() reload() — tear down everything, re-evaluate
|
|
233
369
|
*
|
|
234
|
-
* Re-entrancy:
|
|
235
|
-
*
|
|
236
|
-
* don't pile up.
|
|
370
|
+
* Re-entrancy: in-flight + dirtyDuringFlush guards collapse concurrent
|
|
371
|
+
* triggers into one pass + one follow-up (never two parallel exports).
|
|
237
372
|
*/
|
|
238
373
|
export class MirrorManager {
|
|
239
374
|
private deps: MirrorDeps;
|
|
@@ -245,10 +380,22 @@ export class MirrorManager {
|
|
|
245
380
|
last_export_notes_count: null,
|
|
246
381
|
last_commit_sha: null,
|
|
247
382
|
last_error: null,
|
|
383
|
+
last_push_at: null,
|
|
384
|
+
last_push_sha: null,
|
|
385
|
+
last_push_error: null,
|
|
386
|
+
commits_unpushed: null,
|
|
248
387
|
};
|
|
249
|
-
|
|
388
|
+
/** Safety-net poll timer (interval). Null while not armed. */
|
|
389
|
+
private safetyNetTimer: ReturnType<typeof setInterval> | null = null;
|
|
390
|
+
/** Debounce timer (single-shot setTimeout). Null when no events pending. */
|
|
391
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
392
|
+
/** Unsubscribe handles returned by `hooks.on*()`. */
|
|
393
|
+
private unsubscribes: Array<() => void> = [];
|
|
250
394
|
private stopping = false;
|
|
395
|
+
/** A flush is currently running. Other triggers should not start a new one. */
|
|
251
396
|
private inFlight = false;
|
|
397
|
+
/** Set when an event/safety-net tick arrives during an in-flight flush. */
|
|
398
|
+
private dirtyDuringFlush = false;
|
|
252
399
|
/** Most recent export cursor — passed as `--since` to the next pass. */
|
|
253
400
|
private cursor: string | undefined = undefined;
|
|
254
401
|
private currentConfig: MirrorConfig = defaultMirrorConfig();
|
|
@@ -262,6 +409,15 @@ export class MirrorManager {
|
|
|
262
409
|
this.deps = deps;
|
|
263
410
|
}
|
|
264
411
|
|
|
412
|
+
/**
|
|
413
|
+
* The vault this mirror manager belongs to. Credential reads/writes are
|
|
414
|
+
* per-vault (vault#399); route handlers thread this into the credential
|
|
415
|
+
* functions so they touch the right vault's `.mirror-credentials.yaml`.
|
|
416
|
+
*/
|
|
417
|
+
getVaultName(): string {
|
|
418
|
+
return this.deps.vaultName;
|
|
419
|
+
}
|
|
420
|
+
|
|
265
421
|
/**
|
|
266
422
|
* Read the current config snapshot. Returns a copy so callers can't
|
|
267
423
|
* accidentally mutate the manager's internal state.
|
|
@@ -270,6 +426,30 @@ export class MirrorManager {
|
|
|
270
426
|
return { ...this.currentConfig };
|
|
271
427
|
}
|
|
272
428
|
|
|
429
|
+
/**
|
|
430
|
+
* The config the operator + SPA should SEE for this vault — distinct from
|
|
431
|
+
* the live in-memory `getConfig()` only before the manager has started.
|
|
432
|
+
*
|
|
433
|
+
* Why this exists (vault#400): routes resolve a per-vault manager via the
|
|
434
|
+
* registry, which can LAZILY build a manager for a vault that has config
|
|
435
|
+
* on disk but no running instance yet (e.g. a non-default vault the
|
|
436
|
+
* operator configured, or a runtime-enabled vault between PUT and the
|
|
437
|
+
* reload's start). A freshly-constructed-but-never-started manager has
|
|
438
|
+
* `currentConfig === defaultMirrorConfig()` (disabled), which would make
|
|
439
|
+
* `GET /vault/<name>/.parachute/mirror` show "disabled" for a vault whose
|
|
440
|
+
* file says `enabled: true`. Reading the persisted config when we haven't
|
|
441
|
+
* started yet returns the truth for that vault. Once `start()` has run,
|
|
442
|
+
* `currentConfig` is authoritative (it reflects the same persisted config
|
|
443
|
+
* plus any bootstrap outcome).
|
|
444
|
+
*/
|
|
445
|
+
getEffectiveConfig(): MirrorConfig {
|
|
446
|
+
if (this.startCount === 0) {
|
|
447
|
+
const persisted = this.deps.readMirrorConfig();
|
|
448
|
+
if (persisted) return { ...persisted };
|
|
449
|
+
}
|
|
450
|
+
return { ...this.currentConfig };
|
|
451
|
+
}
|
|
452
|
+
|
|
273
453
|
/**
|
|
274
454
|
* Get the current status snapshot. Returns a copy for the same reason as
|
|
275
455
|
* `getConfig`.
|
|
@@ -302,6 +482,10 @@ export class MirrorManager {
|
|
|
302
482
|
last_export_notes_count: null,
|
|
303
483
|
last_commit_sha: null,
|
|
304
484
|
last_error: null,
|
|
485
|
+
last_push_at: null,
|
|
486
|
+
last_push_sha: null,
|
|
487
|
+
last_push_error: null,
|
|
488
|
+
commits_unpushed: null,
|
|
305
489
|
};
|
|
306
490
|
return this.getStatus();
|
|
307
491
|
}
|
|
@@ -352,31 +536,45 @@ export class MirrorManager {
|
|
|
352
536
|
this.status.enabled = true;
|
|
353
537
|
this.status.last_error = null;
|
|
354
538
|
|
|
539
|
+
// Apply UI-configured credentials to the mirror's git remote, if any.
|
|
540
|
+
// The PAT path stores the full authed URL; we set it as `origin`.
|
|
541
|
+
// The GitHub OAuth path only sets `origin` when the operator has
|
|
542
|
+
// picked a repo (handleAuthGithubSelectRepo writes it then); on a
|
|
543
|
+
// restart with credentials present but no repo picked yet, this is
|
|
544
|
+
// a no-op. Failures are non-fatal — the operator's mirror still
|
|
545
|
+
// exports; they get the original "push needs credentials" feedback
|
|
546
|
+
// when auto_push fires.
|
|
547
|
+
await this.applyCredentialsToRemote(path);
|
|
548
|
+
|
|
355
549
|
// Initial export — full pass (no cursor) so the mirror starts
|
|
356
550
|
// byte-equivalent to current vault state, regardless of when the
|
|
357
|
-
// previous mirror was last refreshed.
|
|
551
|
+
// previous mirror was last refreshed. Also prunes orphans because
|
|
552
|
+
// the initial pass writes the entire vault; anything left over
|
|
553
|
+
// from a prior mirror run that doesn't match a current note is by
|
|
554
|
+
// definition orphaned.
|
|
358
555
|
try {
|
|
359
|
-
await this.runOneCycle({ isInitial: true });
|
|
556
|
+
await this.runOneCycle({ isInitial: true, prune: true });
|
|
360
557
|
} catch (err) {
|
|
361
558
|
const msg = (err as Error).message ?? String(err);
|
|
362
559
|
this.status.last_error = `initial export failed: ${msg}`;
|
|
363
560
|
console.warn(`[mirror] ${this.status.last_error}`);
|
|
364
561
|
// Don't disable the manager — operator may want to retry without
|
|
365
|
-
// restarting the server. Keep status.enabled true so the
|
|
366
|
-
// loop attempts again if armed; the next successful pass
|
|
367
|
-
// last_error.
|
|
562
|
+
// restarting the server. Keep status.enabled true so the safety-
|
|
563
|
+
// net loop attempts again if armed; the next successful pass
|
|
564
|
+
// clears last_error.
|
|
368
565
|
}
|
|
369
566
|
|
|
370
|
-
if (config.
|
|
371
|
-
this.
|
|
567
|
+
if (config.sync_mode === "events") {
|
|
568
|
+
this.subscribeToHooks();
|
|
569
|
+
this.armSafetyNetTimer();
|
|
372
570
|
this.status.watch_running = true;
|
|
373
571
|
console.log(
|
|
374
|
-
`[mirror] enabled (location: ${config.location},
|
|
572
|
+
`[mirror] enabled (location: ${config.location}, sync_mode: events) — initial export complete, hooks subscribed, safety-net every ${config.safety_net_seconds}s`,
|
|
375
573
|
);
|
|
376
574
|
} else {
|
|
377
575
|
this.status.watch_running = false;
|
|
378
576
|
console.log(
|
|
379
|
-
`[mirror] enabled (location: ${config.location},
|
|
577
|
+
`[mirror] enabled (location: ${config.location}, sync_mode: manual) — initial export complete, manual mode`,
|
|
380
578
|
);
|
|
381
579
|
}
|
|
382
580
|
|
|
@@ -384,21 +582,43 @@ export class MirrorManager {
|
|
|
384
582
|
}
|
|
385
583
|
|
|
386
584
|
/**
|
|
387
|
-
* Stop the
|
|
388
|
-
*
|
|
389
|
-
*
|
|
585
|
+
* Stop the mirror lifecycle cleanly:
|
|
586
|
+
* - Unsubscribe from hooks first so no new events queue.
|
|
587
|
+
* - Cancel debounce + safety-net timers.
|
|
588
|
+
* - Await the in-flight cycle (if any) up to a soft timeout.
|
|
390
589
|
*
|
|
391
590
|
* `preserveStatus: true` is the start()-internal path that keeps the
|
|
392
591
|
* status fields around for the about-to-restart pass; default false
|
|
393
|
-
* blanks
|
|
592
|
+
* blanks the `watch_running` indicator.
|
|
394
593
|
*/
|
|
395
594
|
async stop(opts: { preserveStatus?: boolean } = {}): Promise<void> {
|
|
396
595
|
this.stopping = true;
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
596
|
+
|
|
597
|
+
// Unsubscribe BEFORE waiting for in-flight — additional events
|
|
598
|
+
// arriving after we start the settle wait would re-arm the debounce
|
|
599
|
+
// timer and stretch the wait indefinitely.
|
|
600
|
+
for (const off of this.unsubscribes) {
|
|
601
|
+
try {
|
|
602
|
+
off();
|
|
603
|
+
} catch (err) {
|
|
604
|
+
// Unsubscribe should never throw; defensive log only.
|
|
605
|
+
console.warn(`[mirror] unsubscribe threw: ${(err as Error).message ?? err}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
this.unsubscribes = [];
|
|
609
|
+
|
|
610
|
+
if (this.debounceTimer) {
|
|
611
|
+
clearTimeout(this.debounceTimer);
|
|
612
|
+
this.debounceTimer = null;
|
|
400
613
|
}
|
|
401
|
-
|
|
614
|
+
if (this.safetyNetTimer) {
|
|
615
|
+
clearInterval(this.safetyNetTimer);
|
|
616
|
+
this.safetyNetTimer = null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Brief settle window — give an in-flight export ~250ms to complete
|
|
620
|
+
// + write a coherent commit. Don't hang shutdown indefinitely; the
|
|
621
|
+
// export's status will surface a half-finished state on next start.
|
|
402
622
|
const settleMs = 250;
|
|
403
623
|
const start = Date.now();
|
|
404
624
|
while (this.inFlight && Date.now() - start < settleMs) {
|
|
@@ -408,6 +628,9 @@ export class MirrorManager {
|
|
|
408
628
|
this.status.watch_running = false;
|
|
409
629
|
}
|
|
410
630
|
this.stopping = false;
|
|
631
|
+
// Discard any dirty bit from events that arrived during shutdown —
|
|
632
|
+
// they'll be picked up on the next start()'s initial export anyway.
|
|
633
|
+
this.dirtyDuringFlush = false;
|
|
411
634
|
}
|
|
412
635
|
|
|
413
636
|
/**
|
|
@@ -425,26 +648,178 @@ export class MirrorManager {
|
|
|
425
648
|
|
|
426
649
|
/**
|
|
427
650
|
* Force a one-shot export cycle right now. Useful for "force re-export"
|
|
428
|
-
* buttons (
|
|
429
|
-
*
|
|
651
|
+
* buttons (admin SPA) + tests that want a deterministic cycle without
|
|
652
|
+
* waiting on the debounce or safety-net timer.
|
|
653
|
+
*
|
|
654
|
+
* Always runs with prune=true: a manual trigger is an explicit "make
|
|
655
|
+
* the mirror match vault state right now" request; orphan sweeps are
|
|
656
|
+
* cheap on typical vaults and the operator probably wanted that
|
|
657
|
+
* effect anyway.
|
|
430
658
|
*/
|
|
431
659
|
async runNow(): Promise<MirrorStatus> {
|
|
432
660
|
if (!this.status.enabled) {
|
|
433
661
|
return this.getStatus();
|
|
434
662
|
}
|
|
435
|
-
await this.runOneCycle({ isInitial: false });
|
|
663
|
+
await this.runOneCycle({ isInitial: false, prune: true });
|
|
436
664
|
return this.getStatus();
|
|
437
665
|
}
|
|
438
666
|
|
|
439
667
|
// Visible-for-test: number of `start()` calls so far.
|
|
440
668
|
_startCount(): number { return this.startCount; }
|
|
441
669
|
|
|
670
|
+
// ---------------------------------------------------------------------
|
|
671
|
+
// Event-driven path
|
|
672
|
+
// ---------------------------------------------------------------------
|
|
673
|
+
|
|
442
674
|
/**
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
675
|
+
* Subscribe to note / tag / attachment hooks. Each event arms the
|
|
676
|
+
* debounce timer (or marks the dirty bit if a flush is already
|
|
677
|
+
* running). Stop() will call the unsubscribe handles in `unsubscribes`.
|
|
678
|
+
*
|
|
679
|
+
* Subscribes to every mutation event the mirror cares about:
|
|
680
|
+
* - notes: created, updated, deleted
|
|
681
|
+
* - tags: upserted, deleted
|
|
682
|
+
* - attachments: created, deleted
|
|
446
683
|
*/
|
|
447
|
-
private
|
|
684
|
+
private subscribeToHooks(): void {
|
|
685
|
+
const hooks = this.deps.hooks;
|
|
686
|
+
if (!hooks) {
|
|
687
|
+
// No hook registry wired — fall back to safety-net only. Logged
|
|
688
|
+
// once at start; tests that exercise this path inspect status,
|
|
689
|
+
// not logs.
|
|
690
|
+
console.warn(
|
|
691
|
+
`[mirror] sync_mode: events requested but no HookRegistry wired in deps — running with safety-net poll only`,
|
|
692
|
+
);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const onMutation = () => {
|
|
697
|
+
// Cheap pulse — flag dirty + arm the debounce.
|
|
698
|
+
if (this.inFlight) {
|
|
699
|
+
this.dirtyDuringFlush = true;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
this.armDebounce();
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
this.unsubscribes.push(
|
|
706
|
+
hooks.onNote({
|
|
707
|
+
name: "mirror-note-mutation",
|
|
708
|
+
event: ["created", "updated", "deleted"],
|
|
709
|
+
handler: onMutation,
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
this.unsubscribes.push(
|
|
713
|
+
hooks.onTag({
|
|
714
|
+
name: "mirror-tag-mutation",
|
|
715
|
+
event: ["upserted", "deleted"],
|
|
716
|
+
handler: onMutation,
|
|
717
|
+
}),
|
|
718
|
+
);
|
|
719
|
+
this.unsubscribes.push(
|
|
720
|
+
hooks.onAttachment({
|
|
721
|
+
name: "mirror-attachment-mutation",
|
|
722
|
+
event: ["created", "deleted"],
|
|
723
|
+
handler: onMutation,
|
|
724
|
+
}),
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Arm or re-arm the debounce timer. Subsequent calls within
|
|
730
|
+
* DEBOUNCE_MS reset the timer (classic debounce). When the timer
|
|
731
|
+
* fires, run an export pass; if events arrived during the export,
|
|
732
|
+
* run again.
|
|
733
|
+
*/
|
|
734
|
+
private armDebounce(): void {
|
|
735
|
+
if (this.stopping) return;
|
|
736
|
+
if (this.debounceTimer) {
|
|
737
|
+
clearTimeout(this.debounceTimer);
|
|
738
|
+
}
|
|
739
|
+
this.debounceTimer = setTimeout(() => {
|
|
740
|
+
this.debounceTimer = null;
|
|
741
|
+
void this.runFlush({ prune: false });
|
|
742
|
+
}, DEBOUNCE_MS);
|
|
743
|
+
this.debounceTimer.unref?.();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Run a single flush — guards against concurrent runs, follows up
|
|
748
|
+
* with another pass if events arrived mid-flight. The event-driven
|
|
749
|
+
* fast path uses prune=false (rely on the targeted "deleted" events
|
|
750
|
+
* for delete propagation; the safety-net + manual-run paths sweep).
|
|
751
|
+
*
|
|
752
|
+
* In practice deletions DO need pruning even on the fast path —
|
|
753
|
+
* `exportVaultToDir` only writes existing notes; the file the
|
|
754
|
+
* deletion event refers to has to be removed somewhere. The current
|
|
755
|
+
* implementation runs a full prune sweep on every flush so deletes
|
|
756
|
+
* propagate within the debounce window. (If profiling shows this is
|
|
757
|
+
* expensive for very large vaults, a targeted-deletion queue could
|
|
758
|
+
* replace the sweep on the fast path — recorded as a possible
|
|
759
|
+
* future optimization in the design doc.)
|
|
760
|
+
*/
|
|
761
|
+
private async runFlush(opts: { prune: boolean }): Promise<void> {
|
|
762
|
+
if (this.stopping) return;
|
|
763
|
+
if (this.inFlight) {
|
|
764
|
+
// Shouldn't happen given the dirty-bit guards in armDebounce +
|
|
765
|
+
// safetyNet; defensive only.
|
|
766
|
+
this.dirtyDuringFlush = true;
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
this.inFlight = true;
|
|
770
|
+
try {
|
|
771
|
+
// Event-driven flushes prune-on-flush so deletions actually
|
|
772
|
+
// propagate. Cost: O(mirror size) per flush — fine for typical
|
|
773
|
+
// vaults. See doc above on the targeted-queue optimization.
|
|
774
|
+
await this.runOneCycle({ isInitial: false, prune: true });
|
|
775
|
+
} catch (err) {
|
|
776
|
+
const msg = (err as Error).message ?? String(err);
|
|
777
|
+
this.status.last_error = `event-driven flush failed: ${msg}`;
|
|
778
|
+
console.warn(`[mirror] ${this.status.last_error}`);
|
|
779
|
+
} finally {
|
|
780
|
+
this.inFlight = false;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// If events arrived during the flush, run again (one follow-up,
|
|
784
|
+
// not a loop — armDebounce re-engages if events keep coming).
|
|
785
|
+
if (this.dirtyDuringFlush && !this.stopping) {
|
|
786
|
+
this.dirtyDuringFlush = false;
|
|
787
|
+
this.armDebounce();
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Arm the safety-net poll. Runs once per `safety_net_seconds`. When
|
|
793
|
+
* it fires:
|
|
794
|
+
* - If a flush is already pending (debounce armed) or in-flight,
|
|
795
|
+
* skip (the event path will catch it).
|
|
796
|
+
* - Otherwise, run a full prune+export sweep so anything missed by
|
|
797
|
+
* the events (direct SQL writes, dropped dispatches, restart
|
|
798
|
+
* gaps) lands in the mirror.
|
|
799
|
+
*/
|
|
800
|
+
private armSafetyNetTimer(): void {
|
|
801
|
+
if (this.safetyNetTimer) return;
|
|
802
|
+
const intervalMs = (
|
|
803
|
+
this.currentConfig.safety_net_seconds || DEFAULT_SAFETY_NET_SECONDS
|
|
804
|
+
) * 1000;
|
|
805
|
+
this.safetyNetTimer = setInterval(async () => {
|
|
806
|
+
if (this.stopping) return;
|
|
807
|
+
if (this.debounceTimer || this.inFlight) {
|
|
808
|
+
// Event path's already handling things; this poll is a no-op.
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
await this.runFlush({ prune: true });
|
|
812
|
+
}, intervalMs);
|
|
813
|
+
this.safetyNetTimer.unref?.();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Stage → export → prune (optional) → commit pipeline for a single
|
|
818
|
+
* cycle. Updates status fields with the outcome. Errors logged +
|
|
819
|
+
* reflected in `last_error` but never rethrown out of the event
|
|
820
|
+
* dispatcher (would kill the loop).
|
|
821
|
+
*/
|
|
822
|
+
private async runOneCycle(opts: { isInitial: boolean; prune: boolean }): Promise<void> {
|
|
448
823
|
const nextCursor = new Date().toISOString();
|
|
449
824
|
const path = this.status.mirror_path!;
|
|
450
825
|
const sinceCursor = opts.isInitial ? undefined : this.cursor;
|
|
@@ -459,6 +834,34 @@ export class MirrorManager {
|
|
|
459
834
|
return;
|
|
460
835
|
}
|
|
461
836
|
|
|
837
|
+
// Orphan sweep — removes files in the mirror that don't correspond
|
|
838
|
+
// to a current vault note/tag/attachment. Counts surface in logs;
|
|
839
|
+
// the count of removed files is folded into the commit's
|
|
840
|
+
// `notes_changed` so the commit message reflects the delete too.
|
|
841
|
+
let prunedNotes = 0;
|
|
842
|
+
if (opts.prune && this.deps.runPrune) {
|
|
843
|
+
try {
|
|
844
|
+
const pruneStats = await this.deps.runPrune({ outDir: path });
|
|
845
|
+
prunedNotes = pruneStats.notes_removed + pruneStats.sidecars_removed;
|
|
846
|
+
const removedTotal =
|
|
847
|
+
pruneStats.notes_removed +
|
|
848
|
+
pruneStats.sidecars_removed +
|
|
849
|
+
pruneStats.schemas_removed +
|
|
850
|
+
pruneStats.attachment_dirs_removed;
|
|
851
|
+
if (removedTotal > 0) {
|
|
852
|
+
console.log(
|
|
853
|
+
`[mirror] prune: removed ${pruneStats.notes_removed} note(s), ${pruneStats.sidecars_removed} sidecar(s), ${pruneStats.schemas_removed} schema(s), ${pruneStats.attachment_dirs_removed} attachment dir(s)`,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
} catch (err) {
|
|
857
|
+
// Prune failure is non-fatal — the export already wrote the
|
|
858
|
+
// current state; the next sweep retries.
|
|
859
|
+
console.warn(
|
|
860
|
+
`[mirror] prune failed (non-fatal): ${(err as Error).message ?? err}`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
462
865
|
this.cursor = nextCursor;
|
|
463
866
|
this.status.last_export_at = nextCursor;
|
|
464
867
|
this.status.last_export_notes_count = stats.notes;
|
|
@@ -471,11 +874,23 @@ export class MirrorManager {
|
|
|
471
874
|
return;
|
|
472
875
|
}
|
|
473
876
|
|
|
877
|
+
// Commit when EITHER notes changed OR orphans were pruned. The
|
|
878
|
+
// commit message's `notes_changed` reflects the union — operators
|
|
879
|
+
// reading a "deleted 3 notes" diff in their mirror should see that
|
|
880
|
+
// count in the commit. (Pre-event-driven this was strictly
|
|
881
|
+
// notes-from-export; orphan deletes weren't tracked.)
|
|
882
|
+
const totalChanged = stats.notes + prunedNotes;
|
|
883
|
+
if (totalChanged === 0) {
|
|
884
|
+
// Nothing to commit; runGitCommitCycle would no-op anyway, but
|
|
885
|
+
// skipping early avoids the firstNoteTitle DB lookup.
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
|
|
474
889
|
const firstNoteTitle = await this.deps.firstChangedNoteTitle(sinceCursor);
|
|
475
890
|
const commitResult = await runGitCommitCycle({
|
|
476
891
|
repoDir: path,
|
|
477
892
|
template: this.currentConfig.commit_template,
|
|
478
|
-
notesChanged:
|
|
893
|
+
notesChanged: totalChanged,
|
|
479
894
|
vaultName: this.deps.vaultName,
|
|
480
895
|
firstNoteTitle,
|
|
481
896
|
push: this.currentConfig.auto_push,
|
|
@@ -490,32 +905,166 @@ export class MirrorManager {
|
|
|
490
905
|
const sha = new TextDecoder().decode(await new Response(shaProc.stdout).arrayBuffer()).trim();
|
|
491
906
|
if (sha.length > 0) this.status.last_commit_sha = sha;
|
|
492
907
|
}
|
|
908
|
+
|
|
909
|
+
// Cut 5: surface push outcome (success/failure + redacted error).
|
|
910
|
+
// runGitCommitCycle returns push info iff push was attempted; we
|
|
911
|
+
// mirror the same shape into status here.
|
|
912
|
+
if (commitResult.push) {
|
|
913
|
+
const now = new Date().toISOString();
|
|
914
|
+
if (commitResult.push.ok) {
|
|
915
|
+
this.status.last_push_at = now;
|
|
916
|
+
this.status.last_push_sha = this.status.last_commit_sha;
|
|
917
|
+
this.status.last_push_error = null;
|
|
918
|
+
} else if (commitResult.push.error) {
|
|
919
|
+
this.status.last_push_error = commitResult.push.error;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Cut 5: track local-commits-ahead-of-upstream so the UI can render
|
|
924
|
+
// "<N> commits ready to push" subdued helper text.
|
|
925
|
+
this.status.commits_unpushed = await readCommitsUnpushed(path);
|
|
493
926
|
}
|
|
494
927
|
|
|
495
928
|
/**
|
|
496
|
-
*
|
|
497
|
-
*
|
|
929
|
+
* Cut 6: fire a `git push` against the current mirror dir, capture
|
|
930
|
+
* the outcome into status, and return a struct the route handler can
|
|
931
|
+
* fold into its response.
|
|
932
|
+
*
|
|
933
|
+
* Distinguished from `runNow()` which exports + commits + pushes.
|
|
934
|
+
* `pushNow()` skips export + commit entirely and only pushes whatever
|
|
935
|
+
* has already been committed. Used by:
|
|
936
|
+
* - the credential save path (auto-fire after `auth/pat` /
|
|
937
|
+
* `auth/github/select-repo` lands) so the operator sees the
|
|
938
|
+
* push happen rather than waiting for the next write
|
|
939
|
+
* - the explicit POST /.parachute/mirror/push-now route the SPA's
|
|
940
|
+
* "Push now" button hits
|
|
941
|
+
*
|
|
942
|
+
* Idempotent against a fully-synced mirror (git push reports "nothing
|
|
943
|
+
* to push" with exit 0). Failure paths set `last_push_error` for the
|
|
944
|
+
* status surface.
|
|
498
945
|
*/
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
946
|
+
async pushNow(): Promise<
|
|
947
|
+
| { fired: false; reason: "not_enabled" | "no_mirror_path" }
|
|
948
|
+
| { fired: true; pushed: boolean; sha?: string; error?: string }
|
|
949
|
+
> {
|
|
950
|
+
if (!this.status.enabled) return { fired: false, reason: "not_enabled" };
|
|
951
|
+
if (!this.status.mirror_path) return { fired: false, reason: "no_mirror_path" };
|
|
952
|
+
const path = this.status.mirror_path;
|
|
953
|
+
const pushResult = await gitPush(path);
|
|
954
|
+
const now = new Date().toISOString();
|
|
955
|
+
// Refresh commits_unpushed either way — a no-op push still reflects
|
|
956
|
+
// the current state (0 ahead if synced, N if push failed mid-way).
|
|
957
|
+
this.status.commits_unpushed = await readCommitsUnpushed(path);
|
|
958
|
+
if (pushResult.ok) {
|
|
959
|
+
// Capture HEAD sha at this point so the UI can show the operator
|
|
960
|
+
// exactly what landed.
|
|
961
|
+
const shaProc = Bun.spawn(["git", "rev-parse", "HEAD"], {
|
|
962
|
+
cwd: path,
|
|
963
|
+
stdout: "pipe",
|
|
964
|
+
stderr: "pipe",
|
|
965
|
+
});
|
|
966
|
+
await shaProc.exited;
|
|
967
|
+
const sha = new TextDecoder()
|
|
968
|
+
.decode(await new Response(shaProc.stdout).arrayBuffer())
|
|
969
|
+
.trim();
|
|
970
|
+
this.status.last_push_at = now;
|
|
971
|
+
if (sha.length > 0) this.status.last_push_sha = sha;
|
|
972
|
+
this.status.last_push_error = null;
|
|
973
|
+
return { fired: true, pushed: true, sha: sha.length > 0 ? sha : undefined };
|
|
974
|
+
}
|
|
975
|
+
const redacted = redactToken(pushResult.stderr);
|
|
976
|
+
this.status.last_push_error = redacted;
|
|
977
|
+
console.warn(`[mirror] push-now failed (non-fatal): ${redacted}`);
|
|
978
|
+
return { fired: true, pushed: false, error: redacted };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Apply UI-configured credentials to the mirror dir's `origin`. Called
|
|
983
|
+
* on every start()/reload() so credential rotations land without
|
|
984
|
+
* requiring a server restart. Idempotent:
|
|
985
|
+
* - active_method=pat → set origin to the stored authed URL
|
|
986
|
+
* - active_method=github_oauth + a picked repo → set origin (the
|
|
987
|
+
* pick path stores the authed URL in the same flow)
|
|
988
|
+
* - active_method=github_oauth + no repo picked → no-op (UI is mid-
|
|
989
|
+
* flow)
|
|
990
|
+
* - no credentials at all → leave whatever the operator wired by
|
|
991
|
+
* hand alone (don't clobber a manually-set remote)
|
|
992
|
+
*
|
|
993
|
+
* Never throws — push failures will surface via the existing
|
|
994
|
+
* non-fatal-warn path in runGitCommitCycle.
|
|
995
|
+
*/
|
|
996
|
+
private async applyCredentialsToRemote(repoDir: string): Promise<void> {
|
|
997
|
+
try {
|
|
998
|
+
const creds = readCredentials(this.deps.vaultName);
|
|
999
|
+
if (!creds || !creds.active_method) {
|
|
1000
|
+
// No UI-configured credentials. Leave the remote alone — the
|
|
1001
|
+
// operator may have set one up via `git remote add` manually.
|
|
1002
|
+
return;
|
|
515
1003
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
1004
|
+
if (creds.active_method === "pat" && creds.pat) {
|
|
1005
|
+
const result = await applyToGitRemote(repoDir, creds.pat.remote_url);
|
|
1006
|
+
if (!result.ok) {
|
|
1007
|
+
console.warn(
|
|
1008
|
+
`[mirror] could not apply PAT remote URL (non-fatal): ${result.error}`,
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
// github_oauth: the active token is set, but a repo may not yet be
|
|
1014
|
+
// picked. The select-repo route handler writes the URL; on a
|
|
1015
|
+
// subsequent restart we can't reconstruct the URL without the
|
|
1016
|
+
// owner/repo. Best-effort: if `origin` already points at a github.com
|
|
1017
|
+
// URL, rewrite it with the stored token in case the token rotated.
|
|
1018
|
+
if (creds.active_method === "github_oauth" && creds.github_oauth) {
|
|
1019
|
+
const current = await readCurrentOrigin(repoDir);
|
|
1020
|
+
if (current && current.includes("github.com")) {
|
|
1021
|
+
// Parse owner/repo out of the current URL.
|
|
1022
|
+
const match = current.match(
|
|
1023
|
+
/github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?$/,
|
|
1024
|
+
);
|
|
1025
|
+
if (match) {
|
|
1026
|
+
const [, owner, repo] = match;
|
|
1027
|
+
const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
|
|
1028
|
+
const authed = githubAuthedRemoteUrl(
|
|
1029
|
+
creds.github_oauth.access_token,
|
|
1030
|
+
owner!,
|
|
1031
|
+
repo!,
|
|
1032
|
+
);
|
|
1033
|
+
const result = await applyToGitRemote(repoDir, authed);
|
|
1034
|
+
if (!result.ok) {
|
|
1035
|
+
console.warn(
|
|
1036
|
+
`[mirror] could not refresh github_oauth remote URL (non-fatal): ${result.error}`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
console.warn(
|
|
1044
|
+
`[mirror] credential application failed (non-fatal): ${(err as Error).message ?? err}`,
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ---------------------------------------------------------------------
|
|
1050
|
+
// Test seams
|
|
1051
|
+
// ---------------------------------------------------------------------
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Force the debounce timer to fire immediately (cancel + run flush).
|
|
1055
|
+
* Used by mirror-manager.test.ts to avoid sleeping through 500ms in
|
|
1056
|
+
* every event-driven test case. Not exposed via the public API.
|
|
1057
|
+
*/
|
|
1058
|
+
async _flushDebounceForTest(): Promise<void> {
|
|
1059
|
+
if (this.debounceTimer) {
|
|
1060
|
+
clearTimeout(this.debounceTimer);
|
|
1061
|
+
this.debounceTimer = null;
|
|
1062
|
+
}
|
|
1063
|
+
await this.runFlush({ prune: false });
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Test seam: snapshot of the unsubscribe-handle count. */
|
|
1067
|
+
_subscriptionCount(): number {
|
|
1068
|
+
return this.unsubscribes.length;
|
|
520
1069
|
}
|
|
521
1070
|
}
|