@openparachute/vault 0.5.1 → 0.5.2-rc.1
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 +183 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +77 -0
- package/core/src/mcp.ts +130 -22
- package/core/src/notes.ts +36 -0
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/schema.ts +7 -4
- package/core/src/store.ts +1 -1
- package/core/src/tag-schemas.ts +59 -44
- package/core/src/types.ts +31 -3
- package/package.json +1 -1
- package/src/auth.test.ts +37 -1
- package/src/auth.ts +29 -0
- package/src/cli.ts +125 -9
- package/src/config.test.ts +16 -0
- package/src/config.ts +39 -0
- package/src/mcp-tools.ts +60 -6
- package/src/routes.ts +486 -53
- package/src/routing.test.ts +185 -0
- package/src/routing.ts +32 -2
- package/src/server.ts +7 -0
- package/src/storage.test.ts +162 -0
- package/src/tag-scope.ts +68 -1
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +194 -2
- package/src/vault.test.ts +1064 -7
package/src/usage.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-vault usage monitoring — a cheap, read-scoped "how big is this vault"
|
|
3
|
+
* report (data footprint). Built for running many vaults for many users: the
|
|
4
|
+
* operator needs to see each vault's size, and a vault's own user should be
|
|
5
|
+
* able to see their own vault's footprint.
|
|
6
|
+
*
|
|
7
|
+
* The endpoint that consumes these helpers lives in `routing.ts` at
|
|
8
|
+
* `GET /vault/<name>/.parachute/usage` (read-scoped). This module owns the
|
|
9
|
+
* filesystem arithmetic and the dir-walk cache.
|
|
10
|
+
*
|
|
11
|
+
* Cost model:
|
|
12
|
+
* - counts + contentBytes come from `getVaultStats` (a handful of indexed
|
|
13
|
+
* COUNT/SUM queries) — cheap, computed on every request.
|
|
14
|
+
* - `dbBytes` is three `statSync` calls (the WAL trio) — cheap, every request.
|
|
15
|
+
* - the two recursive directory walks (`assets`, `mirror`) can traverse
|
|
16
|
+
* thousands of files, so they go through a 60s TTL cache keyed by vault
|
|
17
|
+
* name. `?fresh=1` bypasses the cache; an attachment upload invalidates it.
|
|
18
|
+
*
|
|
19
|
+
* Everything I/O-touching goes through an injectable `UsageFs` seam + an
|
|
20
|
+
* injectable clock so tests can assert call counts and TTL behavior without
|
|
21
|
+
* touching the real disk.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
statSync as realStatSync,
|
|
26
|
+
readdirSync as realReaddirSync,
|
|
27
|
+
type Dirent,
|
|
28
|
+
} from "fs";
|
|
29
|
+
import { join } from "path";
|
|
30
|
+
import { vaultDir, assetsDir } from "./config.ts";
|
|
31
|
+
import { readMirrorConfigForVault, resolveMirrorPath } from "./mirror-config.ts";
|
|
32
|
+
import type { VaultStats } from "../core/src/types.ts";
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Injectable seams
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The minimal filesystem surface the usage helpers depend on. Injecting it
|
|
40
|
+
* lets tests count how many times the dir-walk runs (to prove the cache
|
|
41
|
+
* actually skips it) and synthesize a tree without writing real files.
|
|
42
|
+
*
|
|
43
|
+
* Symlink safety lives in the WALK, not in `statFile`: `dirSize` never
|
|
44
|
+
* descends into or sizes a symlink entry (it filters on
|
|
45
|
+
* `Dirent.isSymbolicLink()` before ever calling `statFile`), so a symlink
|
|
46
|
+
* loop can't hang the walk and a symlink pointing at a huge tree elsewhere
|
|
47
|
+
* can't inflate the count. `statFile` is therefore only ever called on real
|
|
48
|
+
* files / the WAL trio (which are never symlinks).
|
|
49
|
+
*/
|
|
50
|
+
export interface UsageFs {
|
|
51
|
+
/**
|
|
52
|
+
* `stat` (symlink-FOLLOWING, like `statSync`): returns the size of a file
|
|
53
|
+
* (or throws if absent — callers wrap in try/catch → 0). Following symlinks
|
|
54
|
+
* is safe here because dir descent already filters symlinks via
|
|
55
|
+
* `Dirent.isSymbolicLink()`, and the WAL files (`vault.db{,-wal,-shm}`) are
|
|
56
|
+
* never symlinks.
|
|
57
|
+
*/
|
|
58
|
+
statFile(path: string): { size: number; isDirectory(): boolean; isSymbolicLink(): boolean };
|
|
59
|
+
/** `readdirSync(path, { withFileTypes: true })`. */
|
|
60
|
+
readDir(path: string): Dirent[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Monotonic-enough clock seam: returns epoch millis. */
|
|
64
|
+
export type Clock = () => number;
|
|
65
|
+
|
|
66
|
+
/** Real filesystem implementation (production default). */
|
|
67
|
+
export const realUsageFs: UsageFs = {
|
|
68
|
+
statFile: (path) => realStatSync(path),
|
|
69
|
+
readDir: (path) => realReaddirSync(path, { withFileTypes: true }),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Primitive byte counters
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Physical bytes of a vault's SQLite database, WAL-aware: `vault.db` +
|
|
78
|
+
* `vault.db-wal` + `vault.db-shm`. The WAL (write-ahead log) and shared-memory
|
|
79
|
+
* index files hold committed-but-not-yet-checkpointed pages; ignoring them
|
|
80
|
+
* undercounts a busy vault. A missing `-wal`/`-shm` (the common case at rest,
|
|
81
|
+
* after a checkpoint) contributes 0 rather than erroring.
|
|
82
|
+
*
|
|
83
|
+
* This is the PHYSICAL DB-file size — it includes SQLite page overhead, free
|
|
84
|
+
* pages from deletes, etc. It is intentionally distinct from the LOGICAL
|
|
85
|
+
* `contentBytes` in `VaultStats` (sum of note-content UTF-8 bytes).
|
|
86
|
+
*/
|
|
87
|
+
export function dbBytes(vaultName: string, fs: UsageFs = realUsageFs): number {
|
|
88
|
+
const base = join(vaultDir(vaultName), "vault.db");
|
|
89
|
+
const candidates = [base, `${base}-wal`, `${base}-shm`];
|
|
90
|
+
let total = 0;
|
|
91
|
+
for (const path of candidates) {
|
|
92
|
+
total += safeFileSize(path, fs);
|
|
93
|
+
}
|
|
94
|
+
return total;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Size of a single file, or 0 if it's missing / unreadable. */
|
|
98
|
+
function safeFileSize(path: string, fs: UsageFs): number {
|
|
99
|
+
try {
|
|
100
|
+
return fs.statFile(path).size;
|
|
101
|
+
} catch {
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Recursively sum the size of every regular file under `dirPath`.
|
|
108
|
+
*
|
|
109
|
+
* - Missing directory → 0 (never throws; a vault may have no assets / no
|
|
110
|
+
* mirror yet).
|
|
111
|
+
* - Symlinks are NOT followed — a symlink (file or dir) is skipped entirely.
|
|
112
|
+
* This guards against symlink loops (which would hang an infinite walk)
|
|
113
|
+
* and against a symlink that points at a large tree outside the vault
|
|
114
|
+
* inflating the reported footprint.
|
|
115
|
+
*
|
|
116
|
+
* Uses an explicit stack (not recursion) so a deep tree can't blow the call
|
|
117
|
+
* stack, and reads dirents with types so we avoid an extra `stat` per entry
|
|
118
|
+
* just to learn directory-ness.
|
|
119
|
+
*/
|
|
120
|
+
export function dirSize(dirPath: string, fs: UsageFs = realUsageFs): number {
|
|
121
|
+
let total = 0;
|
|
122
|
+
const stack: string[] = [dirPath];
|
|
123
|
+
|
|
124
|
+
while (stack.length > 0) {
|
|
125
|
+
const current = stack.pop()!;
|
|
126
|
+
let entries: Dirent[];
|
|
127
|
+
try {
|
|
128
|
+
entries = fs.readDir(current);
|
|
129
|
+
} catch {
|
|
130
|
+
// Missing/unreadable dir (including the root) → contributes 0.
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
// Never follow symlinks — neither symlinked files nor symlinked dirs.
|
|
135
|
+
if (entry.isSymbolicLink()) continue;
|
|
136
|
+
const childPath = join(current, entry.name);
|
|
137
|
+
if (entry.isDirectory()) {
|
|
138
|
+
stack.push(childPath);
|
|
139
|
+
} else if (entry.isFile()) {
|
|
140
|
+
total += safeFileSize(childPath, fs);
|
|
141
|
+
}
|
|
142
|
+
// Sockets/FIFOs/devices: ignored.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return total;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Resolve a vault's mirror directory on disk, or `null` when no mirror is
|
|
150
|
+
* configured / resolvable (never configured, or external-without-path). The
|
|
151
|
+
* usage endpoint reports `mirror: 0` (and omits it from the total) in that
|
|
152
|
+
* case.
|
|
153
|
+
*/
|
|
154
|
+
export function resolveVaultMirrorDir(vaultName: string): string | null {
|
|
155
|
+
const config = readMirrorConfigForVault(vaultName);
|
|
156
|
+
if (!config) return null;
|
|
157
|
+
return resolveMirrorPath(vaultDir(vaultName), config);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Dir-walk cache (the only expensive part)
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
export interface DirWalkResult {
|
|
165
|
+
/** Bytes under the vault's assets directory. */
|
|
166
|
+
assets: number;
|
|
167
|
+
/**
|
|
168
|
+
* Bytes under the vault's mirror directory, or `null` when no mirror is
|
|
169
|
+
* configured. `null` → omit `mirror` from the response (or report 0).
|
|
170
|
+
*/
|
|
171
|
+
mirror: number | null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface CacheEntry {
|
|
175
|
+
value: DirWalkResult;
|
|
176
|
+
/** Epoch millis when this entry was computed. */
|
|
177
|
+
computedAt: number;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Default cache lifetime for the dir-walk numbers. */
|
|
181
|
+
export const DIR_WALK_TTL_MS = 60_000;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* In-process, per-vault cache of the two dir-walk numbers. Module-level so it
|
|
185
|
+
* survives across requests within a server process. Tests construct their own
|
|
186
|
+
* `UsageCache` to get isolation + a controllable clock.
|
|
187
|
+
*/
|
|
188
|
+
export class UsageCache {
|
|
189
|
+
private readonly entries = new Map<string, CacheEntry>();
|
|
190
|
+
|
|
191
|
+
constructor(
|
|
192
|
+
private readonly fs: UsageFs = realUsageFs,
|
|
193
|
+
private readonly now: Clock = Date.now,
|
|
194
|
+
private readonly ttlMs: number = DIR_WALK_TTL_MS,
|
|
195
|
+
) {}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get the dir-walk numbers for a vault. Returns whether the numbers came
|
|
199
|
+
* from cache (`cached: true`) or were freshly walked (`cached: false`).
|
|
200
|
+
*
|
|
201
|
+
* - `fresh: true` always recomputes (and refreshes the cache entry).
|
|
202
|
+
* - Otherwise a non-expired entry is returned as-is (no walk); an absent
|
|
203
|
+
* or expired entry triggers a walk + store.
|
|
204
|
+
*/
|
|
205
|
+
get(vaultName: string, opts: { fresh?: boolean } = {}): { result: DirWalkResult; cached: boolean } {
|
|
206
|
+
const existing = this.entries.get(vaultName);
|
|
207
|
+
const ts = this.now();
|
|
208
|
+
if (!opts.fresh && existing && ts - existing.computedAt < this.ttlMs) {
|
|
209
|
+
return { result: existing.value, cached: true };
|
|
210
|
+
}
|
|
211
|
+
const value = this.compute(vaultName);
|
|
212
|
+
this.entries.set(vaultName, { value, computedAt: ts });
|
|
213
|
+
return { result: value, cached: false };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Drop a vault's cached dir-walk so the next read recomputes. */
|
|
217
|
+
invalidate(vaultName: string): void {
|
|
218
|
+
this.entries.delete(vaultName);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Run the (expensive) walks. */
|
|
222
|
+
private compute(vaultName: string): DirWalkResult {
|
|
223
|
+
const assets = dirSize(assetsDir(vaultName), this.fs);
|
|
224
|
+
const mirrorDir = resolveVaultMirrorDir(vaultName);
|
|
225
|
+
const mirror = mirrorDir === null ? null : dirSize(mirrorDir, this.fs);
|
|
226
|
+
return { assets, mirror };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The process-wide cache the live HTTP route uses. A single instance shared
|
|
232
|
+
* across requests gives the 60s TTL its meaning. `invalidateUsageCache` is the
|
|
233
|
+
* write-path hook (attachment upload, post-export).
|
|
234
|
+
*/
|
|
235
|
+
export const usageCache = new UsageCache();
|
|
236
|
+
|
|
237
|
+
/** Invalidate the process-wide usage cache for a vault (write-path hook). */
|
|
238
|
+
export function invalidateUsageCache(vaultName: string): void {
|
|
239
|
+
usageCache.invalidate(vaultName);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Report assembly (consumed by the HTTP route)
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
export interface UsageReport {
|
|
247
|
+
counts: {
|
|
248
|
+
notes: number;
|
|
249
|
+
attachments: number;
|
|
250
|
+
links: number;
|
|
251
|
+
tags: number;
|
|
252
|
+
};
|
|
253
|
+
bytes: {
|
|
254
|
+
/** Logical: sum of note-content UTF-8 bytes (from VaultStats). */
|
|
255
|
+
content: number;
|
|
256
|
+
/** Physical: vault.db + WAL + shm. */
|
|
257
|
+
db: number;
|
|
258
|
+
/** Bytes under the vault's assets directory (dir-walk, cached). */
|
|
259
|
+
assets: number;
|
|
260
|
+
/**
|
|
261
|
+
* Bytes under the vault's mirror directory (dir-walk, cached), present
|
|
262
|
+
* only when a mirror is configured. The mirror is a git PROJECTION of the
|
|
263
|
+
* same notes/attachments — it is NOT added to `total` (that would
|
|
264
|
+
* double-count the data); it's reported as a separate line item.
|
|
265
|
+
*/
|
|
266
|
+
mirror?: number;
|
|
267
|
+
/**
|
|
268
|
+
* Physical on-disk footprint (db + assets); excludes content (inside db)
|
|
269
|
+
* and mirror (separate projection). This is the number an operator sizes a
|
|
270
|
+
* vault by.
|
|
271
|
+
*/
|
|
272
|
+
total: number;
|
|
273
|
+
};
|
|
274
|
+
/** ISO-8601 timestamp of when this report was assembled. */
|
|
275
|
+
computedAt: string;
|
|
276
|
+
/** Whether the dir-walk numbers (assets/mirror) came from cache. */
|
|
277
|
+
cached: boolean;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Assemble the usage report for a vault. `stats` is the already-computed
|
|
282
|
+
* `getVaultStats()` result (counts + contentBytes); the cache supplies the two
|
|
283
|
+
* dir-walk numbers and reports whether they were cached.
|
|
284
|
+
*
|
|
285
|
+
* `total = db + assets` — the physical footprint. Mirror is a separate line
|
|
286
|
+
* item (projection of the same data; adding it double-counts). Content is the
|
|
287
|
+
* logical note size, already inside `db` physically.
|
|
288
|
+
*/
|
|
289
|
+
export function buildUsageReport(
|
|
290
|
+
vaultName: string,
|
|
291
|
+
stats: VaultStats,
|
|
292
|
+
opts: { fresh?: boolean; cache?: UsageCache; fs?: UsageFs; now?: Clock } = {},
|
|
293
|
+
): UsageReport {
|
|
294
|
+
const cache = opts.cache ?? usageCache;
|
|
295
|
+
const { result, cached } = cache.get(vaultName, { fresh: opts.fresh });
|
|
296
|
+
const db = dbBytes(vaultName, opts.fs ?? realUsageFs);
|
|
297
|
+
const total = db + result.assets;
|
|
298
|
+
|
|
299
|
+
const bytes: UsageReport["bytes"] = {
|
|
300
|
+
content: stats.contentBytes,
|
|
301
|
+
db,
|
|
302
|
+
assets: result.assets,
|
|
303
|
+
total,
|
|
304
|
+
};
|
|
305
|
+
if (result.mirror !== null) bytes.mirror = result.mirror;
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
counts: {
|
|
309
|
+
notes: stats.totalNotes,
|
|
310
|
+
attachments: stats.attachmentCount,
|
|
311
|
+
links: stats.linkCount,
|
|
312
|
+
tags: stats.tagCount,
|
|
313
|
+
},
|
|
314
|
+
bytes,
|
|
315
|
+
computedAt: new Date((opts.now ?? Date.now)()).toISOString(),
|
|
316
|
+
cached,
|
|
317
|
+
};
|
|
318
|
+
}
|
package/src/vault-create.test.ts
CHANGED
|
@@ -10,7 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
12
12
|
import { resolve } from "path";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
mkdtempSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
symlinkSync,
|
|
21
|
+
} from "fs";
|
|
14
22
|
import { tmpdir } from "os";
|
|
15
23
|
import { join } from "path";
|
|
16
24
|
|
|
@@ -33,6 +41,23 @@ function runCli(
|
|
|
33
41
|
};
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
/** Path to a vault's per-vault mirror-config file inside a temp PARACHUTE_HOME. */
|
|
45
|
+
function mirrorConfigPath(home: string, name: string): string {
|
|
46
|
+
return join(home, "vault", "data", name, "mirror-config.yaml");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Path to a vault's internal mirror git working tree. */
|
|
50
|
+
function mirrorRepoPath(home: string, name: string): string {
|
|
51
|
+
return join(home, "vault", "data", name, "mirror");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Write a minimal config.yaml carrying a `default_mirror` knob value. */
|
|
55
|
+
function writeDefaultMirrorKnob(home: string, value: "internal" | "off"): void {
|
|
56
|
+
const dir = join(home, "vault");
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
writeFileSync(join(dir, "config.yaml"), `port: 1940\ndefault_mirror: ${value}\n`);
|
|
59
|
+
}
|
|
60
|
+
|
|
36
61
|
let home: string;
|
|
37
62
|
|
|
38
63
|
beforeEach(() => {
|
|
@@ -107,7 +132,20 @@ describe("vault create --json", () => {
|
|
|
107
132
|
);
|
|
108
133
|
expect(exitCode).not.toBe(0);
|
|
109
134
|
expect(stdout).toBe("");
|
|
110
|
-
expect(stderr).toContain("
|
|
135
|
+
expect(stderr).toContain("lowercase alphanumeric");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("UPPERCASE vault name is rejected (security review — audience case-drift)", () => {
|
|
139
|
+
// An uppercase name would flip the JWT audience case (vault.<Name> vs
|
|
140
|
+
// vault.<name>) and drift from hub/init lowercasing. cmdCreate must
|
|
141
|
+
// reject it the same way `init` does.
|
|
142
|
+
const { exitCode, stdout, stderr } = runCli(
|
|
143
|
+
["create", "MyVault", "--json"],
|
|
144
|
+
{ PARACHUTE_HOME: home },
|
|
145
|
+
);
|
|
146
|
+
expect(exitCode).not.toBe(0);
|
|
147
|
+
expect(stdout).toBe("");
|
|
148
|
+
expect(stderr).toContain("lowercase");
|
|
111
149
|
});
|
|
112
150
|
|
|
113
151
|
test("duplicate name in --json mode errors on stderr and exits non-zero", () => {
|
|
@@ -199,3 +237,157 @@ describe("vault create (human mode)", () => {
|
|
|
199
237
|
expect(() => JSON.parse(stdout.trim())).toThrow();
|
|
200
238
|
});
|
|
201
239
|
});
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Create-time default backup posture: new vaults default to an internal live
|
|
243
|
+
* mirror (local git backup of the markdown projection). The History preset
|
|
244
|
+
* `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
|
|
245
|
+
* auto_push:false}` is written to `data/<vault>/mirror-config.yaml` and (when
|
|
246
|
+
* git is present) the internal mirror dir is git-bootstrapped. The behavior is
|
|
247
|
+
* controlled by the server-wide `default_mirror` knob (default `internal`) and
|
|
248
|
+
* overridable per-create by `--no-mirror`.
|
|
249
|
+
*
|
|
250
|
+
* Critically: create-time ONLY — already-created vaults are NOT retroactively
|
|
251
|
+
* migrated, and the git-less box stays a successful create (best-effort
|
|
252
|
+
* bootstrap, config still written, actionable log).
|
|
253
|
+
*/
|
|
254
|
+
describe("vault create — default internal live mirror", () => {
|
|
255
|
+
test("default (no knob) → History-preset mirror config written + git-bootstrapped", () => {
|
|
256
|
+
const { exitCode } = runCli(["create", "backed", "--json"], {
|
|
257
|
+
PARACHUTE_HOME: home,
|
|
258
|
+
});
|
|
259
|
+
expect(exitCode).toBe(0);
|
|
260
|
+
|
|
261
|
+
// Mirror config exists with the exact History preset.
|
|
262
|
+
const cfgPath = mirrorConfigPath(home, "backed");
|
|
263
|
+
expect(existsSync(cfgPath)).toBe(true);
|
|
264
|
+
const cfg = readFileSync(cfgPath, "utf-8");
|
|
265
|
+
expect(cfg).toContain("enabled: true");
|
|
266
|
+
expect(cfg).toContain("location: internal");
|
|
267
|
+
expect(cfg).toContain("sync_mode: events");
|
|
268
|
+
expect(cfg).toContain("auto_commit: true");
|
|
269
|
+
expect(cfg).toContain("auto_push: false");
|
|
270
|
+
|
|
271
|
+
// Git present on the test host → the internal mirror dir is a real repo
|
|
272
|
+
// with the seed commit (bootstrap ran). counts/usage reflect it: the
|
|
273
|
+
// mirror working tree exists under the vault data dir.
|
|
274
|
+
const repo = mirrorRepoPath(home, "backed");
|
|
275
|
+
expect(existsSync(join(repo, ".git"))).toBe(true);
|
|
276
|
+
const log = Bun.spawnSync({
|
|
277
|
+
cmd: ["git", "-C", repo, "log", "--oneline"],
|
|
278
|
+
stdout: "pipe",
|
|
279
|
+
});
|
|
280
|
+
expect(new TextDecoder().decode(log.stdout)).toContain("initial mirror bootstrap");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("default_mirror: off knob → no mirror config written", () => {
|
|
284
|
+
writeDefaultMirrorKnob(home, "off");
|
|
285
|
+
const { exitCode } = runCli(["create", "bare", "--json"], {
|
|
286
|
+
PARACHUTE_HOME: home,
|
|
287
|
+
});
|
|
288
|
+
expect(exitCode).toBe(0);
|
|
289
|
+
|
|
290
|
+
// Vault is created…
|
|
291
|
+
expect(existsSync(join(home, "vault", "data", "bare", "vault.db"))).toBe(true);
|
|
292
|
+
// …but NO mirror config + NO mirror dir.
|
|
293
|
+
expect(existsSync(mirrorConfigPath(home, "bare"))).toBe(false);
|
|
294
|
+
expect(existsSync(mirrorRepoPath(home, "bare"))).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("default_mirror: internal knob (explicit) → mirror config written", () => {
|
|
298
|
+
writeDefaultMirrorKnob(home, "internal");
|
|
299
|
+
const { exitCode } = runCli(["create", "explicit", "--json"], {
|
|
300
|
+
PARACHUTE_HOME: home,
|
|
301
|
+
});
|
|
302
|
+
expect(exitCode).toBe(0);
|
|
303
|
+
expect(existsSync(mirrorConfigPath(home, "explicit"))).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("--no-mirror → no mirror config even when knob is internal", () => {
|
|
307
|
+
writeDefaultMirrorKnob(home, "internal");
|
|
308
|
+
const { exitCode } = runCli(["create", "optout", "--no-mirror", "--json"], {
|
|
309
|
+
PARACHUTE_HOME: home,
|
|
310
|
+
});
|
|
311
|
+
expect(exitCode).toBe(0);
|
|
312
|
+
|
|
313
|
+
expect(existsSync(join(home, "vault", "data", "optout", "vault.db"))).toBe(true);
|
|
314
|
+
expect(existsSync(mirrorConfigPath(home, "optout"))).toBe(false);
|
|
315
|
+
expect(existsSync(mirrorRepoPath(home, "optout"))).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("--no-mirror in human mode keeps the rest of the create working", () => {
|
|
319
|
+
const { exitCode, stdout } = runCli(["create", "humanbare", "--no-mirror"], {
|
|
320
|
+
PARACHUTE_HOME: home,
|
|
321
|
+
});
|
|
322
|
+
expect(exitCode).toBe(0);
|
|
323
|
+
expect(stdout).toContain('Vault "humanbare" created.');
|
|
324
|
+
expect(existsSync(mirrorConfigPath(home, "humanbare"))).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("git missing → vault still creates, config written, mirror NOT bootstrapped, actionable log, no crash", () => {
|
|
328
|
+
// Simulate a git-less server: spawn the CLI with a PATH that resolves
|
|
329
|
+
// `bun` (so the test can run) but NOT `git`. `bootstrapInternalMirror`'s
|
|
330
|
+
// `Bun.which("git")` preflight then returns null → friendly,
|
|
331
|
+
// best-effort failure. The vault create MUST still succeed.
|
|
332
|
+
const bunBin = Bun.which("bun");
|
|
333
|
+
expect(bunBin).toBeTruthy();
|
|
334
|
+
const fakeBin = mkdtempSync(join(tmpdir(), "vault-create-nogit-bin-"));
|
|
335
|
+
symlinkSync(bunBin!, join(fakeBin, "bun"));
|
|
336
|
+
try {
|
|
337
|
+
const proc = Bun.spawnSync({
|
|
338
|
+
cmd: [bunBin!, CLI, "create", "nogit", "--json"],
|
|
339
|
+
stdout: "pipe",
|
|
340
|
+
stderr: "pipe",
|
|
341
|
+
// Replace PATH entirely so `git` is unresolvable — only our fake bin
|
|
342
|
+
// (bun only) is on the path.
|
|
343
|
+
env: { ...process.env, PARACHUTE_HOME: home, PATH: fakeBin },
|
|
344
|
+
});
|
|
345
|
+
const exitCode = proc.exitCode ?? -1;
|
|
346
|
+
const stdout = new TextDecoder().decode(proc.stdout);
|
|
347
|
+
const stderr = new TextDecoder().decode(proc.stderr);
|
|
348
|
+
|
|
349
|
+
// No crash — the vault create succeeds on a git-less box.
|
|
350
|
+
expect(exitCode).toBe(0);
|
|
351
|
+
// The vault itself was created.
|
|
352
|
+
expect(existsSync(join(home, "vault", "data", "nogit", "vault.db"))).toBe(true);
|
|
353
|
+
// The mirror CONFIG was still written (intent persists for when git
|
|
354
|
+
// lands later).
|
|
355
|
+
expect(existsSync(mirrorConfigPath(home, "nogit"))).toBe(true);
|
|
356
|
+
// But the mirror was NOT git-bootstrapped (no .git — git was absent).
|
|
357
|
+
expect(existsSync(join(mirrorRepoPath(home, "nogit"), ".git"))).toBe(false);
|
|
358
|
+
// And the operator got an actionable, clear log on stderr.
|
|
359
|
+
expect(stderr).toContain("local git backup configured but not yet active");
|
|
360
|
+
expect(stderr).toContain("Install git");
|
|
361
|
+
// JSON stdout stays clean + parseable (the note went to stderr).
|
|
362
|
+
const payload = JSON.parse(stdout.trim());
|
|
363
|
+
expect(payload.name).toBe("nogit");
|
|
364
|
+
} finally {
|
|
365
|
+
rmSync(fakeBin, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("EXISTING vault (created before this change) is NOT auto-migrated on a later create", () => {
|
|
370
|
+
// Simulate a vault that predates the default-mirror behavior: create it
|
|
371
|
+
// with the knob OFF so it gets no mirror config (stand-in for "created by
|
|
372
|
+
// an older vault build").
|
|
373
|
+
writeDefaultMirrorKnob(home, "off");
|
|
374
|
+
const first = runCli(["create", "legacy", "--json"], { PARACHUTE_HOME: home });
|
|
375
|
+
expect(first.exitCode).toBe(0);
|
|
376
|
+
expect(existsSync(mirrorConfigPath(home, "legacy"))).toBe(false);
|
|
377
|
+
|
|
378
|
+
// Now flip the knob ON and create a SECOND, different vault. The act of
|
|
379
|
+
// creating "fresh" (which DOES get a mirror) must not retroactively write
|
|
380
|
+
// a mirror config onto the pre-existing "legacy" vault — create-time only,
|
|
381
|
+
// never a sweep over existing vaults.
|
|
382
|
+
writeDefaultMirrorKnob(home, "internal");
|
|
383
|
+
const second = runCli(["create", "fresh", "--json"], { PARACHUTE_HOME: home });
|
|
384
|
+
expect(second.exitCode).toBe(0);
|
|
385
|
+
|
|
386
|
+
// The new vault is backed…
|
|
387
|
+
expect(existsSync(mirrorConfigPath(home, "fresh"))).toBe(true);
|
|
388
|
+
// …but the pre-existing vault is left exactly as it was — no surprise
|
|
389
|
+
// mirror config, no surprise disk-doubling mirror repo.
|
|
390
|
+
expect(existsSync(mirrorConfigPath(home, "legacy"))).toBe(false);
|
|
391
|
+
expect(existsSync(mirrorRepoPath(home, "legacy"))).toBe(false);
|
|
392
|
+
});
|
|
393
|
+
});
|