@openparachute/vault 0.1.0 → 0.2.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/CHANGELOG.md +87 -0
- package/CLAUDE.md +2 -2
- package/README.md +289 -44
- package/core/src/core.test.ts +802 -346
- package/core/src/expand.ts +140 -0
- package/core/src/hooks.test.ts +27 -27
- package/core/src/hooks.ts +1 -1
- package/core/src/mcp.ts +102 -39
- package/core/src/notes.ts +82 -4
- package/core/src/obsidian.test.ts +11 -11
- package/core/src/paths.test.ts +46 -46
- package/core/src/schema.ts +18 -2
- package/core/src/store.ts +51 -51
- package/core/src/types.ts +29 -29
- package/core/src/wikilinks.test.ts +61 -61
- package/docs/HTTP_API.md +4 -2
- package/package.json +1 -1
- package/src/auth.test.ts +319 -0
- package/src/backup-launchd.test.ts +90 -0
- package/src/backup-launchd.ts +169 -0
- package/src/backup.test.ts +715 -0
- package/src/backup.ts +699 -0
- package/src/cli.ts +923 -31
- package/src/config.test.ts +173 -0
- package/src/config.ts +345 -15
- package/src/daemon.ts +136 -0
- package/src/doctor.test.ts +356 -0
- package/src/health.test.ts +201 -0
- package/src/health.ts +115 -0
- package/src/launchd.test.ts +91 -0
- package/src/launchd.ts +37 -40
- package/src/mcp-http.ts +1 -1
- package/src/mcp-tools.ts +7 -9
- package/src/oauth.test.ts +289 -8
- package/src/oauth.ts +66 -13
- package/src/published.test.ts +21 -21
- package/src/routes.ts +152 -70
- package/src/routing.test.ts +478 -0
- package/src/routing.ts +413 -0
- package/src/server.ts +7 -278
- package/src/systemd.test.ts +15 -0
- package/src/systemd.ts +18 -11
- package/src/triggers.test.ts +7 -7
- package/src/triggers.ts +6 -6
- package/src/vault-store.ts +20 -3
- package/src/vault.test.ts +356 -262
- package/.claude/settings.local.json +0 -31
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -15
package/src/config.test.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { describe, test, expect } from "bun:test";
|
|
|
2
2
|
import {
|
|
3
3
|
writeVaultConfig,
|
|
4
4
|
readVaultConfig,
|
|
5
|
+
writeGlobalConfig,
|
|
6
|
+
readGlobalConfig,
|
|
5
7
|
generateApiKey,
|
|
6
8
|
hashKey,
|
|
7
9
|
verifyKey,
|
|
@@ -122,4 +124,175 @@ describe("config", () => {
|
|
|
122
124
|
expect(loaded).not.toBeNull();
|
|
123
125
|
expect(loaded!.tag_schemas).toBeUndefined();
|
|
124
126
|
});
|
|
127
|
+
|
|
128
|
+
test("round-trips discovery: enabled|disabled", () => {
|
|
129
|
+
// Default: absent means enabled (endpoint serves names).
|
|
130
|
+
writeGlobalConfig({ port: 1940 });
|
|
131
|
+
expect(readGlobalConfig().discovery).toBeUndefined();
|
|
132
|
+
|
|
133
|
+
// Explicit enabled.
|
|
134
|
+
writeGlobalConfig({ port: 1940, discovery: "enabled" });
|
|
135
|
+
expect(readGlobalConfig().discovery).toBe("enabled");
|
|
136
|
+
|
|
137
|
+
// Explicit disabled — this is the opt-out flag operators set when they
|
|
138
|
+
// don't want /vaults/list to reveal vault names publicly.
|
|
139
|
+
writeGlobalConfig({ port: 1940, discovery: "disabled" });
|
|
140
|
+
expect(readGlobalConfig().discovery).toBe("disabled");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Backup config — round-trip through writeGlobalConfig + readGlobalConfig
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
import { describe as describe2, test as test2, expect as expect2, beforeEach, afterEach } from "bun:test";
|
|
149
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
150
|
+
import { tmpdir } from "os";
|
|
151
|
+
import { join } from "path";
|
|
152
|
+
|
|
153
|
+
describe2("backup config round-trip", () => {
|
|
154
|
+
// We can't just call writeGlobalConfig / readGlobalConfig like above: they
|
|
155
|
+
// write into CONFIG_DIR, which is derived from PARACHUTE_HOME at import
|
|
156
|
+
// time. So we spawn a child process with an isolated PARACHUTE_HOME to
|
|
157
|
+
// test the full read/write cycle, matching the pattern in doctor.test.ts.
|
|
158
|
+
let dir: string;
|
|
159
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "backup-cfg-")); });
|
|
160
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
|
161
|
+
|
|
162
|
+
test2("writes and reads a backup section with tiered retention + local dest", async () => {
|
|
163
|
+
// Child script writes a config, re-reads it, and prints the normalized
|
|
164
|
+
// result. This exercises the full YAML round-trip without mocking.
|
|
165
|
+
const script = `
|
|
166
|
+
process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
|
|
167
|
+
const { writeGlobalConfig, readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
|
|
168
|
+
writeGlobalConfig({
|
|
169
|
+
port: 1940,
|
|
170
|
+
default_vault: "default",
|
|
171
|
+
backup: {
|
|
172
|
+
schedule: "daily",
|
|
173
|
+
retention: { daily: 7, weekly: 4, monthly: 12, yearly: null },
|
|
174
|
+
destinations: [{ kind: "local", path: "~/parachute-backups" }],
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
const read = readGlobalConfig();
|
|
178
|
+
console.log(JSON.stringify(read.backup));
|
|
179
|
+
`;
|
|
180
|
+
const proc = Bun.spawnSync({
|
|
181
|
+
cmd: ["bun", "-e", script],
|
|
182
|
+
stdout: "pipe",
|
|
183
|
+
stderr: "pipe",
|
|
184
|
+
});
|
|
185
|
+
const stdout = new TextDecoder().decode(proc.stdout);
|
|
186
|
+
const stderr = new TextDecoder().decode(proc.stderr);
|
|
187
|
+
expect2(proc.exitCode, stderr).toBe(0);
|
|
188
|
+
|
|
189
|
+
const parsed = JSON.parse(stdout.trim());
|
|
190
|
+
expect2(parsed.schedule).toBe("daily");
|
|
191
|
+
expect2(parsed.retention).toEqual({ daily: 7, weekly: 4, monthly: 12, yearly: null });
|
|
192
|
+
expect2(parsed.destinations.length).toBe(1);
|
|
193
|
+
expect2(parsed.destinations[0].kind).toBe("local");
|
|
194
|
+
expect2(parsed.destinations[0].path).toBe("~/parachute-backups");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test2("retention defaults when the user omits the retention block entirely", async () => {
|
|
198
|
+
// A backup: with schedule only (no retention block) should pick up the
|
|
199
|
+
// shipped defaults: 7/4/12/null.
|
|
200
|
+
const script = `
|
|
201
|
+
process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
|
|
202
|
+
const fs = await import("fs");
|
|
203
|
+
const path = await import("path");
|
|
204
|
+
fs.writeFileSync(path.join(${JSON.stringify(dir)}, "config.yaml"),
|
|
205
|
+
"port: 1940\\nbackup:\\n schedule: daily\\n destinations: []\\n");
|
|
206
|
+
const { readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
|
|
207
|
+
console.log(JSON.stringify(readGlobalConfig().backup));
|
|
208
|
+
`;
|
|
209
|
+
const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
|
|
210
|
+
const out = new TextDecoder().decode(proc.stdout);
|
|
211
|
+
expect2(proc.exitCode, new TextDecoder().decode(proc.stderr)).toBe(0);
|
|
212
|
+
const parsed = JSON.parse(out.trim());
|
|
213
|
+
expect2(parsed.retention).toEqual({ daily: 7, weekly: 4, monthly: 12, yearly: null });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test2("partial retention block: unspecified tiers default to 0 (explicit > merged)", async () => {
|
|
217
|
+
// If the user supplies a retention block with only `daily: 3`, the
|
|
218
|
+
// remaining tiers read as 0 rather than merging with shipped defaults.
|
|
219
|
+
// Predictable: what you write is what you get.
|
|
220
|
+
const script = `
|
|
221
|
+
process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
|
|
222
|
+
const fs = await import("fs");
|
|
223
|
+
const path = await import("path");
|
|
224
|
+
fs.writeFileSync(path.join(${JSON.stringify(dir)}, "config.yaml"),
|
|
225
|
+
"port: 1940\\nbackup:\\n schedule: daily\\n retention:\\n daily: 3\\n destinations: []\\n");
|
|
226
|
+
const { readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
|
|
227
|
+
console.log(JSON.stringify(readGlobalConfig().backup));
|
|
228
|
+
`;
|
|
229
|
+
const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
|
|
230
|
+
const out = new TextDecoder().decode(proc.stdout);
|
|
231
|
+
expect2(proc.exitCode, new TextDecoder().decode(proc.stderr)).toBe(0);
|
|
232
|
+
const parsed = JSON.parse(out.trim());
|
|
233
|
+
expect2(parsed.retention.daily).toBe(3);
|
|
234
|
+
expect2(parsed.retention.weekly).toBe(0);
|
|
235
|
+
expect2(parsed.retention.monthly).toBe(0);
|
|
236
|
+
// yearly stays at 0 because the user didn't say null.
|
|
237
|
+
expect2(parsed.retention.yearly).toBe(0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test2("yearly: null round-trips through write/read as JSON null", async () => {
|
|
241
|
+
const script = `
|
|
242
|
+
process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
|
|
243
|
+
const { writeGlobalConfig, readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
|
|
244
|
+
writeGlobalConfig({
|
|
245
|
+
port: 1940,
|
|
246
|
+
backup: {
|
|
247
|
+
schedule: "manual",
|
|
248
|
+
retention: { daily: 0, weekly: 0, monthly: 0, yearly: null },
|
|
249
|
+
destinations: [],
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
console.log(JSON.stringify(readGlobalConfig().backup));
|
|
253
|
+
`;
|
|
254
|
+
const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
|
|
255
|
+
const out = new TextDecoder().decode(proc.stdout);
|
|
256
|
+
expect2(proc.exitCode).toBe(0);
|
|
257
|
+
const parsed = JSON.parse(out.trim());
|
|
258
|
+
expect2(parsed.retention.yearly).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test2("config without a backup section reads back with backup === undefined", async () => {
|
|
262
|
+
const script = `
|
|
263
|
+
process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
|
|
264
|
+
const { writeGlobalConfig, readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
|
|
265
|
+
writeGlobalConfig({ port: 1940, default_vault: "default" });
|
|
266
|
+
const read = readGlobalConfig();
|
|
267
|
+
console.log(JSON.stringify({ backup: read.backup ?? null }));
|
|
268
|
+
`;
|
|
269
|
+
const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
|
|
270
|
+
const out = new TextDecoder().decode(proc.stdout);
|
|
271
|
+
expect2(proc.exitCode).toBe(0);
|
|
272
|
+
const parsed = JSON.parse(out.trim());
|
|
273
|
+
expect2(parsed.backup).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test2("empty destinations round-trips as empty list (not missing key)", async () => {
|
|
277
|
+
const script = `
|
|
278
|
+
process.env.PARACHUTE_HOME = ${JSON.stringify(dir)};
|
|
279
|
+
const { writeGlobalConfig, readGlobalConfig } = await import(${JSON.stringify(join(import.meta.dir, "config.ts"))});
|
|
280
|
+
writeGlobalConfig({
|
|
281
|
+
port: 1940,
|
|
282
|
+
backup: {
|
|
283
|
+
schedule: "manual",
|
|
284
|
+
retention: { daily: 7, weekly: 4, monthly: 12, yearly: null },
|
|
285
|
+
destinations: [],
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
const read = readGlobalConfig();
|
|
289
|
+
console.log(JSON.stringify(read.backup));
|
|
290
|
+
`;
|
|
291
|
+
const proc = Bun.spawnSync({ cmd: ["bun", "-e", script], stdout: "pipe", stderr: "pipe" });
|
|
292
|
+
const out = new TextDecoder().decode(proc.stdout);
|
|
293
|
+
expect2(proc.exitCode).toBe(0);
|
|
294
|
+
const parsed = JSON.parse(out.trim());
|
|
295
|
+
expect2(parsed.schedule).toBe("manual");
|
|
296
|
+
expect2(parsed.destinations).toEqual([]);
|
|
297
|
+
});
|
|
125
298
|
});
|
package/src/config.ts
CHANGED
|
@@ -19,9 +19,34 @@ import crypto from "node:crypto";
|
|
|
19
19
|
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
// Paths
|
|
22
|
+
//
|
|
23
|
+
// Historical note: the exported `CONFIG_DIR`, `VAULTS_DIR`, etc. used to be
|
|
24
|
+
// `const` captured at module load. That made tests flaky: anything setting
|
|
25
|
+
// `process.env.PARACHUTE_HOME` after import would be ignored, and when `bun
|
|
26
|
+
// test` shares one process across files, whichever test loaded first froze
|
|
27
|
+
// the path for the rest. Internal read/write now go through the `*Path()`
|
|
28
|
+
// getters so `PARACHUTE_HOME` is re-read per call. The top-level constants
|
|
29
|
+
// are kept for backward-compat (other modules import them) and reflect the
|
|
30
|
+
// value at load time.
|
|
22
31
|
// ---------------------------------------------------------------------------
|
|
23
32
|
|
|
24
|
-
|
|
33
|
+
function configDirPath(): string {
|
|
34
|
+
return process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function vaultsDirPath(): string {
|
|
38
|
+
return join(configDirPath(), "vaults");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function globalConfigPath(): string {
|
|
42
|
+
return join(configDirPath(), "config.yaml");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function envFilePath(): string {
|
|
46
|
+
return join(configDirPath(), ".env");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const CONFIG_DIR = configDirPath();
|
|
25
50
|
export const VAULTS_DIR = join(CONFIG_DIR, "vaults");
|
|
26
51
|
export const GLOBAL_CONFIG_PATH = join(CONFIG_DIR, "config.yaml");
|
|
27
52
|
export const ENV_PATH = join(CONFIG_DIR, ".env");
|
|
@@ -31,7 +56,7 @@ export const DEFAULT_PORT = 1940;
|
|
|
31
56
|
export const ASSETS_DIR = join(CONFIG_DIR, "assets");
|
|
32
57
|
|
|
33
58
|
export function vaultDir(name: string): string {
|
|
34
|
-
return join(
|
|
59
|
+
return join(vaultsDirPath(), name);
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
export function vaultDbPath(name: string): string {
|
|
@@ -137,6 +162,90 @@ export interface GlobalConfig {
|
|
|
137
162
|
totp_secret?: string;
|
|
138
163
|
/** Bcrypt hashes of single-use backup codes for 2FA recovery. */
|
|
139
164
|
backup_codes?: string[];
|
|
165
|
+
/**
|
|
166
|
+
* Controls the public `GET /vaults/list` endpoint.
|
|
167
|
+
* - `"enabled"` (default): returns vault names (no other metadata).
|
|
168
|
+
* - `"disabled"`: returns 404, hiding vault existence from unauthenticated
|
|
169
|
+
* callers.
|
|
170
|
+
*/
|
|
171
|
+
discovery?: "enabled" | "disabled";
|
|
172
|
+
/** Backup configuration: schedule, retention, destinations. */
|
|
173
|
+
backup?: BackupConfig;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Backup configuration
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
export type BackupSchedule = "hourly" | "daily" | "weekly" | "manual";
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Discriminated union over destination kinds. For the MVP we ship `local`
|
|
184
|
+
* only; `s3`, `rsync`, and `cloud` kinds will be added as additional variants
|
|
185
|
+
* without breaking existing configs. Unknown kinds are preserved verbatim so
|
|
186
|
+
* a forward-rolled config edited by a newer CLI isn't silently downgraded by
|
|
187
|
+
* an older CLI rewriting the file.
|
|
188
|
+
*/
|
|
189
|
+
export interface LocalBackupDestination {
|
|
190
|
+
kind: "local";
|
|
191
|
+
/** Absolute or `~/`-prefixed path. `~/` is expanded at use-time. */
|
|
192
|
+
path: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export type BackupDestination = LocalBackupDestination;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Tiered (grandfather-father-son) retention policy. After each backup we keep
|
|
199
|
+
* the union of four tier queries:
|
|
200
|
+
*
|
|
201
|
+
* daily — the N most recent snapshots (unconditionally).
|
|
202
|
+
* weekly — one snapshot per ISO week, for the N most recent such weeks.
|
|
203
|
+
* monthly — one snapshot per calendar month, for the N most recent months.
|
|
204
|
+
* yearly — one snapshot per calendar year; `null` means keep every year
|
|
205
|
+
* (never prune by age — the long-tail archive).
|
|
206
|
+
*
|
|
207
|
+
* A tier set to 0 is disabled (it contributes no keepers, but the other tiers
|
|
208
|
+
* still apply). All bucketing uses the local timezone so calendar alignment
|
|
209
|
+
* matches the user's expectations, not UTC.
|
|
210
|
+
*/
|
|
211
|
+
export interface RetentionPolicy {
|
|
212
|
+
/** Keep the last N daily snapshots. 0 disables the daily tier. */
|
|
213
|
+
daily: number;
|
|
214
|
+
/** Keep the last snapshot from each of the last N ISO weeks. 0 disables. */
|
|
215
|
+
weekly: number;
|
|
216
|
+
/** Keep the last snapshot from each of the last N months. 0 disables. */
|
|
217
|
+
monthly: number;
|
|
218
|
+
/**
|
|
219
|
+
* Keep the last snapshot from each of the last N years. `null` or `undefined`
|
|
220
|
+
* means unbounded — keep one snapshot per year, forever, across the full
|
|
221
|
+
* history. 0 disables the yearly tier entirely.
|
|
222
|
+
*/
|
|
223
|
+
yearly: number | null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface BackupConfig {
|
|
227
|
+
/** How often the scheduler fires. "manual" = scheduler is not registered. */
|
|
228
|
+
schedule: BackupSchedule;
|
|
229
|
+
/** Tiered retention policy — grandfather/father/son. */
|
|
230
|
+
retention: RetentionPolicy;
|
|
231
|
+
/** Pluggable destinations. Runs in order; a destination error logs + continues. */
|
|
232
|
+
destinations: BackupDestination[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function defaultRetentionPolicy(): RetentionPolicy {
|
|
236
|
+
// Defaults balance "I want to roll back yesterday's accidental delete"
|
|
237
|
+
// against "my iCloud folder shouldn't blow up in a year." The yearly tier
|
|
238
|
+
// is unbounded by default — the whole point of the tiered policy is that
|
|
239
|
+
// one-per-year is cheap forever.
|
|
240
|
+
return { daily: 7, weekly: 4, monthly: 12, yearly: null };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function defaultBackupConfig(): BackupConfig {
|
|
244
|
+
return {
|
|
245
|
+
schedule: "manual",
|
|
246
|
+
retention: defaultRetentionPolicy(),
|
|
247
|
+
destinations: [],
|
|
248
|
+
};
|
|
140
249
|
}
|
|
141
250
|
|
|
142
251
|
// ---------------------------------------------------------------------------
|
|
@@ -426,18 +535,192 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
|
|
|
426
535
|
return triggers.length > 0 ? triggers : undefined;
|
|
427
536
|
}
|
|
428
537
|
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
// Backup YAML parsing / serialization
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Parse the `backup:` section. Returns undefined if no section is present so
|
|
544
|
+
* callers can tell "user hasn't configured backups" apart from "user asked
|
|
545
|
+
* for the default (schedule: manual, empty destinations)."
|
|
546
|
+
*
|
|
547
|
+
* Shape:
|
|
548
|
+
* backup:
|
|
549
|
+
* schedule: daily
|
|
550
|
+
* retention:
|
|
551
|
+
* daily: 7
|
|
552
|
+
* weekly: 4
|
|
553
|
+
* monthly: 12
|
|
554
|
+
* yearly: null # or omit for unbounded; 0 disables the tier
|
|
555
|
+
* destinations:
|
|
556
|
+
* - kind: local
|
|
557
|
+
* path: ~/Library/Mobile Documents/com~apple~CloudDocs/parachute-backups
|
|
558
|
+
*/
|
|
559
|
+
function parseBackup(yaml: string): BackupConfig | undefined {
|
|
560
|
+
const startMatch = yaml.match(/^backup:\s*$/m);
|
|
561
|
+
if (!startMatch) return undefined;
|
|
562
|
+
|
|
563
|
+
const startIdx = (startMatch.index ?? 0) + startMatch[0].length;
|
|
564
|
+
const lines = yaml.slice(startIdx).split("\n");
|
|
565
|
+
|
|
566
|
+
const backup: BackupConfig = defaultBackupConfig();
|
|
567
|
+
let section: "top" | "retention" | "destinations" = "top";
|
|
568
|
+
let currentDest: Partial<BackupDestination> & { kind?: string } = {};
|
|
569
|
+
let hasDest = false;
|
|
570
|
+
// Track whether the user supplied a retention block at all. If they did,
|
|
571
|
+
// we start from a clean slate (all tiers default 0, yearly null) so that
|
|
572
|
+
// an explicit partial policy overrides defaults rather than merging with
|
|
573
|
+
// them — predictable semantics beat magical merging.
|
|
574
|
+
let retentionSeen = false;
|
|
575
|
+
|
|
576
|
+
const pushDest = () => {
|
|
577
|
+
if (!hasDest) return;
|
|
578
|
+
// For the MVP we only ship `local`. Unknown/malformed destination kinds
|
|
579
|
+
// are skipped rather than rejected: a forward-rolled config authored by
|
|
580
|
+
// a newer CLI mustn't break backup for the features this CLI does
|
|
581
|
+
// understand. The backup command itself warns about skipped kinds.
|
|
582
|
+
if (currentDest.kind === "local" && typeof currentDest.path === "string") {
|
|
583
|
+
backup.destinations.push({ kind: "local", path: currentDest.path });
|
|
584
|
+
}
|
|
585
|
+
currentDest = {};
|
|
586
|
+
hasDest = false;
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
for (const line of lines) {
|
|
590
|
+
// Stop at next top-level key.
|
|
591
|
+
if (line.match(/^\S/) && line.trim().length > 0) break;
|
|
592
|
+
if (line.trim().length === 0) continue;
|
|
593
|
+
|
|
594
|
+
const trimmed = line.trim();
|
|
595
|
+
|
|
596
|
+
// A 2-space-indented line starting a new sub-section closes the previous
|
|
597
|
+
// section. The only 2-space keys under `backup:` today are `schedule`,
|
|
598
|
+
// `retention`, and `destinations`, so we key off indent depth here.
|
|
599
|
+
const indent = line.match(/^ */)?.[0].length ?? 0;
|
|
600
|
+
|
|
601
|
+
if (indent === 2) {
|
|
602
|
+
if (/^retention:\s*$/.test(trimmed)) {
|
|
603
|
+
section = "retention";
|
|
604
|
+
retentionSeen = true;
|
|
605
|
+
// Zero-out tiers so partially specified blocks don't silently merge
|
|
606
|
+
// with defaults in surprising ways.
|
|
607
|
+
backup.retention = { daily: 0, weekly: 0, monthly: 0, yearly: 0 };
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (/^destinations:\s*$/.test(trimmed)) {
|
|
611
|
+
pushDest();
|
|
612
|
+
section = "destinations";
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
// Any other 2-space top field terminates the current sub-section.
|
|
616
|
+
if (section !== "top") {
|
|
617
|
+
pushDest();
|
|
618
|
+
section = "top";
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (section === "top" && indent === 2) {
|
|
623
|
+
const schedMatch = trimmed.match(/^schedule:\s*(\S+)/);
|
|
624
|
+
if (schedMatch) {
|
|
625
|
+
const v = schedMatch[1];
|
|
626
|
+
if (v === "hourly" || v === "daily" || v === "weekly" || v === "manual") {
|
|
627
|
+
backup.schedule = v;
|
|
628
|
+
}
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (section === "retention" && indent === 4) {
|
|
634
|
+
const tierMatch = trimmed.match(/^(daily|weekly|monthly|yearly):\s*(\S+)/);
|
|
635
|
+
if (tierMatch) {
|
|
636
|
+
const tier = tierMatch[1] as keyof RetentionPolicy;
|
|
637
|
+
const raw = tierMatch[2].trim();
|
|
638
|
+
// "null" / "~" / "unbounded" all mean "keep every year" for the
|
|
639
|
+
// yearly tier. For the other tiers they'd be meaningless; we
|
|
640
|
+
// silently treat them as disabled (0) rather than erroring.
|
|
641
|
+
if (raw === "null" || raw === "~" || raw === "unbounded") {
|
|
642
|
+
if (tier === "yearly") backup.retention.yearly = null;
|
|
643
|
+
// Other tiers stay at 0.
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const n = parseInt(raw, 10);
|
|
647
|
+
if (Number.isFinite(n) && n >= 0) {
|
|
648
|
+
backup.retention[tier] = n as never;
|
|
649
|
+
}
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (section === "destinations") {
|
|
655
|
+
// Start of a new list item: "- kind: local" or just "- kind:"
|
|
656
|
+
const itemMatch = trimmed.match(/^-\s+(\w+):\s*(.*)$/);
|
|
657
|
+
if (itemMatch) {
|
|
658
|
+
pushDest();
|
|
659
|
+
hasDest = true;
|
|
660
|
+
currentDest = {};
|
|
661
|
+
(currentDest as Record<string, string>)[itemMatch[1]] = itemMatch[2].trim();
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
// Continuation line inside the current list item.
|
|
665
|
+
const fieldMatch = trimmed.match(/^(\w+):\s*(.*)$/);
|
|
666
|
+
if (fieldMatch && hasDest) {
|
|
667
|
+
(currentDest as Record<string, string>)[fieldMatch[1]] = fieldMatch[2].trim();
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
pushDest();
|
|
673
|
+
|
|
674
|
+
// If the user left retention out entirely, fall back to the shipped default.
|
|
675
|
+
if (!retentionSeen) {
|
|
676
|
+
backup.retention = defaultRetentionPolicy();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return backup;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function serializeBackup(backup: BackupConfig): string[] {
|
|
683
|
+
const lines: string[] = [];
|
|
684
|
+
lines.push("backup:");
|
|
685
|
+
lines.push(` schedule: ${backup.schedule}`);
|
|
686
|
+
lines.push(" retention:");
|
|
687
|
+
lines.push(` daily: ${backup.retention.daily}`);
|
|
688
|
+
lines.push(` weekly: ${backup.retention.weekly}`);
|
|
689
|
+
lines.push(` monthly: ${backup.retention.monthly}`);
|
|
690
|
+
// `null` is serialized as the YAML literal `null` so round-trips preserve
|
|
691
|
+
// "unbounded." Numbers render as-is, including 0 (disabled).
|
|
692
|
+
lines.push(` yearly: ${backup.retention.yearly === null ? "null" : backup.retention.yearly}`);
|
|
693
|
+
if (backup.destinations.length > 0) {
|
|
694
|
+
lines.push(" destinations:");
|
|
695
|
+
for (const dest of backup.destinations) {
|
|
696
|
+
lines.push(` - kind: ${dest.kind}`);
|
|
697
|
+
// Paths may contain ~, spaces, and `.` — the whole line being on its
|
|
698
|
+
// own key line means we don't need to quote unless the value begins
|
|
699
|
+
// with a YAML-special character. Quoting defensively keeps the
|
|
700
|
+
// hand-rolled parser happy under future path-with-colons pressure.
|
|
701
|
+
if ("path" in dest) {
|
|
702
|
+
const needsQuote = /[:#]/.test(dest.path);
|
|
703
|
+
lines.push(` path: ${needsQuote ? `"${dest.path}"` : dest.path}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
} else {
|
|
707
|
+
lines.push(" destinations: []");
|
|
708
|
+
}
|
|
709
|
+
return lines;
|
|
710
|
+
}
|
|
711
|
+
|
|
429
712
|
// ---------------------------------------------------------------------------
|
|
430
713
|
// Directory management
|
|
431
714
|
// ---------------------------------------------------------------------------
|
|
432
715
|
|
|
433
716
|
export async function ensureConfigDir(): Promise<void> {
|
|
434
|
-
await mkdir(
|
|
435
|
-
await mkdir(
|
|
717
|
+
await mkdir(configDirPath(), { recursive: true });
|
|
718
|
+
await mkdir(vaultsDirPath(), { recursive: true });
|
|
436
719
|
}
|
|
437
720
|
|
|
438
721
|
export function ensureConfigDirSync(): void {
|
|
439
|
-
mkdirSync(
|
|
440
|
-
mkdirSync(
|
|
722
|
+
mkdirSync(configDirPath(), { recursive: true });
|
|
723
|
+
mkdirSync(vaultsDirPath(), { recursive: true });
|
|
441
724
|
}
|
|
442
725
|
|
|
443
726
|
// ---------------------------------------------------------------------------
|
|
@@ -446,18 +729,23 @@ export function ensureConfigDirSync(): void {
|
|
|
446
729
|
|
|
447
730
|
export function readGlobalConfig(): GlobalConfig {
|
|
448
731
|
try {
|
|
449
|
-
|
|
450
|
-
|
|
732
|
+
const gcPath = globalConfigPath();
|
|
733
|
+
if (existsSync(gcPath)) {
|
|
734
|
+
const yaml = readFileSync(gcPath, "utf-8");
|
|
451
735
|
const portMatch = yaml.match(/^port:\s*(\d+)/m);
|
|
452
736
|
const defaultVaultMatch = yaml.match(/^default_vault:\s*(\S+)/m);
|
|
453
737
|
const passwordHashMatch = yaml.match(/^owner_password_hash:\s*"([^"]+)"/m);
|
|
454
738
|
const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
|
|
739
|
+
const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
|
|
455
740
|
const config: GlobalConfig = {
|
|
456
741
|
port: portMatch ? parseInt(portMatch[1], 10) : DEFAULT_PORT,
|
|
457
742
|
default_vault: defaultVaultMatch?.[1],
|
|
458
743
|
owner_password_hash: passwordHashMatch?.[1],
|
|
459
744
|
totp_secret: totpSecretMatch?.[1],
|
|
460
745
|
};
|
|
746
|
+
if (discoveryMatch) {
|
|
747
|
+
config.discovery = discoveryMatch[1] as "enabled" | "disabled";
|
|
748
|
+
}
|
|
461
749
|
|
|
462
750
|
// Parse backup_codes: a YAML list of quoted bcrypt hashes under
|
|
463
751
|
// backup_codes:
|
|
@@ -501,6 +789,9 @@ export function readGlobalConfig(): GlobalConfig {
|
|
|
501
789
|
// Parse triggers
|
|
502
790
|
config.triggers = parseTriggers(yaml);
|
|
503
791
|
|
|
792
|
+
// Parse backup section
|
|
793
|
+
config.backup = parseBackup(yaml);
|
|
794
|
+
|
|
504
795
|
return config;
|
|
505
796
|
}
|
|
506
797
|
} catch {}
|
|
@@ -511,6 +802,7 @@ export function writeGlobalConfig(config: GlobalConfig): void {
|
|
|
511
802
|
ensureConfigDirSync();
|
|
512
803
|
const lines = [`port: ${config.port}`];
|
|
513
804
|
if (config.default_vault) lines.push(`default_vault: ${config.default_vault}`);
|
|
805
|
+
if (config.discovery) lines.push(`discovery: ${config.discovery}`);
|
|
514
806
|
if (config.owner_password_hash) {
|
|
515
807
|
lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
|
|
516
808
|
}
|
|
@@ -568,12 +860,16 @@ export function writeGlobalConfig(config: GlobalConfig): void {
|
|
|
568
860
|
}
|
|
569
861
|
}
|
|
570
862
|
|
|
863
|
+
if (config.backup) {
|
|
864
|
+
lines.push(...serializeBackup(config.backup));
|
|
865
|
+
}
|
|
866
|
+
|
|
571
867
|
// 0600 — owner read/write only. This file may contain the bcrypt password
|
|
572
868
|
// hash and plaintext TOTP secret; it must not be world- or group-readable.
|
|
573
|
-
writeFileSync(
|
|
869
|
+
writeFileSync(globalConfigPath(), lines.join("\n") + "\n", { mode: 0o600 });
|
|
574
870
|
// writeFileSync's `mode` only applies on file creation, so chmod an existing
|
|
575
871
|
// file explicitly in case it was written by an older version at 0644.
|
|
576
|
-
try { chmodSync(
|
|
872
|
+
try { chmodSync(globalConfigPath(), 0o600); } catch {}
|
|
577
873
|
}
|
|
578
874
|
|
|
579
875
|
// ---------------------------------------------------------------------------
|
|
@@ -629,8 +925,9 @@ export function generateApiKey(): { fullKey: string; keyId: string } {
|
|
|
629
925
|
export function readEnvFile(): Record<string, string> {
|
|
630
926
|
const env: Record<string, string> = {};
|
|
631
927
|
try {
|
|
632
|
-
|
|
633
|
-
|
|
928
|
+
const p = envFilePath();
|
|
929
|
+
if (!existsSync(p)) return env;
|
|
930
|
+
const content = readFileSync(p, "utf-8");
|
|
634
931
|
for (const line of content.split("\n")) {
|
|
635
932
|
const trimmed = line.trim();
|
|
636
933
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -665,7 +962,7 @@ export function writeEnvFile(env: Record<string, string>): void {
|
|
|
665
962
|
lines.push(`${key}=${val}`);
|
|
666
963
|
}
|
|
667
964
|
}
|
|
668
|
-
writeFileSync(
|
|
965
|
+
writeFileSync(envFilePath(), lines.join("\n") + "\n");
|
|
669
966
|
}
|
|
670
967
|
|
|
671
968
|
/**
|
|
@@ -704,8 +1001,9 @@ export function loadEnvFile(): void {
|
|
|
704
1001
|
|
|
705
1002
|
export function listVaults(): string[] {
|
|
706
1003
|
try {
|
|
707
|
-
|
|
708
|
-
|
|
1004
|
+
const dir = vaultsDirPath();
|
|
1005
|
+
if (!existsSync(dir)) return [];
|
|
1006
|
+
const entries = Bun.spawnSync(["ls", dir]).stdout.toString().trim();
|
|
709
1007
|
if (!entries) return [];
|
|
710
1008
|
return entries.split("\n").filter((name) => {
|
|
711
1009
|
return existsSync(vaultConfigPath(name));
|
|
@@ -714,3 +1012,35 @@ export function listVaults(): string[] {
|
|
|
714
1012
|
return [];
|
|
715
1013
|
}
|
|
716
1014
|
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Resolve the vault that unscoped routes (`/mcp`, `/api/*`, `/oauth/*`,
|
|
1018
|
+
* `/view/*`) should target.
|
|
1019
|
+
*
|
|
1020
|
+
* Resolution order:
|
|
1021
|
+
* 1. If `default_vault` is set in config.yaml AND that vault exists → use it.
|
|
1022
|
+
* 2. Else if exactly one vault exists → use that vault regardless of its name.
|
|
1023
|
+
* This is the "single-vault auto-default": if you only have `journal`,
|
|
1024
|
+
* `/mcp` transparently targets `journal` without requiring you to visit
|
|
1025
|
+
* `/vaults/journal/mcp`.
|
|
1026
|
+
* 3. Otherwise → return `null` (multi-vault deployment with no/bad default;
|
|
1027
|
+
* the caller should surface an explicit error rather than guess).
|
|
1028
|
+
*
|
|
1029
|
+
* Notes:
|
|
1030
|
+
* - If `default_vault` points to a deleted vault, step 2 still kicks in so
|
|
1031
|
+
* operators aren't stranded after `vault remove`.
|
|
1032
|
+
* - The name "default" has no special meaning here; it's just whatever
|
|
1033
|
+
* `vault init` happens to create on first run. A single vault named
|
|
1034
|
+
* "journal" behaves identically.
|
|
1035
|
+
*/
|
|
1036
|
+
export function resolveDefaultVault(): string | null {
|
|
1037
|
+
const globalConfig = readGlobalConfig();
|
|
1038
|
+
const vaults = listVaults();
|
|
1039
|
+
if (globalConfig.default_vault && vaults.includes(globalConfig.default_vault)) {
|
|
1040
|
+
return globalConfig.default_vault;
|
|
1041
|
+
}
|
|
1042
|
+
if (vaults.length === 1) {
|
|
1043
|
+
return vaults[0];
|
|
1044
|
+
}
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|