@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2
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/README.md +51 -54
- package/core/src/core.test.ts +4 -1
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +66 -43
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +52 -0
- package/core/src/portable-md.ts +48 -0
- package/core/src/schema.ts +87 -14
- package/core/src/store.ts +117 -0
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +191 -11
- package/src/auth-status.ts +12 -5
- package/src/auth.test.ts +135 -219
- package/src/auth.ts +158 -107
- package/src/cli.ts +306 -224
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/init-summary.test.ts +4 -4
- package/src/init-summary.ts +36 -10
- package/src/mcp-config.test.ts +4 -2
- package/src/mcp-http.ts +24 -3
- package/src/mcp-install-interactive.test.ts +33 -71
- package/src/mcp-install-interactive.ts +23 -76
- package/src/mcp-install.test.ts +156 -55
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +249 -74
- package/src/mirror-config.test.ts +107 -0
- package/src/mirror-config.ts +275 -9
- package/src/mirror-credentials.test.ts +168 -17
- package/src/mirror-credentials.ts +155 -32
- package/src/mirror-deps.ts +25 -16
- package/src/mirror-import.test.ts +122 -16
- package/src/mirror-import.ts +50 -16
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +116 -22
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +81 -21
- package/src/mirror-routes.ts +90 -16
- package/src/routes.ts +39 -2
- package/src/routing.test.ts +203 -118
- package/src/routing.ts +46 -59
- package/src/scopes.test.ts +0 -86
- package/src/scopes.ts +9 -97
- package/src/server.ts +102 -34
- package/src/storage.test.ts +132 -7
- package/src/token-store.test.ts +88 -169
- package/src/token-store.ts +123 -249
- package/src/vault-create.test.ts +12 -4
- package/src/vault.test.ts +408 -103
- package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/tokens-routes.test.ts +0 -727
- package/src/tokens-routes.ts +0 -392
- package/web/ui/dist/assets/index-Degr8snN.js +0 -60
|
@@ -215,6 +215,21 @@ describe("bootstrapInternalMirror", () => {
|
|
|
215
215
|
expect(isGitRepoSync(dir)).toBe(true);
|
|
216
216
|
}
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
test("git missing → ok:false with actionable error (status surfaces it, no raw crash)", async () => {
|
|
220
|
+
// vault#415 — a git-less server can't bootstrap a mirror. The error
|
|
221
|
+
// channel carries the friendly message that MirrorManager.start threads
|
|
222
|
+
// into status.last_error, instead of a raw "Executable not found" crash.
|
|
223
|
+
dir = path.join(tmp("mirror-boot-nogit-"), "mirror");
|
|
224
|
+
const r = await bootstrapInternalMirror(dir, () => null);
|
|
225
|
+
expect(r.ok).toBe(false);
|
|
226
|
+
if (!r.ok) {
|
|
227
|
+
expect(r.error).toContain("git is required");
|
|
228
|
+
expect(r.error).toContain("dnf install git");
|
|
229
|
+
}
|
|
230
|
+
// Failed fast at the preflight — the dir was never created.
|
|
231
|
+
expect(fs.existsSync(dir)).toBe(false);
|
|
232
|
+
});
|
|
218
233
|
});
|
|
219
234
|
|
|
220
235
|
// ---------------------------------------------------------------------------
|
|
@@ -378,6 +393,42 @@ describe("MirrorManager.start — lifecycle matrix", () => {
|
|
|
378
393
|
fs.rmSync(external, { recursive: true, force: true });
|
|
379
394
|
});
|
|
380
395
|
|
|
396
|
+
test("external + git not installed → enabled:false with friendly error, never spawns/exports", async () => {
|
|
397
|
+
// vault#415 nit — the external branch's isGitRepo() check shells `git`
|
|
398
|
+
// with no preflight; on a git-less server it would throw a raw
|
|
399
|
+
// "Executable not found in $PATH: \"git\"" and crash start(). The
|
|
400
|
+
// top-of-start() preflight (forced via the `which` seam) lands the
|
|
401
|
+
// friendly, actionable message in last_error for the external location.
|
|
402
|
+
home = tmp("mgr-ext-nogit-installed-");
|
|
403
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
404
|
+
// Use a real, valid external git repo so the ONLY thing that can fail is
|
|
405
|
+
// the git-presence preflight — proving the preflight (not the path/repo
|
|
406
|
+
// checks) produced the disabled state.
|
|
407
|
+
const external = tmp("mgr-ext-nogit-target-");
|
|
408
|
+
initRepo(external);
|
|
409
|
+
seedCommit(external);
|
|
410
|
+
const deps = makeFakeDeps({
|
|
411
|
+
parachuteHome: home,
|
|
412
|
+
initialConfig: {
|
|
413
|
+
...defaultMirrorConfig(),
|
|
414
|
+
enabled: true,
|
|
415
|
+
location: "external",
|
|
416
|
+
external_path: external,
|
|
417
|
+
sync_mode: "events",
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
const mgr = new MirrorManager(deps);
|
|
421
|
+
// Force the preflight to see no git on PATH.
|
|
422
|
+
const status = await mgr.start(() => null);
|
|
423
|
+
expect(status.enabled).toBe(false);
|
|
424
|
+
expect(status.last_error).toContain("git is required");
|
|
425
|
+
expect(status.last_error).toContain("dnf install git");
|
|
426
|
+
// Never reached export — the preflight bailed before any git work.
|
|
427
|
+
expect(deps.exportCalls).toHaveLength(0);
|
|
428
|
+
await mgr.stop();
|
|
429
|
+
fs.rmSync(external, { recursive: true, force: true });
|
|
430
|
+
});
|
|
431
|
+
|
|
381
432
|
test("internal bootstrap refuses to clobber pre-existing non-git data", async () => {
|
|
382
433
|
home = tmp("mgr-int-clobber-");
|
|
383
434
|
const mirrorPath = path.join(home, "vault", "data", "default", "mirror");
|
package/src/mirror-manager.ts
CHANGED
|
@@ -26,17 +26,16 @@
|
|
|
26
26
|
* debounce timer, cancel safety-net poll, let an in-flight export
|
|
27
27
|
* finish via the soft settle window.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* vault
|
|
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.
|
|
33
35
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* `PARACHUTE_VAULT_NAME` / `default_vault`; the mirror config follows
|
|
38
|
-
* suit. Multi-vault mirror routing is a future ripple (open question 2
|
|
39
|
-
* in the design doc).
|
|
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.
|
|
40
39
|
*
|
|
41
40
|
* ## Race-condition contract
|
|
42
41
|
*
|
|
@@ -76,6 +75,7 @@ import {
|
|
|
76
75
|
applyToGitRemote,
|
|
77
76
|
readCredentials,
|
|
78
77
|
} from "./mirror-credentials.ts";
|
|
78
|
+
import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
|
|
79
79
|
import type { HookRegistry } from "../core/src/hooks.ts";
|
|
80
80
|
|
|
81
81
|
/**
|
|
@@ -231,7 +231,20 @@ export type BootstrapResult = BootstrapResultOk | BootstrapResultError;
|
|
|
231
231
|
*/
|
|
232
232
|
export async function bootstrapInternalMirror(
|
|
233
233
|
path: string,
|
|
234
|
+
// Test seam for the git-presence preflight (default `Bun.which`). Inject a
|
|
235
|
+
// fn returning `null` to exercise the git-not-installed bootstrap path.
|
|
236
|
+
which?: (cmd: string) => string | null,
|
|
234
237
|
): Promise<BootstrapResult> {
|
|
238
|
+
// Preflight: a git-less server can't bootstrap a mirror. Surface the
|
|
239
|
+
// friendly, actionable message into the bootstrap-error channel so the
|
|
240
|
+
// caller threads it into mirror status (`last_error`) rather than letting
|
|
241
|
+
// a raw `Executable not found in $PATH: "git"` crash out of the spawn.
|
|
242
|
+
try {
|
|
243
|
+
ensureGitAvailable(which);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
return { ok: false, error: (err as Error).message };
|
|
246
|
+
}
|
|
247
|
+
|
|
235
248
|
if (existsSync(path)) {
|
|
236
249
|
let stat;
|
|
237
250
|
try {
|
|
@@ -410,6 +423,15 @@ export class MirrorManager {
|
|
|
410
423
|
this.deps = deps;
|
|
411
424
|
}
|
|
412
425
|
|
|
426
|
+
/**
|
|
427
|
+
* The vault this mirror manager belongs to. Credential reads/writes are
|
|
428
|
+
* per-vault (vault#399); route handlers thread this into the credential
|
|
429
|
+
* functions so they touch the right vault's `.mirror-credentials.yaml`.
|
|
430
|
+
*/
|
|
431
|
+
getVaultName(): string {
|
|
432
|
+
return this.deps.vaultName;
|
|
433
|
+
}
|
|
434
|
+
|
|
413
435
|
/**
|
|
414
436
|
* Read the current config snapshot. Returns a copy so callers can't
|
|
415
437
|
* accidentally mutate the manager's internal state.
|
|
@@ -418,6 +440,30 @@ export class MirrorManager {
|
|
|
418
440
|
return { ...this.currentConfig };
|
|
419
441
|
}
|
|
420
442
|
|
|
443
|
+
/**
|
|
444
|
+
* The config the operator + SPA should SEE for this vault — distinct from
|
|
445
|
+
* the live in-memory `getConfig()` only before the manager has started.
|
|
446
|
+
*
|
|
447
|
+
* Why this exists (vault#400): routes resolve a per-vault manager via the
|
|
448
|
+
* registry, which can LAZILY build a manager for a vault that has config
|
|
449
|
+
* on disk but no running instance yet (e.g. a non-default vault the
|
|
450
|
+
* operator configured, or a runtime-enabled vault between PUT and the
|
|
451
|
+
* reload's start). A freshly-constructed-but-never-started manager has
|
|
452
|
+
* `currentConfig === defaultMirrorConfig()` (disabled), which would make
|
|
453
|
+
* `GET /vault/<name>/.parachute/mirror` show "disabled" for a vault whose
|
|
454
|
+
* file says `enabled: true`. Reading the persisted config when we haven't
|
|
455
|
+
* started yet returns the truth for that vault. Once `start()` has run,
|
|
456
|
+
* `currentConfig` is authoritative (it reflects the same persisted config
|
|
457
|
+
* plus any bootstrap outcome).
|
|
458
|
+
*/
|
|
459
|
+
getEffectiveConfig(): MirrorConfig {
|
|
460
|
+
if (this.startCount === 0) {
|
|
461
|
+
const persisted = this.deps.readMirrorConfig();
|
|
462
|
+
if (persisted) return { ...persisted };
|
|
463
|
+
}
|
|
464
|
+
return { ...this.currentConfig };
|
|
465
|
+
}
|
|
466
|
+
|
|
421
467
|
/**
|
|
422
468
|
* Get the current status snapshot. Returns a copy for the same reason as
|
|
423
469
|
* `getConfig`.
|
|
@@ -434,7 +480,11 @@ export class MirrorManager {
|
|
|
434
480
|
* Returns the final status snapshot — useful for tests + the PUT
|
|
435
481
|
* endpoint response.
|
|
436
482
|
*/
|
|
437
|
-
async start(
|
|
483
|
+
async start(
|
|
484
|
+
// Test seam for the git-presence preflight (default `Bun.which`). Inject
|
|
485
|
+
// a fn returning `null` to exercise the git-not-installed start path.
|
|
486
|
+
which?: (cmd: string) => string | null,
|
|
487
|
+
): Promise<MirrorStatus> {
|
|
438
488
|
this.startCount++;
|
|
439
489
|
await this.stop({ preserveStatus: true });
|
|
440
490
|
|
|
@@ -469,6 +519,24 @@ export class MirrorManager {
|
|
|
469
519
|
}
|
|
470
520
|
this.status.mirror_path = path;
|
|
471
521
|
|
|
522
|
+
// Preflight git BEFORE branching on location. Both branches shell `git`
|
|
523
|
+
// (internal → bootstrapInternalMirror; external → isGitRepo). On a
|
|
524
|
+
// git-less server the external branch's `isGitRepo` would otherwise throw
|
|
525
|
+
// a raw "Executable not found in $PATH: \"git\"" and crash start();
|
|
526
|
+
// catching it here lands the friendly, actionable message in
|
|
527
|
+
// status.last_error (disabled) for either location, uniformly.
|
|
528
|
+
try {
|
|
529
|
+
ensureGitAvailable(which);
|
|
530
|
+
} catch (err) {
|
|
531
|
+
if (err instanceof GitNotInstalledError) {
|
|
532
|
+
this.status.enabled = false;
|
|
533
|
+
this.status.last_error = err.message;
|
|
534
|
+
console.warn(`[mirror] ${err.message}`);
|
|
535
|
+
return this.getStatus();
|
|
536
|
+
}
|
|
537
|
+
throw err;
|
|
538
|
+
}
|
|
539
|
+
|
|
472
540
|
// Internal bootstrap. External path is the operator's responsibility —
|
|
473
541
|
// they should have validated via the PUT endpoint before we hit boot.
|
|
474
542
|
// We re-check `isGitRepo` defensively here either way; a missing/non-
|
|
@@ -609,9 +677,13 @@ export class MirrorManager {
|
|
|
609
677
|
* the operator-intended config on disk; on the next vault boot it
|
|
610
678
|
* applies cleanly.
|
|
611
679
|
*/
|
|
612
|
-
async reload(
|
|
680
|
+
async reload(
|
|
681
|
+
newConfig: MirrorConfig,
|
|
682
|
+
// Test seam forwarded to `start()` — see `start(which)`.
|
|
683
|
+
which?: (cmd: string) => string | null,
|
|
684
|
+
): Promise<MirrorStatus> {
|
|
613
685
|
this.deps.writeMirrorConfig(newConfig);
|
|
614
|
-
return this.start();
|
|
686
|
+
return this.start(which);
|
|
615
687
|
}
|
|
616
688
|
|
|
617
689
|
/**
|
|
@@ -855,14 +927,25 @@ export class MirrorManager {
|
|
|
855
927
|
}
|
|
856
928
|
|
|
857
929
|
const firstNoteTitle = await this.deps.firstChangedNoteTitle(sinceCursor);
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
930
|
+
let commitResult: Awaited<ReturnType<typeof runGitCommitCycle>>;
|
|
931
|
+
try {
|
|
932
|
+
commitResult = await runGitCommitCycle({
|
|
933
|
+
repoDir: path,
|
|
934
|
+
template: this.currentConfig.commit_template,
|
|
935
|
+
notesChanged: totalChanged,
|
|
936
|
+
vaultName: this.deps.vaultName,
|
|
937
|
+
firstNoteTitle,
|
|
938
|
+
push: this.currentConfig.auto_push,
|
|
939
|
+
});
|
|
940
|
+
} catch (err) {
|
|
941
|
+
// git-not-installed (or any commit-cycle throw) lands in status as a
|
|
942
|
+
// friendly last_error rather than crashing the cycle. Matches the
|
|
943
|
+
// "errors reflected in last_error, never rethrown" contract above.
|
|
944
|
+
const msg = (err as Error).message ?? String(err);
|
|
945
|
+
this.status.last_error = `commit cycle failed: ${msg}`;
|
|
946
|
+
console.warn(`[mirror] ${this.status.last_error}`);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
866
949
|
|
|
867
950
|
if (commitResult.committed) {
|
|
868
951
|
// Resolve the new HEAD sha so the status displays the commit that
|
|
@@ -918,6 +1001,17 @@ export class MirrorManager {
|
|
|
918
1001
|
if (!this.status.enabled) return { fired: false, reason: "not_enabled" };
|
|
919
1002
|
if (!this.status.mirror_path) return { fired: false, reason: "no_mirror_path" };
|
|
920
1003
|
const path = this.status.mirror_path;
|
|
1004
|
+
// Preflight: git-less server can't push. Surface the friendly message
|
|
1005
|
+
// into last_push_error (the SPA renders it) rather than throwing a raw
|
|
1006
|
+
// "Executable not found" out of the gitPush spawn.
|
|
1007
|
+
try {
|
|
1008
|
+
ensureGitAvailable();
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
const msg = (err as Error).message ?? String(err);
|
|
1011
|
+
this.status.last_push_error = msg;
|
|
1012
|
+
console.warn(`[mirror] push-now failed: ${msg}`);
|
|
1013
|
+
return { fired: true, pushed: false, error: msg };
|
|
1014
|
+
}
|
|
921
1015
|
const pushResult = await gitPush(path);
|
|
922
1016
|
const now = new Date().toISOString();
|
|
923
1017
|
// Refresh commits_unpushed either way — a no-op push still reflects
|
|
@@ -963,7 +1057,7 @@ export class MirrorManager {
|
|
|
963
1057
|
*/
|
|
964
1058
|
private async applyCredentialsToRemote(repoDir: string): Promise<void> {
|
|
965
1059
|
try {
|
|
966
|
-
const creds = readCredentials();
|
|
1060
|
+
const creds = readCredentials(this.deps.vaultName);
|
|
967
1061
|
if (!creds || !creds.active_method) {
|
|
968
1062
|
// No UI-configured credentials. Leave the remote alone — the
|
|
969
1063
|
// operator may have set one up via `git remote add` manually.
|