@openparachute/vault 0.6.4-rc.7 → 0.6.4-rc.9
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/package.json +1 -1
- package/src/cli.ts +181 -6
- package/src/mirror-history.test.ts +426 -0
- package/src/mirror-manager.ts +164 -0
- package/src/mirror-routes.ts +182 -1
- package/src/routing.test.ts +73 -0
- package/src/routing.ts +46 -0
- package/src/self-register.test.ts +5 -5
- package/src/self-register.ts +8 -3
- package/src/vault-remove.test.ts +4 -5
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -110,11 +110,17 @@ import { getVaultStore } from "./vault-store.ts";
|
|
|
110
110
|
import { seedOnboardingNotesBestEffort } from "./onboarding-seed.ts";
|
|
111
111
|
import {
|
|
112
112
|
defaultMirrorConfig,
|
|
113
|
+
readMirrorConfigForVault,
|
|
113
114
|
resolveMirrorPath,
|
|
114
115
|
writeMirrorConfigForVault,
|
|
115
116
|
type MirrorConfig,
|
|
116
117
|
} from "./mirror-config.ts";
|
|
117
|
-
import {
|
|
118
|
+
import {
|
|
119
|
+
bootstrapInternalMirror,
|
|
120
|
+
readMirrorHistory,
|
|
121
|
+
showMirrorRevision,
|
|
122
|
+
} from "./mirror-manager.ts";
|
|
123
|
+
import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
|
|
118
124
|
import { selfRegister } from "./self-register.ts";
|
|
119
125
|
import {
|
|
120
126
|
hasOwnerPassword,
|
|
@@ -234,6 +240,9 @@ switch (command) {
|
|
|
234
240
|
case "export":
|
|
235
241
|
await cmdExport(cmdArgs);
|
|
236
242
|
break;
|
|
243
|
+
case "history":
|
|
244
|
+
await cmdHistory(cmdArgs);
|
|
245
|
+
break;
|
|
237
246
|
case "schema":
|
|
238
247
|
await cmdSchema(cmdArgs);
|
|
239
248
|
break;
|
|
@@ -1665,11 +1674,11 @@ function cmdRemove(args: string[]) {
|
|
|
1665
1674
|
// out of the parachute-vault row immediately — the same selfRegister
|
|
1666
1675
|
// refresh cmdCreate does (#208). Without this, the hub's well-known
|
|
1667
1676
|
// fan-out kept advertising the deleted vault until the next server boot.
|
|
1668
|
-
// Note: with zero vaults remaining, selfRegister
|
|
1669
|
-
//
|
|
1670
|
-
//
|
|
1671
|
-
//
|
|
1672
|
-
// ours.
|
|
1677
|
+
// Note: with zero vaults remaining, selfRegister emits paths: [] (#478) —
|
|
1678
|
+
// the row stays present (so the hub still sees the module as installed)
|
|
1679
|
+
// but advertises no /vault/<name> path. The hub must tolerate empty-paths
|
|
1680
|
+
// rows and skip them rather than resolving a phantom "default". Warnings
|
|
1681
|
+
// go to stderr; status lines stay ours.
|
|
1673
1682
|
selfRegister({
|
|
1674
1683
|
version: pkg.version,
|
|
1675
1684
|
warn: (msg) => console.error(`Warning: ${msg}`),
|
|
@@ -3330,6 +3339,163 @@ async function cmdExport(args: string[]) {
|
|
|
3330
3339
|
await new Promise(() => {});
|
|
3331
3340
|
}
|
|
3332
3341
|
|
|
3342
|
+
// ---------------------------------------------------------------------------
|
|
3343
|
+
// `parachute-vault history` — surface the vault's git write history (vault#300)
|
|
3344
|
+
// ---------------------------------------------------------------------------
|
|
3345
|
+
|
|
3346
|
+
/**
|
|
3347
|
+
* `parachute-vault history [--note <path>] [--limit N] [--vault <name>] [--json]`
|
|
3348
|
+
*
|
|
3349
|
+
* Surfaces the mirror's git commit log — the vault is already git-backed
|
|
3350
|
+
* (one file per note), so `git log` IS a tamper-evident, diffable write
|
|
3351
|
+
* history. This CLI front-door wraps the SAME `readMirrorHistory` helper the
|
|
3352
|
+
* REST `/history` endpoint uses (no drift between the two surfaces).
|
|
3353
|
+
*
|
|
3354
|
+
* `--note <path>` scopes to a single note's history (`git log --follow --
|
|
3355
|
+
* <path>.md`). `--show <sha>` (with `--note`) prints that note's content at
|
|
3356
|
+
* a past revision (`git show <sha>:<path>.md`).
|
|
3357
|
+
*/
|
|
3358
|
+
async function cmdHistory(args: string[]) {
|
|
3359
|
+
let vaultName = "default";
|
|
3360
|
+
let notePath: string | undefined;
|
|
3361
|
+
let limit: number | undefined;
|
|
3362
|
+
let showSha: string | undefined;
|
|
3363
|
+
let asJson = false;
|
|
3364
|
+
|
|
3365
|
+
for (let i = 0; i < args.length; i++) {
|
|
3366
|
+
const arg = args[i]!;
|
|
3367
|
+
if (arg === "--vault") {
|
|
3368
|
+
const v = args[++i];
|
|
3369
|
+
if (!v) {
|
|
3370
|
+
console.error("--vault requires a value.");
|
|
3371
|
+
process.exit(1);
|
|
3372
|
+
}
|
|
3373
|
+
vaultName = v;
|
|
3374
|
+
} else if (arg === "--note") {
|
|
3375
|
+
const v = args[++i];
|
|
3376
|
+
if (!v) {
|
|
3377
|
+
console.error("--note requires a note path.");
|
|
3378
|
+
process.exit(1);
|
|
3379
|
+
}
|
|
3380
|
+
notePath = v;
|
|
3381
|
+
} else if (arg === "--limit") {
|
|
3382
|
+
const v = args[++i];
|
|
3383
|
+
if (!v) {
|
|
3384
|
+
console.error("--limit requires a number.");
|
|
3385
|
+
process.exit(1);
|
|
3386
|
+
}
|
|
3387
|
+
const n = Number(v);
|
|
3388
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
3389
|
+
console.error(`--limit: must be a positive integer (got '${v}')`);
|
|
3390
|
+
process.exit(1);
|
|
3391
|
+
}
|
|
3392
|
+
limit = n;
|
|
3393
|
+
} else if (arg === "--show") {
|
|
3394
|
+
const v = args[++i];
|
|
3395
|
+
if (!v) {
|
|
3396
|
+
console.error("--show requires a commit sha.");
|
|
3397
|
+
process.exit(1);
|
|
3398
|
+
}
|
|
3399
|
+
showSha = v;
|
|
3400
|
+
} else if (arg === "--json") {
|
|
3401
|
+
asJson = true;
|
|
3402
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
3403
|
+
printHistoryUsage();
|
|
3404
|
+
return;
|
|
3405
|
+
} else {
|
|
3406
|
+
console.error(`Unknown argument: ${arg}`);
|
|
3407
|
+
printHistoryUsage();
|
|
3408
|
+
process.exit(1);
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
if (showSha && !notePath) {
|
|
3413
|
+
console.error("--show <sha> requires --note <path> (which file to read at that revision).");
|
|
3414
|
+
process.exit(1);
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
const config = readVaultConfig(vaultName);
|
|
3418
|
+
if (!config) {
|
|
3419
|
+
console.error(`Vault "${vaultName}" not found. Available: ${listVaults().join(", ") || "(none)"}.`);
|
|
3420
|
+
process.exit(1);
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
// Resolve the mirror dir the same way the server does: per-vault mirror
|
|
3424
|
+
// config (or defaults) → resolveMirrorPath against the vault's data dir.
|
|
3425
|
+
const mirrorConfig = readMirrorConfigForVault(vaultName) ?? defaultMirrorConfig();
|
|
3426
|
+
const mirrorPath = resolveMirrorPath(vaultDir(vaultName), mirrorConfig);
|
|
3427
|
+
if (!mirrorPath || !existsSync(mirrorPath)) {
|
|
3428
|
+
console.error(
|
|
3429
|
+
`No git history for vault "${vaultName}" — the mirror isn't initialized yet.\n` +
|
|
3430
|
+
`Enable history (backup) so writes are recorded, then re-run.`,
|
|
3431
|
+
);
|
|
3432
|
+
process.exit(1);
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
// Preflight git — friendly, actionable message instead of a raw spawn throw.
|
|
3436
|
+
try {
|
|
3437
|
+
ensureGitAvailable();
|
|
3438
|
+
} catch (err) {
|
|
3439
|
+
if (err instanceof GitNotInstalledError) {
|
|
3440
|
+
console.error(err.message);
|
|
3441
|
+
process.exit(1);
|
|
3442
|
+
}
|
|
3443
|
+
throw err;
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
// `--show` path: print one note's content at a past revision.
|
|
3447
|
+
if (showSha && notePath) {
|
|
3448
|
+
const content = await showMirrorRevision(mirrorPath, showSha, notePath);
|
|
3449
|
+
if (content === null) {
|
|
3450
|
+
console.error(
|
|
3451
|
+
`No content for "${notePath}" at ${showSha} — the sha may be unknown, the note may not have existed at that commit, or the path is invalid.`,
|
|
3452
|
+
);
|
|
3453
|
+
process.exit(1);
|
|
3454
|
+
}
|
|
3455
|
+
process.stdout.write(content);
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
const history = await readMirrorHistory(mirrorPath, { notePath, limit });
|
|
3460
|
+
|
|
3461
|
+
if (asJson) {
|
|
3462
|
+
console.log(JSON.stringify(history, null, 2));
|
|
3463
|
+
return;
|
|
3464
|
+
}
|
|
3465
|
+
|
|
3466
|
+
if (history.length === 0) {
|
|
3467
|
+
if (notePath) {
|
|
3468
|
+
console.log(`No history for note "${notePath}" in vault "${vaultName}".`);
|
|
3469
|
+
} else {
|
|
3470
|
+
console.log(`No history yet for vault "${vaultName}".`);
|
|
3471
|
+
}
|
|
3472
|
+
return;
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
const scope = notePath ? ` for "${notePath}"` : "";
|
|
3476
|
+
console.log(`Git history for vault "${vaultName}"${scope} (${history.length} commit${history.length === 1 ? "" : "s"}):\n`);
|
|
3477
|
+
for (const entry of history) {
|
|
3478
|
+
// Short sha + date + subject — one line per commit, the `git log --oneline`
|
|
3479
|
+
// shape an operator expects.
|
|
3480
|
+
console.log(` ${entry.sha.slice(0, 8)} ${entry.date} ${entry.message}`);
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
function printHistoryUsage(): void {
|
|
3485
|
+
console.error(
|
|
3486
|
+
"Usage: parachute-vault history [--note <path>] [--limit N] [--vault <name>] [--json]\n" +
|
|
3487
|
+
" [--note <path> --show <sha>]",
|
|
3488
|
+
);
|
|
3489
|
+
console.error("\nSurface the vault's git write history. The vault is already git-backed via the");
|
|
3490
|
+
console.error("mirror (one file per note), so `git log` IS a tamper-evident, diffable history.");
|
|
3491
|
+
console.error("\nOptions:");
|
|
3492
|
+
console.error(" --vault <name> Vault to read (default: 'default')");
|
|
3493
|
+
console.error(" --note <path> Scope to a single note's history (git log --follow)");
|
|
3494
|
+
console.error(" --limit N Cap the number of commits returned (default 100)");
|
|
3495
|
+
console.error(" --show <sha> With --note: print that note's content at the given revision");
|
|
3496
|
+
console.error(" --json Emit the history as JSON instead of the human-readable list");
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3333
3499
|
// ---------------------------------------------------------------------------
|
|
3334
3500
|
// Schema maintenance — `parachute-vault schema <subcommand>`
|
|
3335
3501
|
// ---------------------------------------------------------------------------
|
|
@@ -4106,6 +4272,15 @@ Import/Export:
|
|
|
4106
4272
|
template via --git-message-template;
|
|
4107
4273
|
--git-push to push after commit)
|
|
4108
4274
|
|
|
4275
|
+
History:
|
|
4276
|
+
parachute-vault history [--vault <name>] Show the vault's git write history (the
|
|
4277
|
+
mirror is git-backed — one file per note,
|
|
4278
|
+
so git log IS a tamper-evident history)
|
|
4279
|
+
parachute-vault history --note <path> Scope to one note's history (git log --follow)
|
|
4280
|
+
parachute-vault history --limit N Cap the number of commits (default 100)
|
|
4281
|
+
parachute-vault history --note <path> --show <sha> Print that note's content at a past revision
|
|
4282
|
+
parachute-vault history --json Emit history as JSON
|
|
4283
|
+
|
|
4109
4284
|
Schema maintenance:
|
|
4110
4285
|
parachute-vault schema prune [--vault <name>] Drop orphaned indexed-field columns +
|
|
4111
4286
|
indexes whose declaring tags no longer
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the git-history read surface (vault#300).
|
|
3
|
+
*
|
|
4
|
+
* The vault is already git-backed via the mirror — one file per note — so
|
|
5
|
+
* `git log` IS the write history. These tests exercise:
|
|
6
|
+
* - the `readMirrorHistory` / `showMirrorRevision` / `noteHistoryPathspec`
|
|
7
|
+
* helpers (mirror-manager.ts) against a real seeded git repo in a tmpdir
|
|
8
|
+
* - the `handleMirrorHistory` / `handleMirrorHistoryShow` REST handlers
|
|
9
|
+
* (mirror-routes.ts), including the not-initialized + path-scoped cases
|
|
10
|
+
*
|
|
11
|
+
* Routing-level admin-gate enforcement lives in routing.test.ts (alongside
|
|
12
|
+
* the sibling mirror routes); these are the after-auth handler + helper
|
|
13
|
+
* tests, matching the mirror-manager / mirror-routes test split.
|
|
14
|
+
*
|
|
15
|
+
* Like the other mirror tests: real tempdirs + real `git` so we exercise
|
|
16
|
+
* the actual log/show behavior, not a mock.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, test, expect, afterEach, afterAll } from "bun:test";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
HISTORY_MAX_LIMIT,
|
|
26
|
+
noteHistoryPathspec,
|
|
27
|
+
readMirrorHistory,
|
|
28
|
+
showMirrorRevision,
|
|
29
|
+
MirrorManager,
|
|
30
|
+
type MirrorDeps,
|
|
31
|
+
} from "./mirror-manager.ts";
|
|
32
|
+
import {
|
|
33
|
+
handleMirrorHistory,
|
|
34
|
+
handleMirrorHistoryShow,
|
|
35
|
+
} from "./mirror-routes.ts";
|
|
36
|
+
import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
|
|
37
|
+
|
|
38
|
+
// Keep HOME / PARACHUTE_HOME from leaking between test files — same pattern
|
|
39
|
+
// as mirror-manager.test.ts.
|
|
40
|
+
const ORIG_HOME = process.env.HOME;
|
|
41
|
+
const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
if (ORIG_HOME === undefined) delete process.env.HOME;
|
|
44
|
+
else process.env.HOME = ORIG_HOME;
|
|
45
|
+
if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
|
|
46
|
+
else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
|
|
47
|
+
});
|
|
48
|
+
afterAll(() => {
|
|
49
|
+
if (ORIG_HOME === undefined) delete process.env.HOME;
|
|
50
|
+
else process.env.HOME = ORIG_HOME;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function tmp(prefix: string): string {
|
|
54
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function initRepo(dir: string): void {
|
|
58
|
+
Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
|
|
59
|
+
Bun.spawnSync(["git", "config", "user.email", "t@p.computer"], { cwd: dir });
|
|
60
|
+
Bun.spawnSync(["git", "config", "user.name", "T P"], { cwd: dir });
|
|
61
|
+
Bun.spawnSync(["git", "config", "commit.gpgsign", "false"], { cwd: dir });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Write a file (creating parent dirs), `git add -A`, commit with `message`. */
|
|
65
|
+
function commitFile(dir: string, relPath: string, content: string, message: string): void {
|
|
66
|
+
const full = path.join(dir, relPath);
|
|
67
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
68
|
+
fs.writeFileSync(full, content);
|
|
69
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: dir });
|
|
70
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", message], { cwd: dir });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// noteHistoryPathspec — normalize + traversal guard
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
describe("noteHistoryPathspec", () => {
|
|
78
|
+
test("appends .md to a bare note path", () => {
|
|
79
|
+
expect(noteHistoryPathspec("Inbox/today")).toBe("Inbox/today.md");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("is idempotent on an already-suffixed path", () => {
|
|
83
|
+
expect(noteHistoryPathspec("Inbox/today.md")).toBe("Inbox/today.md");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("rejects traversal", () => {
|
|
87
|
+
expect(noteHistoryPathspec("../etc/passwd")).toBeNull();
|
|
88
|
+
expect(noteHistoryPathspec("Inbox/../../secret")).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects absolute paths", () => {
|
|
92
|
+
expect(noteHistoryPathspec("/etc/passwd")).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("rejects empty / whitespace", () => {
|
|
96
|
+
expect(noteHistoryPathspec("")).toBeNull();
|
|
97
|
+
expect(noteHistoryPathspec(" ")).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// readMirrorHistory — the core git-log read
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
describe("readMirrorHistory", () => {
|
|
106
|
+
test("returns commits newest-first with sha/date/message", async () => {
|
|
107
|
+
const dir = tmp("vault-history-");
|
|
108
|
+
initRepo(dir);
|
|
109
|
+
commitFile(dir, "Inbox/one.md", "one", "export: first (1 note)");
|
|
110
|
+
commitFile(dir, "Inbox/two.md", "two", "export: second (1 note)");
|
|
111
|
+
|
|
112
|
+
const history = await readMirrorHistory(dir);
|
|
113
|
+
expect(history.length).toBe(2);
|
|
114
|
+
// Newest first.
|
|
115
|
+
expect(history[0]!.message).toBe("export: second (1 note)");
|
|
116
|
+
expect(history[1]!.message).toBe("export: first (1 note)");
|
|
117
|
+
// Each entry has a full sha + an ISO date.
|
|
118
|
+
for (const entry of history) {
|
|
119
|
+
expect(entry.sha).toMatch(/^[0-9a-f]{40}$/);
|
|
120
|
+
expect(Number.isNaN(Date.parse(entry.date))).toBe(false);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("?path filters to a single note's commits, --follow across renames", async () => {
|
|
125
|
+
const dir = tmp("vault-history-path-");
|
|
126
|
+
initRepo(dir);
|
|
127
|
+
commitFile(dir, "Notes/alpha.md", "a1", "export: alpha created");
|
|
128
|
+
commitFile(dir, "Notes/beta.md", "b1", "export: beta created");
|
|
129
|
+
commitFile(dir, "Notes/alpha.md", "a2", "export: alpha edited");
|
|
130
|
+
|
|
131
|
+
const alpha = await readMirrorHistory(dir, { notePath: "Notes/alpha" });
|
|
132
|
+
// Two commits touched alpha; beta's commit is excluded.
|
|
133
|
+
expect(alpha.length).toBe(2);
|
|
134
|
+
expect(alpha.map((e) => e.message)).toEqual([
|
|
135
|
+
"export: alpha edited",
|
|
136
|
+
"export: alpha created",
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const beta = await readMirrorHistory(dir, { notePath: "Notes/beta" });
|
|
140
|
+
expect(beta.length).toBe(1);
|
|
141
|
+
expect(beta[0]!.message).toBe("export: beta created");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("limit caps the number of commits returned", async () => {
|
|
145
|
+
const dir = tmp("vault-history-limit-");
|
|
146
|
+
initRepo(dir);
|
|
147
|
+
for (let i = 0; i < 5; i++) {
|
|
148
|
+
commitFile(dir, `Notes/n${i}.md`, `v${i}`, `export: commit ${i}`);
|
|
149
|
+
}
|
|
150
|
+
const limited = await readMirrorHistory(dir, { limit: 2 });
|
|
151
|
+
expect(limited.length).toBe(2);
|
|
152
|
+
// Newest two.
|
|
153
|
+
expect(limited[0]!.message).toBe("export: commit 4");
|
|
154
|
+
expect(limited[1]!.message).toBe("export: commit 3");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("an out-of-range limit is clamped to HISTORY_MAX_LIMIT (never exceeds the ceiling)", async () => {
|
|
158
|
+
const dir = tmp("vault-history-ceiling-");
|
|
159
|
+
initRepo(dir);
|
|
160
|
+
// Seed just a few commits — the assertion is on the ARG cap, not commit
|
|
161
|
+
// count: an absurd limit must never spawn `git log` with --max-count
|
|
162
|
+
// above the hard ceiling. With <ceiling commits the result is bounded by
|
|
163
|
+
// commit count, so we assert it's never MORE than the ceiling.
|
|
164
|
+
commitFile(dir, "Notes/a.md", "a", "export: a");
|
|
165
|
+
commitFile(dir, "Notes/b.md", "b", "export: b");
|
|
166
|
+
const history = await readMirrorHistory(dir, { limit: 99_999_999 });
|
|
167
|
+
expect(history.length).toBeLessThanOrEqual(HISTORY_MAX_LIMIT);
|
|
168
|
+
// Sanity: a huge limit still returns the (few) real commits.
|
|
169
|
+
expect(history.length).toBe(2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("no commits yet → empty list, not an error", async () => {
|
|
173
|
+
const dir = tmp("vault-history-empty-");
|
|
174
|
+
initRepo(dir); // repo but no commits
|
|
175
|
+
const history = await readMirrorHistory(dir);
|
|
176
|
+
expect(history).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("not a git repo → empty list, not an error", async () => {
|
|
180
|
+
const dir = tmp("vault-history-nonrepo-");
|
|
181
|
+
// No `git init`.
|
|
182
|
+
const history = await readMirrorHistory(dir);
|
|
183
|
+
expect(history).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a path with no history → empty list", async () => {
|
|
187
|
+
const dir = tmp("vault-history-nopath-");
|
|
188
|
+
initRepo(dir);
|
|
189
|
+
commitFile(dir, "Notes/exists.md", "x", "export: exists");
|
|
190
|
+
const history = await readMirrorHistory(dir, { notePath: "Notes/ghost" });
|
|
191
|
+
expect(history).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("an unsafe path → empty list (never an unscoped log)", async () => {
|
|
195
|
+
const dir = tmp("vault-history-unsafe-");
|
|
196
|
+
initRepo(dir);
|
|
197
|
+
commitFile(dir, "Notes/a.md", "a", "export: a");
|
|
198
|
+
commitFile(dir, "Notes/b.md", "b", "export: b");
|
|
199
|
+
// A traversal path must NOT fall back to the full (unscoped) log.
|
|
200
|
+
const history = await readMirrorHistory(dir, { notePath: "../../../etc/passwd" });
|
|
201
|
+
expect(history).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("redacts tokens that appear in a commit subject", async () => {
|
|
205
|
+
const dir = tmp("vault-history-redact-");
|
|
206
|
+
initRepo(dir);
|
|
207
|
+
commitFile(dir, "Notes/a.md", "a", "synced from https://x-access-token:ghp_supersecrettoken123@github.com/a/b");
|
|
208
|
+
const history = await readMirrorHistory(dir);
|
|
209
|
+
expect(history.length).toBe(1);
|
|
210
|
+
expect(history[0]!.message).not.toContain("ghp_supersecrettoken123");
|
|
211
|
+
expect(history[0]!.message).toContain("***");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// showMirrorRevision — read a past revision of a note
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
describe("showMirrorRevision", () => {
|
|
220
|
+
test("returns the file content at a past revision", async () => {
|
|
221
|
+
const dir = tmp("vault-show-");
|
|
222
|
+
initRepo(dir);
|
|
223
|
+
commitFile(dir, "Notes/a.md", "version one", "export: a v1");
|
|
224
|
+
// Capture the sha at v1.
|
|
225
|
+
const v1 = (await readMirrorHistory(dir))[0]!.sha;
|
|
226
|
+
commitFile(dir, "Notes/a.md", "version two", "export: a v2");
|
|
227
|
+
|
|
228
|
+
const past = await showMirrorRevision(dir, v1, "Notes/a");
|
|
229
|
+
expect(past).toBe("version one");
|
|
230
|
+
|
|
231
|
+
const current = await showMirrorRevision(dir, (await readMirrorHistory(dir))[0]!.sha, "Notes/a");
|
|
232
|
+
expect(current).toBe("version two");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("unknown sha → null", async () => {
|
|
236
|
+
const dir = tmp("vault-show-badsha-");
|
|
237
|
+
initRepo(dir);
|
|
238
|
+
commitFile(dir, "Notes/a.md", "x", "export: a");
|
|
239
|
+
const result = await showMirrorRevision(dir, "deadbeef", "Notes/a");
|
|
240
|
+
expect(result).toBeNull();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("non-hex / option-looking sha → null (no smuggled ref)", async () => {
|
|
244
|
+
const dir = tmp("vault-show-refsmuggle-");
|
|
245
|
+
initRepo(dir);
|
|
246
|
+
commitFile(dir, "Notes/a.md", "x", "export: a");
|
|
247
|
+
expect(await showMirrorRevision(dir, "HEAD", "Notes/a")).toBeNull();
|
|
248
|
+
expect(await showMirrorRevision(dir, "--output=/tmp/x", "Notes/a")).toBeNull();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("unsafe path → null", async () => {
|
|
252
|
+
const dir = tmp("vault-show-unsafe-");
|
|
253
|
+
initRepo(dir);
|
|
254
|
+
commitFile(dir, "Notes/a.md", "x", "export: a");
|
|
255
|
+
const sha = (await readMirrorHistory(dir))[0]!.sha;
|
|
256
|
+
expect(await showMirrorRevision(dir, sha, "../../../etc/passwd")).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Route handlers — handleMirrorHistory / handleMirrorHistoryShow
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Build a MirrorManager whose status reports `mirror_path = mirrorPath`
|
|
266
|
+
* without standing up the full lifecycle — the history handlers only read
|
|
267
|
+
* `getStatus().mirror_path`, so we start a minimal enabled internal mirror
|
|
268
|
+
* pointed at a tmp repo we control.
|
|
269
|
+
*
|
|
270
|
+
* Simpler: construct the manager with fake deps and force the status by
|
|
271
|
+
* starting it against an internal mirror dir we then seed. But the history
|
|
272
|
+
* read is path-only, so we instead use a tiny manager whose deps resolve the
|
|
273
|
+
* internal mirror under a tmp PARACHUTE_HOME and start it (which bootstraps a
|
|
274
|
+
* real git repo), then commit into that repo.
|
|
275
|
+
*/
|
|
276
|
+
function makeStartedManager(home: string): { manager: MirrorManager; deps: MirrorDeps } {
|
|
277
|
+
process.env.PARACHUTE_HOME = home;
|
|
278
|
+
process.env.HOME = home;
|
|
279
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
280
|
+
let stored: MirrorConfig | undefined = { ...defaultMirrorConfig(), enabled: true };
|
|
281
|
+
const deps: MirrorDeps = {
|
|
282
|
+
vaultName: "default",
|
|
283
|
+
runExport: async () => ({ notes: 0 }),
|
|
284
|
+
firstChangedNoteTitle: async () => "",
|
|
285
|
+
readMirrorConfig: () => stored,
|
|
286
|
+
writeMirrorConfig: (c) => {
|
|
287
|
+
stored = c;
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
return { manager: new MirrorManager(deps), deps };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
describe("handleMirrorHistory route", () => {
|
|
294
|
+
test("not-initialized mirror → 200 empty history + note (not 500)", async () => {
|
|
295
|
+
const home = tmp("vault-route-noinit-");
|
|
296
|
+
const { manager } = makeStartedManager(home);
|
|
297
|
+
// Never started → no mirror_path resolved.
|
|
298
|
+
const res = await handleMirrorHistory(
|
|
299
|
+
new Request("http://localhost/vault/default/.parachute/mirror/history"),
|
|
300
|
+
manager,
|
|
301
|
+
);
|
|
302
|
+
expect(res.status).toBe(200);
|
|
303
|
+
const body = (await res.json()) as { history: unknown[]; mirror_path: null; note?: string };
|
|
304
|
+
expect(body.history).toEqual([]);
|
|
305
|
+
expect(body.mirror_path).toBeNull();
|
|
306
|
+
expect(body.note).toBeDefined();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("started mirror with commits → 200 history list", async () => {
|
|
310
|
+
const home = tmp("vault-route-hist-");
|
|
311
|
+
const { manager } = makeStartedManager(home);
|
|
312
|
+
await manager.start();
|
|
313
|
+
const mirrorPath = manager.getStatus().mirror_path!;
|
|
314
|
+
expect(mirrorPath).toBeTruthy();
|
|
315
|
+
commitFile(mirrorPath, "Notes/a.md", "a", "export: a created");
|
|
316
|
+
commitFile(mirrorPath, "Notes/b.md", "b", "export: b created");
|
|
317
|
+
|
|
318
|
+
const res = await handleMirrorHistory(
|
|
319
|
+
new Request("http://localhost/vault/default/.parachute/mirror/history"),
|
|
320
|
+
manager,
|
|
321
|
+
);
|
|
322
|
+
expect(res.status).toBe(200);
|
|
323
|
+
const body = (await res.json()) as {
|
|
324
|
+
history: Array<{ sha: string; date: string; message: string }>;
|
|
325
|
+
mirror_path: string;
|
|
326
|
+
};
|
|
327
|
+
// Includes the bootstrap commit + our two; newest first.
|
|
328
|
+
expect(body.history.length).toBeGreaterThanOrEqual(3);
|
|
329
|
+
expect(body.history[0]!.message).toBe("export: b created");
|
|
330
|
+
expect(body.mirror_path).toBe(mirrorPath);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("?path scopes to one note", async () => {
|
|
334
|
+
const home = tmp("vault-route-histpath-");
|
|
335
|
+
const { manager } = makeStartedManager(home);
|
|
336
|
+
await manager.start();
|
|
337
|
+
const mirrorPath = manager.getStatus().mirror_path!;
|
|
338
|
+
commitFile(mirrorPath, "Notes/alpha.md", "a1", "export: alpha");
|
|
339
|
+
commitFile(mirrorPath, "Notes/beta.md", "b1", "export: beta");
|
|
340
|
+
|
|
341
|
+
const res = await handleMirrorHistory(
|
|
342
|
+
new Request("http://localhost/vault/default/.parachute/mirror/history?path=Notes/alpha"),
|
|
343
|
+
manager,
|
|
344
|
+
);
|
|
345
|
+
const body = (await res.json()) as {
|
|
346
|
+
history: Array<{ message: string }>;
|
|
347
|
+
path?: string;
|
|
348
|
+
};
|
|
349
|
+
expect(body.path).toBe("Notes/alpha");
|
|
350
|
+
expect(body.history.length).toBe(1);
|
|
351
|
+
expect(body.history[0]!.message).toBe("export: alpha");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("?limit caps the count", async () => {
|
|
355
|
+
const home = tmp("vault-route-histlimit-");
|
|
356
|
+
const { manager } = makeStartedManager(home);
|
|
357
|
+
await manager.start();
|
|
358
|
+
const mirrorPath = manager.getStatus().mirror_path!;
|
|
359
|
+
for (let i = 0; i < 4; i++) commitFile(mirrorPath, `Notes/n${i}.md`, `v${i}`, `export: c${i}`);
|
|
360
|
+
|
|
361
|
+
const res = await handleMirrorHistory(
|
|
362
|
+
new Request("http://localhost/vault/default/.parachute/mirror/history?limit=2"),
|
|
363
|
+
manager,
|
|
364
|
+
);
|
|
365
|
+
const body = (await res.json()) as { history: unknown[] };
|
|
366
|
+
expect(body.history.length).toBe(2);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("invalid limit → 400", async () => {
|
|
370
|
+
const home = tmp("vault-route-histbadlimit-");
|
|
371
|
+
const { manager } = makeStartedManager(home);
|
|
372
|
+
await manager.start();
|
|
373
|
+
const res = await handleMirrorHistory(
|
|
374
|
+
new Request("http://localhost/vault/default/.parachute/mirror/history?limit=-3"),
|
|
375
|
+
manager,
|
|
376
|
+
);
|
|
377
|
+
expect(res.status).toBe(400);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("handleMirrorHistoryShow route", () => {
|
|
382
|
+
test("returns a past revision's content", async () => {
|
|
383
|
+
const home = tmp("vault-route-show-");
|
|
384
|
+
const { manager } = makeStartedManager(home);
|
|
385
|
+
await manager.start();
|
|
386
|
+
const mirrorPath = manager.getStatus().mirror_path!;
|
|
387
|
+
commitFile(mirrorPath, "Notes/a.md", "v1 content", "export: a v1");
|
|
388
|
+
const sha = (await readMirrorHistory(mirrorPath, { notePath: "Notes/a" }))[0]!.sha;
|
|
389
|
+
commitFile(mirrorPath, "Notes/a.md", "v2 content", "export: a v2");
|
|
390
|
+
|
|
391
|
+
const res = await handleMirrorHistoryShow(
|
|
392
|
+
new Request(
|
|
393
|
+
`http://localhost/vault/default/.parachute/mirror/history/show?sha=${sha}&path=Notes/a`,
|
|
394
|
+
),
|
|
395
|
+
manager,
|
|
396
|
+
);
|
|
397
|
+
expect(res.status).toBe(200);
|
|
398
|
+
const body = (await res.json()) as { content: string; sha: string; path: string };
|
|
399
|
+
expect(body.content).toBe("v1 content");
|
|
400
|
+
expect(body.path).toBe("Notes/a");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("missing sha/path → 400", async () => {
|
|
404
|
+
const home = tmp("vault-route-show-missing-");
|
|
405
|
+
const { manager } = makeStartedManager(home);
|
|
406
|
+
await manager.start();
|
|
407
|
+
const res = await handleMirrorHistoryShow(
|
|
408
|
+
new Request("http://localhost/vault/default/.parachute/mirror/history/show?sha=abc123"),
|
|
409
|
+
manager,
|
|
410
|
+
);
|
|
411
|
+
expect(res.status).toBe(400);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("unknown sha/path → 404", async () => {
|
|
415
|
+
const home = tmp("vault-route-show-404-");
|
|
416
|
+
const { manager } = makeStartedManager(home);
|
|
417
|
+
await manager.start();
|
|
418
|
+
const res = await handleMirrorHistoryShow(
|
|
419
|
+
new Request(
|
|
420
|
+
"http://localhost/vault/default/.parachute/mirror/history/show?sha=deadbeef&path=Notes/ghost",
|
|
421
|
+
),
|
|
422
|
+
manager,
|
|
423
|
+
);
|
|
424
|
+
expect(res.status).toBe(404);
|
|
425
|
+
});
|
|
426
|
+
});
|
package/src/mirror-manager.ts
CHANGED
|
@@ -369,6 +369,170 @@ async function readCurrentOrigin(repoDir: string): Promise<string | null> {
|
|
|
369
369
|
return url.length > 0 ? url : null;
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// Git history read surface (vault#300)
|
|
374
|
+
//
|
|
375
|
+
// The vault is already git-backed via the mirror — one file per note, so
|
|
376
|
+
// `git log` IS a tamper-evident, time-travelable, diffable write history.
|
|
377
|
+
// These helpers SURFACE that history through the admin-gated REST + CLI
|
|
378
|
+
// read paths. No write-path change, no schema change, no new table — the
|
|
379
|
+
// history already exists on disk; we just read it.
|
|
380
|
+
//
|
|
381
|
+
// Same Bun.spawn-git shape as `readCommitsUnpushed` / `readCurrentOrigin`
|
|
382
|
+
// above (spawn array argv, capture stdout, parse) so REST + CLI share ONE
|
|
383
|
+
// implementation and there's no drift between them.
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
/** A single commit in the mirror's history. */
|
|
387
|
+
export interface HistoryEntry {
|
|
388
|
+
/** Full commit sha. */
|
|
389
|
+
sha: string;
|
|
390
|
+
/** ISO-8601 author date (`%aI`). */
|
|
391
|
+
date: string;
|
|
392
|
+
/** Commit subject line (`%s`). */
|
|
393
|
+
message: string;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Default cap on how many commits a history read returns. */
|
|
397
|
+
export const HISTORY_DEFAULT_LIMIT = 100;
|
|
398
|
+
/** Hard ceiling — even an explicit `?limit=` can't exceed this. */
|
|
399
|
+
export const HISTORY_MAX_LIMIT = 1000;
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Normalize + validate a note path for use as a `git log -- <path>.md`
|
|
403
|
+
* pathspec. Returns the `<path>.md` argv token, or null when the path is
|
|
404
|
+
* unsafe / empty.
|
|
405
|
+
*
|
|
406
|
+
* The path goes into an ARRAY-spawn argv (no shell), so this isn't guarding
|
|
407
|
+
* against shell injection — it's guarding against directory traversal
|
|
408
|
+
* (`..`) and absolute paths escaping the mirror dir, plus normalizing the
|
|
409
|
+
* leading-slash + trailing-`.md` shape so the pathspec matches the file the
|
|
410
|
+
* exporter actually wrote (`<note.path>.md`, see portable-md.ts).
|
|
411
|
+
*/
|
|
412
|
+
export function noteHistoryPathspec(notePath: string): string | null {
|
|
413
|
+
const trimmed = notePath.trim();
|
|
414
|
+
if (trimmed.length === 0) return null;
|
|
415
|
+
// Reject absolute paths and traversal — the pathspec must stay inside the
|
|
416
|
+
// mirror working tree. A `..` segment (or a leading `/`) could otherwise
|
|
417
|
+
// point `git log` at files outside the vault's note tree.
|
|
418
|
+
if (trimmed.startsWith("/")) return null;
|
|
419
|
+
const segments = trimmed.split("/");
|
|
420
|
+
if (segments.some((s) => s === "..")) return null;
|
|
421
|
+
// The exporter writes `<note.path>.md`. Accept a path the caller already
|
|
422
|
+
// suffixed with `.md` (idempotent) or bare; emit the `.md` form either way.
|
|
423
|
+
const base = trimmed.endsWith(".md") ? trimmed.slice(0, -3) : trimmed;
|
|
424
|
+
if (base.length === 0) return null;
|
|
425
|
+
return `${base}.md`;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Read the mirror's commit history via `git log`. Optionally scoped to a
|
|
430
|
+
* single note's file (`opts.notePath` → `git log --follow -- <path>.md`).
|
|
431
|
+
*
|
|
432
|
+
* Returns an ordered (newest-first) array of `{ sha, date, message }`.
|
|
433
|
+
* Tolerant of the not-yet-a-repo / no-commits-yet cases — those return an
|
|
434
|
+
* empty array rather than throwing, so a freshly-bootstrapped (or never-
|
|
435
|
+
* bootstrapped) mirror reads as "no history yet", not a 500.
|
|
436
|
+
*
|
|
437
|
+
* `git log` field separators: we use `%H` (full sha), `%aI` (ISO author
|
|
438
|
+
* date), `%s` (subject). A literal record separator (`\x1f`) joins the
|
|
439
|
+
* three fields and a literal unit separator (`\x1e`) terminates each record
|
|
440
|
+
* so a multi-line-unfriendly subject (it isn't — `%s` is the subject line
|
|
441
|
+
* only) or a commit message containing our delimiters can't desync parsing.
|
|
442
|
+
*
|
|
443
|
+
* The git binary preflight is the CALLER's responsibility (REST + CLI both
|
|
444
|
+
* call `ensureGitAvailable` before this) — but a missing-repo `git log`
|
|
445
|
+
* exits non-zero, which we map to `[]`, so even an un-preflighted call
|
|
446
|
+
* degrades to "empty" rather than a crash.
|
|
447
|
+
*/
|
|
448
|
+
export async function readMirrorHistory(
|
|
449
|
+
repoDir: string,
|
|
450
|
+
opts: { notePath?: string; limit?: number } = {},
|
|
451
|
+
): Promise<HistoryEntry[]> {
|
|
452
|
+
const limit = Math.min(
|
|
453
|
+
Math.max(1, Math.floor(opts.limit ?? HISTORY_DEFAULT_LIMIT)),
|
|
454
|
+
HISTORY_MAX_LIMIT,
|
|
455
|
+
);
|
|
456
|
+
const FIELD_SEP = "\x1f";
|
|
457
|
+
const RECORD_SEP = "\x1e";
|
|
458
|
+
const format = `%H${FIELD_SEP}%aI${FIELD_SEP}%s${RECORD_SEP}`;
|
|
459
|
+
const args = [
|
|
460
|
+
"log",
|
|
461
|
+
`--max-count=${limit}`,
|
|
462
|
+
`--pretty=format:${format}`,
|
|
463
|
+
];
|
|
464
|
+
if (opts.notePath) {
|
|
465
|
+
const spec = noteHistoryPathspec(opts.notePath);
|
|
466
|
+
// An unsafe / empty path yields no history rather than an unscoped log.
|
|
467
|
+
if (spec === null) return [];
|
|
468
|
+
// `--follow` traces the file across renames (the vault renames note
|
|
469
|
+
// files when a note's path changes). `--` ends the option list so the
|
|
470
|
+
// pathspec is never mistaken for a flag.
|
|
471
|
+
args.push("--follow", "--", spec);
|
|
472
|
+
}
|
|
473
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
474
|
+
cwd: repoDir,
|
|
475
|
+
stdout: "pipe",
|
|
476
|
+
stderr: "pipe",
|
|
477
|
+
});
|
|
478
|
+
const exitCode = await proc.exited;
|
|
479
|
+
// Non-zero: not a git repo, no commits yet, or a path with no history.
|
|
480
|
+
// All map to "empty history" — never a thrown error.
|
|
481
|
+
if (exitCode !== 0) return [];
|
|
482
|
+
const out = new TextDecoder().decode(
|
|
483
|
+
await new Response(proc.stdout).arrayBuffer(),
|
|
484
|
+
);
|
|
485
|
+
const entries: HistoryEntry[] = [];
|
|
486
|
+
for (const record of out.split(RECORD_SEP)) {
|
|
487
|
+
const trimmed = record.trim();
|
|
488
|
+
if (trimmed.length === 0) continue;
|
|
489
|
+
const [sha, date, ...rest] = trimmed.split(FIELD_SEP);
|
|
490
|
+
if (!sha || !date) continue;
|
|
491
|
+
entries.push({
|
|
492
|
+
sha,
|
|
493
|
+
date,
|
|
494
|
+
// `%s` never contains FIELD_SEP, but rejoin defensively so a delimiter
|
|
495
|
+
// that somehow slipped into a subject doesn't truncate the message.
|
|
496
|
+
message: redactToken(rest.join(FIELD_SEP)),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return entries;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Read a single past revision of a note file: `git show <sha>:<path>.md`.
|
|
504
|
+
* Returns the file content at that commit, or null when the path is unsafe,
|
|
505
|
+
* the sha/path pair doesn't resolve, or git exits non-zero.
|
|
506
|
+
*
|
|
507
|
+
* The sha + path both go into an array-spawn argv (no shell). The sha is
|
|
508
|
+
* validated to a hex-only shape (git accepts abbreviated shas + symbolic
|
|
509
|
+
* refs, but for this read surface we only allow `[0-9a-f]` so a caller can't
|
|
510
|
+
* smuggle a `..`-style ref or option-looking token). The path runs through
|
|
511
|
+
* the same `noteHistoryPathspec` normalization as the log read.
|
|
512
|
+
*/
|
|
513
|
+
export async function showMirrorRevision(
|
|
514
|
+
repoDir: string,
|
|
515
|
+
sha: string,
|
|
516
|
+
notePath: string,
|
|
517
|
+
): Promise<string | null> {
|
|
518
|
+
const cleanSha = sha.trim();
|
|
519
|
+
// Hex-only, bounded. Covers full + abbreviated shas; rejects refs,
|
|
520
|
+
// ranges, and anything option-looking.
|
|
521
|
+
if (!/^[0-9a-f]{4,64}$/.test(cleanSha)) return null;
|
|
522
|
+
const spec = noteHistoryPathspec(notePath);
|
|
523
|
+
if (spec === null) return null;
|
|
524
|
+
const proc = Bun.spawn(["git", "show", `${cleanSha}:${spec}`], {
|
|
525
|
+
cwd: repoDir,
|
|
526
|
+
stdout: "pipe",
|
|
527
|
+
stderr: "pipe",
|
|
528
|
+
});
|
|
529
|
+
const exitCode = await proc.exited;
|
|
530
|
+
if (exitCode !== 0) return null;
|
|
531
|
+
return new TextDecoder().decode(
|
|
532
|
+
await new Response(proc.stdout).arrayBuffer(),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
372
536
|
/**
|
|
373
537
|
* Singleton lifecycle controller. Holds the active mirror config, the
|
|
374
538
|
* resolved path, hook subscriptions (when sync_mode=events), the
|
package/src/mirror-routes.ts
CHANGED
|
@@ -34,7 +34,11 @@ import {
|
|
|
34
34
|
validateMirrorConfigShape,
|
|
35
35
|
type MirrorConfig,
|
|
36
36
|
} from "./mirror-config.ts";
|
|
37
|
-
import
|
|
37
|
+
import {
|
|
38
|
+
readMirrorHistory,
|
|
39
|
+
showMirrorRevision,
|
|
40
|
+
type MirrorManager,
|
|
41
|
+
} from "./mirror-manager.ts";
|
|
38
42
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
39
43
|
import {
|
|
40
44
|
applyToGitRemote,
|
|
@@ -296,6 +300,183 @@ export async function handleMirrorPushNow(
|
|
|
296
300
|
);
|
|
297
301
|
}
|
|
298
302
|
|
|
303
|
+
/**
|
|
304
|
+
* `GET /vault/<name>/.parachute/mirror/history` — surface the mirror's git
|
|
305
|
+
* commit history (vault#300).
|
|
306
|
+
*
|
|
307
|
+
* The vault is already git-backed via the mirror (one file per note), so
|
|
308
|
+
* `git log` IS a tamper-evident, diffable write history. This endpoint
|
|
309
|
+
* SURFACES it through the admin gate — it's a read-only ops/forensics
|
|
310
|
+
* surface, not a content read path (hence admin, not read, scoped upstream).
|
|
311
|
+
*
|
|
312
|
+
* Query params:
|
|
313
|
+
* - `?path=<note.path>` — scope the log to a single note's file
|
|
314
|
+
* (`git log --follow -- <path>.md`). An unsafe path (traversal /
|
|
315
|
+
* absolute) yields an empty list, never an unscoped log.
|
|
316
|
+
* - `?limit=<n>` — cap the number of commits (default
|
|
317
|
+
* HISTORY_DEFAULT_LIMIT, clamped to HISTORY_MAX_LIMIT).
|
|
318
|
+
*
|
|
319
|
+
* Response: 200 `{ history: [{ sha, date, message }], mirror_path,
|
|
320
|
+
* path?, limit }`. Degrades gracefully:
|
|
321
|
+
* - mirror disabled / no path resolved → 200 with empty history + a
|
|
322
|
+
* `note` explaining the mirror isn't initialized (not a 500).
|
|
323
|
+
* - git not installed → 503 git_not_installed (consistent with the other
|
|
324
|
+
* mirror routes).
|
|
325
|
+
* - no commits yet / path with no history → 200 empty history.
|
|
326
|
+
*/
|
|
327
|
+
export async function handleMirrorHistory(
|
|
328
|
+
req: Request,
|
|
329
|
+
manager: MirrorManager,
|
|
330
|
+
): Promise<Response> {
|
|
331
|
+
const url = new URL(req.url);
|
|
332
|
+
const pathParam = url.searchParams.get("path") ?? undefined;
|
|
333
|
+
const limitParam = url.searchParams.get("limit");
|
|
334
|
+
let limit: number | undefined;
|
|
335
|
+
if (limitParam !== null) {
|
|
336
|
+
const n = Number(limitParam);
|
|
337
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
338
|
+
return Response.json(
|
|
339
|
+
{
|
|
340
|
+
error: "Invalid limit",
|
|
341
|
+
field: "limit",
|
|
342
|
+
message: "limit must be a positive integer.",
|
|
343
|
+
},
|
|
344
|
+
{ status: 400 },
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
limit = n;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const status = manager.getStatus();
|
|
351
|
+
// No resolved mirror path means the mirror was never enabled /
|
|
352
|
+
// bootstrapped for this vault — there's no git repo to read. Return an
|
|
353
|
+
// empty history with an explanatory note rather than a 500/404; the
|
|
354
|
+
// caller (SPA / CLI) renders "no history yet, enable backup to start".
|
|
355
|
+
if (!status.mirror_path) {
|
|
356
|
+
return Response.json(
|
|
357
|
+
{
|
|
358
|
+
history: [],
|
|
359
|
+
mirror_path: null,
|
|
360
|
+
...(pathParam ? { path: pathParam } : {}),
|
|
361
|
+
note: "Mirror is not initialized for this vault — no git history exists yet. Enable history (backup) to start recording write history.",
|
|
362
|
+
},
|
|
363
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Preflight git — the helper degrades a missing repo to [], but a
|
|
368
|
+
// git-less server should get the friendly, actionable 503 (same shape as
|
|
369
|
+
// the import / PUT routes) rather than a silently-empty list.
|
|
370
|
+
try {
|
|
371
|
+
ensureGitAvailable();
|
|
372
|
+
} catch (err) {
|
|
373
|
+
if (err instanceof GitNotInstalledError) {
|
|
374
|
+
return Response.json(
|
|
375
|
+
{
|
|
376
|
+
error: "git not installed",
|
|
377
|
+
error_type: "git_not_installed",
|
|
378
|
+
message: err.message,
|
|
379
|
+
},
|
|
380
|
+
{ status: 503 },
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const history = await readMirrorHistory(status.mirror_path, {
|
|
387
|
+
notePath: pathParam,
|
|
388
|
+
limit,
|
|
389
|
+
});
|
|
390
|
+
return Response.json(
|
|
391
|
+
{
|
|
392
|
+
history,
|
|
393
|
+
mirror_path: status.mirror_path,
|
|
394
|
+
...(pathParam ? { path: pathParam } : {}),
|
|
395
|
+
...(limit !== undefined ? { limit } : {}),
|
|
396
|
+
},
|
|
397
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* `GET /vault/<name>/.parachute/mirror/history/show?sha=<sha>&path=<note.path>`
|
|
403
|
+
* — read a single note file at a past revision (`git show <sha>:<path>.md`).
|
|
404
|
+
* The companion to `/history`: history lists the commits, this reads what a
|
|
405
|
+
* note looked like at one of them. Admin-gated (vault#300).
|
|
406
|
+
*
|
|
407
|
+
* Query params (both required):
|
|
408
|
+
* - `sha` — a commit sha (hex, validated in `showMirrorRevision`).
|
|
409
|
+
* - `path` — the note path (normalized to `<path>.md`).
|
|
410
|
+
*
|
|
411
|
+
* Response:
|
|
412
|
+
* 200 `{ sha, path, content }` — the file content at that revision.
|
|
413
|
+
* 400 — missing sha/path.
|
|
414
|
+
* 404 — the sha/path pair doesn't resolve (unknown sha, note didn't
|
|
415
|
+
* exist at that commit, or an unsafe path).
|
|
416
|
+
* 503 — git not installed.
|
|
417
|
+
*/
|
|
418
|
+
export async function handleMirrorHistoryShow(
|
|
419
|
+
req: Request,
|
|
420
|
+
manager: MirrorManager,
|
|
421
|
+
): Promise<Response> {
|
|
422
|
+
const url = new URL(req.url);
|
|
423
|
+
const sha = url.searchParams.get("sha")?.trim() ?? "";
|
|
424
|
+
const notePath = url.searchParams.get("path")?.trim() ?? "";
|
|
425
|
+
if (sha.length === 0 || notePath.length === 0) {
|
|
426
|
+
return Response.json(
|
|
427
|
+
{
|
|
428
|
+
error: "sha and path required",
|
|
429
|
+
message:
|
|
430
|
+
"Provide both `sha` (a commit from /history) and `path` (the note path) query params.",
|
|
431
|
+
},
|
|
432
|
+
{ status: 400 },
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const status = manager.getStatus();
|
|
437
|
+
if (!status.mirror_path) {
|
|
438
|
+
return Response.json(
|
|
439
|
+
{
|
|
440
|
+
error: "Mirror not initialized",
|
|
441
|
+
message:
|
|
442
|
+
"No git history exists for this vault yet — enable history (backup) first.",
|
|
443
|
+
},
|
|
444
|
+
{ status: 404 },
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
ensureGitAvailable();
|
|
450
|
+
} catch (err) {
|
|
451
|
+
if (err instanceof GitNotInstalledError) {
|
|
452
|
+
return Response.json(
|
|
453
|
+
{
|
|
454
|
+
error: "git not installed",
|
|
455
|
+
error_type: "git_not_installed",
|
|
456
|
+
message: err.message,
|
|
457
|
+
},
|
|
458
|
+
{ status: 503 },
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
throw err;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const content = await showMirrorRevision(status.mirror_path, sha, notePath);
|
|
465
|
+
if (content === null) {
|
|
466
|
+
return Response.json(
|
|
467
|
+
{
|
|
468
|
+
error: "Revision not found",
|
|
469
|
+
message: `No content for ${notePath} at ${sha} — the sha may be unknown, the note may not have existed at that commit, or the path is invalid.`,
|
|
470
|
+
},
|
|
471
|
+
{ status: 404 },
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
return Response.json(
|
|
475
|
+
{ sha, path: notePath, content },
|
|
476
|
+
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
299
480
|
/**
|
|
300
481
|
* Convenience for tests + future callers: build the GET response from a
|
|
301
482
|
* known-good config without needing a real MirrorManager.
|
package/src/routing.test.ts
CHANGED
|
@@ -2266,6 +2266,79 @@ describe("/vault/<name>/.parachute/mirror/run-now — auth + dispatch", () => {
|
|
|
2266
2266
|
});
|
|
2267
2267
|
});
|
|
2268
2268
|
|
|
2269
|
+
// ---------------------------------------------------------------------------
|
|
2270
|
+
// /vault/<name>/.parachute/mirror/history — surface the git write history
|
|
2271
|
+
// (vault#300). Admin-gated (ops/forensics surface). Tests pin the auth gate
|
|
2272
|
+
// matches the sibling mirror routes; helper + handler shape lives in
|
|
2273
|
+
// mirror-history.test.ts.
|
|
2274
|
+
// ---------------------------------------------------------------------------
|
|
2275
|
+
|
|
2276
|
+
describe("/vault/<name>/.parachute/mirror/history — auth + dispatch", () => {
|
|
2277
|
+
test("unauthenticated → 401", async () => {
|
|
2278
|
+
createVault("journal");
|
|
2279
|
+
const p = "/vault/journal/.parachute/mirror/history";
|
|
2280
|
+
const res = await route(new Request(`http://localhost:1940${p}`), p);
|
|
2281
|
+
expect(res.status).toBe(401);
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
test("vault:read token → 403 insufficient_scope (admin required)", async () => {
|
|
2285
|
+
createVault("journal");
|
|
2286
|
+
const fullToken = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
|
|
2287
|
+
const p = "/vault/journal/.parachute/mirror/history";
|
|
2288
|
+
const res = await route(
|
|
2289
|
+
new Request(`http://localhost:1940${p}`, {
|
|
2290
|
+
headers: { authorization: `Bearer ${fullToken}` },
|
|
2291
|
+
}),
|
|
2292
|
+
p,
|
|
2293
|
+
);
|
|
2294
|
+
expect(res.status).toBe(403);
|
|
2295
|
+
const body = (await res.json()) as { error_type?: string; required_scope?: string };
|
|
2296
|
+
expect(body.error_type).toBe("insufficient_scope");
|
|
2297
|
+
expect(body.required_scope).toBe("vault:admin");
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
test("admin token reaches the handler (200 when wired, 503 when not)", async () => {
|
|
2301
|
+
createVault("journal");
|
|
2302
|
+
const token = await createAdminToken("journal");
|
|
2303
|
+
const p = "/vault/journal/.parachute/mirror/history";
|
|
2304
|
+
const res = await route(
|
|
2305
|
+
new Request(`http://localhost:1940${p}`, {
|
|
2306
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2307
|
+
}),
|
|
2308
|
+
p,
|
|
2309
|
+
);
|
|
2310
|
+
// Either way the auth gate passed — that's what this routing-level test pins.
|
|
2311
|
+
expect([200, 503]).toContain(res.status);
|
|
2312
|
+
});
|
|
2313
|
+
|
|
2314
|
+
test("/history/show admin gate matches /history", async () => {
|
|
2315
|
+
createVault("journal");
|
|
2316
|
+
const fullToken = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
|
|
2317
|
+
const p = "/vault/journal/.parachute/mirror/history/show";
|
|
2318
|
+
const res = await route(
|
|
2319
|
+
new Request(`http://localhost:1940${p}?sha=abc123&path=Notes/a`, {
|
|
2320
|
+
headers: { authorization: `Bearer ${fullToken}` },
|
|
2321
|
+
}),
|
|
2322
|
+
p,
|
|
2323
|
+
);
|
|
2324
|
+
expect(res.status).toBe(403);
|
|
2325
|
+
});
|
|
2326
|
+
|
|
2327
|
+
test("non-GET method → 405 (or 503 when manager not wired)", async () => {
|
|
2328
|
+
createVault("journal");
|
|
2329
|
+
const token = await createAdminToken("journal");
|
|
2330
|
+
const p = "/vault/journal/.parachute/mirror/history";
|
|
2331
|
+
const res = await route(
|
|
2332
|
+
new Request(`http://localhost:1940${p}`, {
|
|
2333
|
+
method: "POST",
|
|
2334
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2335
|
+
}),
|
|
2336
|
+
p,
|
|
2337
|
+
);
|
|
2338
|
+
expect([405, 503]).toContain(res.status);
|
|
2339
|
+
});
|
|
2340
|
+
});
|
|
2341
|
+
|
|
2269
2342
|
// ---------------------------------------------------------------------------
|
|
2270
2343
|
// /vault/<name>/.parachute/usage — per-vault data-footprint endpoint.
|
|
2271
2344
|
//
|
package/src/routing.ts
CHANGED
|
@@ -94,6 +94,8 @@ import {
|
|
|
94
94
|
handleAuthGithubSelectRepo,
|
|
95
95
|
handleAuthPat,
|
|
96
96
|
handleMirrorGet,
|
|
97
|
+
handleMirrorHistory,
|
|
98
|
+
handleMirrorHistoryShow,
|
|
97
99
|
handleMirrorImport,
|
|
98
100
|
handleMirrorPushNow,
|
|
99
101
|
handleMirrorPut,
|
|
@@ -671,6 +673,50 @@ export async function route(
|
|
|
671
673
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
672
674
|
}
|
|
673
675
|
|
|
676
|
+
// /.parachute/mirror/history — surface the mirror's git commit history
|
|
677
|
+
// (vault#300). The vault is already git-backed (one file per note), so
|
|
678
|
+
// `git log` IS a tamper-evident write history; this read path exposes it.
|
|
679
|
+
// Admin-gated (it's an ops/forensics surface, not a content read) — same
|
|
680
|
+
// gate + manager check as the sibling mirror routes. GET-only.
|
|
681
|
+
// /history — full mirror history (?path=<note> scopes to one note,
|
|
682
|
+
// ?limit=<n> caps the count)
|
|
683
|
+
// /history/show — `git show <sha>:<path>.md` to read a past revision
|
|
684
|
+
if (
|
|
685
|
+
subpath === "/.parachute/mirror/history" ||
|
|
686
|
+
subpath === "/.parachute/mirror/history/show"
|
|
687
|
+
) {
|
|
688
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
689
|
+
return Response.json(
|
|
690
|
+
{
|
|
691
|
+
error: "Forbidden",
|
|
692
|
+
error_type: "insufficient_scope",
|
|
693
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
694
|
+
required_scope: SCOPE_ADMIN,
|
|
695
|
+
granted_scopes: auth.scopes,
|
|
696
|
+
},
|
|
697
|
+
{ status: 403 },
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
const manager = getMirrorManager(vaultName);
|
|
701
|
+
if (!manager) {
|
|
702
|
+
return Response.json(
|
|
703
|
+
{
|
|
704
|
+
error: "Mirror manager not initialized",
|
|
705
|
+
message:
|
|
706
|
+
"The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
|
|
707
|
+
},
|
|
708
|
+
{ status: 503 },
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
if (req.method !== "GET") {
|
|
712
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
713
|
+
}
|
|
714
|
+
if (subpath === "/.parachute/mirror/history/show") {
|
|
715
|
+
return handleMirrorHistoryShow(req, manager);
|
|
716
|
+
}
|
|
717
|
+
return handleMirrorHistory(req, manager);
|
|
718
|
+
}
|
|
719
|
+
|
|
674
720
|
// /.parachute/mirror/import — clone a vault export from git + import.
|
|
675
721
|
// Admin-gated. POST-only. Synchronous (imports finish in <30s for
|
|
676
722
|
// typical vaults). See mirror-routes.ts:handleMirrorImport for the
|
|
@@ -61,10 +61,8 @@ function captureLogs(): {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
describe("self-register", () => {
|
|
64
|
-
test("buildVaultServicePaths — no vaults yet →
|
|
65
|
-
expect(buildVaultServicePaths(undefined, [], ["/vault/default"])).toEqual([
|
|
66
|
-
"/vault/default",
|
|
67
|
-
]);
|
|
64
|
+
test("buildVaultServicePaths — no vaults yet → empty paths (no phantom /vault/default, #478)", () => {
|
|
65
|
+
expect(buildVaultServicePaths(undefined, [], ["/vault/default"])).toEqual([]);
|
|
68
66
|
});
|
|
69
67
|
|
|
70
68
|
test("buildVaultServicePaths — default vault sorts first", () => {
|
|
@@ -108,7 +106,9 @@ describe("self-register", () => {
|
|
|
108
106
|
const row = parsed.services[0] as Record<string, unknown>;
|
|
109
107
|
expect(row.name).toBe("parachute-vault");
|
|
110
108
|
expect(row.port).toBe(1940);
|
|
111
|
-
|
|
109
|
+
// Zero vaults → paths: [] (no phantom /vault/default, #478).
|
|
110
|
+
// Health still well-formed: paths[0] ?? "/vault/default" fallback.
|
|
111
|
+
expect(row.paths).toEqual([]);
|
|
112
112
|
expect(row.health).toBe("/vault/default/health");
|
|
113
113
|
expect(row.version).toBe("0.4.8-rc.3");
|
|
114
114
|
expect(row.installDir).toBe("/fake/install/dir");
|
package/src/self-register.ts
CHANGED
|
@@ -56,8 +56,13 @@ import { listVaults, readGlobalConfig, DEFAULT_PORT } from "./config.ts";
|
|
|
56
56
|
*
|
|
57
57
|
* Mirrors `buildVaultServicePaths` in `cli.ts` so the self-register pass
|
|
58
58
|
* produces the same multi-vault path advertisement as `parachute-vault
|
|
59
|
-
* init` / `vault create`.
|
|
60
|
-
*
|
|
59
|
+
* init` / `vault create`.
|
|
60
|
+
*
|
|
61
|
+
* At zero vaults, returns an empty array (`paths: []`). The row remains
|
|
62
|
+
* present in services.json (name, port, health, version, installDir intact
|
|
63
|
+
* — so the module is still detected as installed), but it advertises no
|
|
64
|
+
* `/vault/<name>` path and the hub must not resolve it to a phantom
|
|
65
|
+
* `/vault/default`. Closes #478.
|
|
61
66
|
*
|
|
62
67
|
* Exported for tests; not part of the public module surface.
|
|
63
68
|
*/
|
|
@@ -66,7 +71,7 @@ export function buildVaultServicePaths(
|
|
|
66
71
|
vaults: readonly string[],
|
|
67
72
|
fallbackFromManifest: readonly string[],
|
|
68
73
|
): string[] {
|
|
69
|
-
if (vaults.length === 0) return [
|
|
74
|
+
if (vaults.length === 0) return [];
|
|
70
75
|
if (defaultVault && vaults.includes(defaultVault)) {
|
|
71
76
|
return [
|
|
72
77
|
`/vault/${defaultVault}`,
|
package/src/vault-remove.test.ts
CHANGED
|
@@ -141,13 +141,12 @@ describe("vault remove — last-vault auto_create marker", () => {
|
|
|
141
141
|
// resurrection of a freshly-credentialed "default".
|
|
142
142
|
expect(bootAutoCreateAllowed(config)).toBe(false);
|
|
143
143
|
|
|
144
|
-
// services.json was still refreshed: with zero vaults the row
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
// registration shape (and the hub still sees the module as installed).
|
|
144
|
+
// services.json was still refreshed: with zero vaults the row carries
|
|
145
|
+
// paths: [] (#478) — the row stays present so the hub sees the module as
|
|
146
|
+
// installed, but advertises no /vault/<name> path (no phantom default).
|
|
148
147
|
const vault = readServices(home).find((s) => s.name === "parachute-vault");
|
|
149
148
|
expect(vault).toBeDefined();
|
|
150
|
-
expect(vault!.paths).toEqual([
|
|
149
|
+
expect(vault!.paths).toEqual([]);
|
|
151
150
|
});
|
|
152
151
|
|
|
153
152
|
test("removing a NON-last vault does not write the marker", () => {
|