@openparachute/vault 0.1.0 → 0.2.0

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/CLAUDE.md +2 -2
  3. package/README.md +289 -44
  4. package/core/src/core.test.ts +802 -346
  5. package/core/src/expand.ts +140 -0
  6. package/core/src/hooks.test.ts +27 -27
  7. package/core/src/hooks.ts +1 -1
  8. package/core/src/mcp.ts +102 -39
  9. package/core/src/notes.ts +82 -4
  10. package/core/src/obsidian.test.ts +11 -11
  11. package/core/src/paths.test.ts +46 -46
  12. package/core/src/schema.ts +18 -2
  13. package/core/src/store.ts +51 -51
  14. package/core/src/types.ts +29 -29
  15. package/core/src/wikilinks.test.ts +61 -61
  16. package/docs/HTTP_API.md +4 -2
  17. package/package.json +1 -1
  18. package/src/auth.test.ts +319 -0
  19. package/src/backup-launchd.test.ts +90 -0
  20. package/src/backup-launchd.ts +169 -0
  21. package/src/backup.test.ts +715 -0
  22. package/src/backup.ts +699 -0
  23. package/src/cli.ts +923 -31
  24. package/src/config.test.ts +173 -0
  25. package/src/config.ts +345 -15
  26. package/src/daemon.ts +136 -0
  27. package/src/doctor.test.ts +356 -0
  28. package/src/health.test.ts +201 -0
  29. package/src/health.ts +115 -0
  30. package/src/launchd.test.ts +91 -0
  31. package/src/launchd.ts +37 -40
  32. package/src/mcp-http.ts +1 -1
  33. package/src/mcp-tools.ts +7 -9
  34. package/src/oauth.test.ts +289 -8
  35. package/src/oauth.ts +57 -12
  36. package/src/published.test.ts +21 -21
  37. package/src/routes.ts +152 -70
  38. package/src/routing.test.ts +347 -0
  39. package/src/routing.ts +365 -0
  40. package/src/server.ts +7 -278
  41. package/src/systemd.test.ts +15 -0
  42. package/src/systemd.ts +18 -11
  43. package/src/triggers.test.ts +7 -7
  44. package/src/triggers.ts +6 -6
  45. package/src/vault-store.ts +20 -3
  46. package/src/vault.test.ts +356 -262
  47. package/.claude/settings.local.json +0 -31
  48. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  49. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  50. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  51. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  52. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  53. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  54. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  55. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  56. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  57. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  58. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  59. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  60. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  61. package/religions-abrahamic-filter.png +0 -0
  62. package/religions-buddhism-v2.png +0 -0
  63. package/religions-buddhism.png +0 -0
  64. package/religions-final.png +0 -0
  65. package/religions-v1.png +0 -0
  66. package/religions-v2.png +0 -0
  67. package/religions-zen.png +0 -0
  68. package/web/README.md +0 -73
  69. package/web/bun.lock +0 -827
  70. package/web/eslint.config.js +0 -23
  71. package/web/index.html +0 -15
  72. package/web/package.json +0 -36
  73. package/web/public/favicon.svg +0 -1
  74. package/web/public/icons.svg +0 -24
  75. package/web/src/App.tsx +0 -149
  76. package/web/src/Graph.tsx +0 -200
  77. package/web/src/NoteView.tsx +0 -155
  78. package/web/src/Sidebar.tsx +0 -186
  79. package/web/src/api.ts +0 -21
  80. package/web/src/index.css +0 -50
  81. package/web/src/main.tsx +0 -10
  82. package/web/src/types.ts +0 -37
  83. package/web/src/utils.ts +0 -107
  84. package/web/tsconfig.app.json +0 -25
  85. package/web/tsconfig.json +0 -7
  86. package/web/tsconfig.node.json +0 -24
  87. package/web/vite.config.ts +0 -15
package/src/cli.ts CHANGED
@@ -38,10 +38,38 @@ import {
38
38
  ENV_PATH,
39
39
  LOG_PATH,
40
40
  ERR_PATH,
41
+ GLOBAL_CONFIG_PATH,
41
42
  } from "./config.ts";
42
43
  import type { VaultConfig } from "./config.ts";
44
+ import { VAULTS_DIR } from "./config.ts";
43
45
  import { installAgent, uninstallAgent, isAgentLoaded, restartAgent } from "./launchd.ts";
44
- import { installSystemdService, restartSystemdService, isSystemdAvailable, isServiceActive } from "./systemd.ts";
46
+ import {
47
+ runBackup,
48
+ readLastBackup,
49
+ nextRunEstimate,
50
+ updateBackupConfig,
51
+ expandTilde,
52
+ checkDestinationWritable,
53
+ tierTally,
54
+ } from "./backup.ts";
55
+ import {
56
+ installBackupAgent,
57
+ uninstallBackupAgent,
58
+ isBackupAgentLoaded,
59
+ BACKUP_PLIST_PATH,
60
+ } from "./backup-launchd.ts";
61
+ import { defaultBackupConfig } from "./config.ts";
62
+ import type { BackupSchedule } from "./config.ts";
63
+ import { installSystemdService, uninstallSystemdService, restartSystemdService, isSystemdAvailable, isServiceActive } from "./systemd.ts";
64
+ import { checkHealth, waitForHealthy, tailFile } from "./health.ts";
65
+ import type { HealthResult } from "./health.ts";
66
+ import {
67
+ WRAPPER_PATH,
68
+ SERVER_PATH_FILE,
69
+ readServerPathPointer,
70
+ removeDaemonWrapper,
71
+ resolveServerPath,
72
+ } from "./daemon.ts";
45
73
  import { confirm, ask, askPassword, choose } from "./prompt.ts";
46
74
  import { generateToken, createToken, listTokens, revokeToken, migrateVaultKeys } from "./token-store.ts";
47
75
  import type { TokenPermission } from "./token-store.ts";
@@ -129,6 +157,18 @@ switch (command) {
129
157
  case "restart":
130
158
  await cmdRestart();
131
159
  break;
160
+ case "uninstall":
161
+ await cmdUninstall(cmdArgs);
162
+ break;
163
+ case "doctor":
164
+ await cmdDoctor();
165
+ break;
166
+ case "url":
167
+ cmdUrl();
168
+ break;
169
+ case "backup":
170
+ await cmdBackup(cmdArgs);
171
+ break;
132
172
  case "import":
133
173
  await cmdImport(cmdArgs);
134
174
  break;
@@ -170,10 +210,30 @@ async function cmdInit() {
170
210
  console.log(`Found ${vaults.length} existing vault(s)`);
171
211
  }
172
212
 
173
- // 2. Write global config
213
+ // 2. Write global config. Set default_vault to the lone existing vault
214
+ // when unset or stale — this keeps unscoped routes (/mcp, /api/*) working
215
+ // for users who bootstrapped with `vault create <name>` before running init.
174
216
  const globalConfig = readGlobalConfig();
175
- if (!globalConfig.default_vault) {
176
- globalConfig.default_vault = "default";
217
+ const allVaults = listVaults();
218
+ const needsDefault = !globalConfig.default_vault
219
+ || !allVaults.includes(globalConfig.default_vault);
220
+ if (needsDefault) {
221
+ if (allVaults.length === 1) {
222
+ globalConfig.default_vault = allVaults[0];
223
+ } else if (allVaults.includes("default")) {
224
+ globalConfig.default_vault = "default";
225
+ } else if (allVaults.length > 0) {
226
+ // Multi-vault, no safe fallback — don't guess. Operator must set it.
227
+ console.log(
228
+ ` No default_vault set and multiple vaults exist. Unscoped routes (/mcp) will error until you set one.`,
229
+ );
230
+ console.log(
231
+ ` Fix: echo 'default_vault: <name>' >> ${CONFIG_DIR}/config.yaml`,
232
+ );
233
+ } else {
234
+ // We created "default" above (vaults.length === 0 branch).
235
+ globalConfig.default_vault = "default";
236
+ }
177
237
  }
178
238
  writeGlobalConfig(globalConfig);
179
239
 
@@ -209,17 +269,24 @@ async function cmdInit() {
209
269
  await promptForOwnerPassword("Set an owner password for OAuth consent?");
210
270
  }
211
271
 
212
- // 6. Install daemon (platform-aware)
272
+ // 6. Install daemon (platform-aware). Idempotent — safe to re-run after
273
+ // a folder move; this refreshes ~/.parachute/server-path and bounces the
274
+ // daemon so the new location takes effect immediately.
213
275
  console.log("Installing daemon...");
276
+ let serverPath: string | null = null;
214
277
  if (isMac) {
215
- await installAgent();
278
+ ({ serverPath } = await installAgent());
216
279
  } else if (isLinux && isSystemdAvailable()) {
217
- await installSystemdService();
280
+ ({ serverPath } = await installSystemdService());
218
281
  } else {
219
282
  console.log(" Auto-start not available on this platform.");
220
283
  console.log(" Run manually: bun src/server.ts");
221
284
  console.log(" Or use Docker: docker compose up -d");
222
285
  }
286
+ if (serverPath) {
287
+ console.log(` Server path: ${serverPath}`);
288
+ console.log(` Wrapper: ~/.parachute/start.sh`);
289
+ }
223
290
  console.log(` Listening on http://0.0.0.0:${globalConfig.port || DEFAULT_PORT}`);
224
291
 
225
292
  // 7. Install MCP for Claude Code (with token for auth)
@@ -490,6 +557,13 @@ function cmdCreate(args: string[]) {
490
557
  console.error("Vault name must contain only letters, numbers, hyphens, and underscores.");
491
558
  process.exit(1);
492
559
  }
560
+ if (name === "list") {
561
+ // Reserved — /vaults/list is the public discovery endpoint. Allowing a
562
+ // vault with this name would let its routes (/vaults/list/mcp, etc.) be
563
+ // shadowed by the discovery handler.
564
+ console.error(`"list" is a reserved vault name.`);
565
+ process.exit(1);
566
+ }
493
567
 
494
568
  const existing = readVaultConfig(name);
495
569
  if (existing) {
@@ -498,12 +572,31 @@ function cmdCreate(args: string[]) {
498
572
  }
499
573
 
500
574
  ensureConfigDirSync();
575
+ const wasFirst = listVaults().length === 0;
501
576
  const key = createVault(name);
502
577
 
578
+ // If this is the only vault now, make it the default so unscoped routes
579
+ // (/mcp, /api/*, /oauth/*) target it. Avoids the "single vault named
580
+ // 'journal' but /mcp returns 404" surprise.
581
+ const globalConfig = readGlobalConfig();
582
+ const needsDefault = !globalConfig.default_vault
583
+ || !listVaults().includes(globalConfig.default_vault);
584
+ let defaultNote: string | null = null;
585
+ if (needsDefault) {
586
+ globalConfig.default_vault = name;
587
+ writeGlobalConfig(globalConfig);
588
+ defaultNote = wasFirst
589
+ ? `Set as default vault (unscoped routes will target "${name}")`
590
+ : `Set as default vault (previous default was missing)`;
591
+ }
592
+
503
593
  console.log(`Vault "${name}" created.`);
504
594
  console.log(` Path: ${vaultDir(name)}`);
505
595
  console.log(` API token: ${key}`);
506
596
  console.log(` Save this — it will not be shown again.`);
597
+ if (defaultNote) {
598
+ console.log(` ${defaultNote}`);
599
+ }
507
600
  console.log();
508
601
  console.log(`To add MCP to Claude: parachute vault mcp-install ${name}`);
509
602
  }
@@ -552,6 +645,24 @@ function cmdRemove(args: string[]) {
552
645
 
553
646
  rmSync(vaultDir(name), { recursive: true, force: true });
554
647
  console.log(`Vault "${name}" removed.`);
648
+
649
+ // Keep default_vault in sync. If the removed vault was the default, either
650
+ // promote the remaining vault (if exactly one) or clear the setting.
651
+ const globalConfig = readGlobalConfig();
652
+ if (globalConfig.default_vault === name) {
653
+ const remaining = listVaults();
654
+ if (remaining.length === 1) {
655
+ globalConfig.default_vault = remaining[0];
656
+ writeGlobalConfig(globalConfig);
657
+ console.log(` Default vault is now "${remaining[0]}".`);
658
+ } else {
659
+ delete globalConfig.default_vault;
660
+ writeGlobalConfig(globalConfig);
661
+ if (remaining.length > 1) {
662
+ console.log(` Cleared default_vault — set one with: editor ${CONFIG_DIR}/config.yaml`);
663
+ }
664
+ }
665
+ }
555
666
  }
556
667
 
557
668
  async function cmdConfig(args: string[]) {
@@ -771,6 +882,9 @@ async function cmdLogs() {
771
882
  }
772
883
 
773
884
  async function cmdRestart() {
885
+ loadEnvFile();
886
+ const port = readGlobalConfig().port || DEFAULT_PORT;
887
+
774
888
  console.log("Restarting daemon...");
775
889
  if (process.platform === "darwin") {
776
890
  await restartAgent();
@@ -780,30 +894,48 @@ async function cmdRestart() {
780
894
  console.error("No daemon manager available. Restart manually or use Docker.");
781
895
  process.exit(1);
782
896
  }
783
- console.log("Done.");
897
+
898
+ process.stdout.write("Waiting for /health ");
899
+ // Dot-progress only to interactive terminals so piped output stays clean.
900
+ const interval = process.stdout.isTTY
901
+ ? setInterval(() => process.stdout.write("."), 500)
902
+ : null;
903
+ const health = await waitForHealthy(port, { totalMs: 10_000 });
904
+ if (interval) clearInterval(interval);
905
+ process.stdout.write("\n");
906
+
907
+ if (health.status === "healthy") {
908
+ console.log(`Vault is healthy at http://127.0.0.1:${port} (${health.latencyMs}ms)`);
909
+ return;
910
+ }
911
+
912
+ console.error(`Vault did not come up within 10s — status: ${health.status}${health.error ? ` (${health.error})` : ""}`);
913
+ printErrLogTail(20);
914
+ process.exit(1);
784
915
  }
785
916
 
786
917
  async function cmdStatus() {
787
918
  loadEnvFile();
788
- let loaded: boolean;
919
+ const globalConfig = readGlobalConfig();
920
+ const port = globalConfig.port || DEFAULT_PORT;
921
+ const vaults = listVaults();
922
+
923
+ // Three distinct states:
924
+ // loaded — launchd/systemd believes the agent is running
925
+ // health — what the HTTP server at <port> actually responds with
926
+ let loaded: boolean | "n/a";
789
927
  if (process.platform === "darwin") {
790
928
  loaded = await isAgentLoaded();
791
929
  } else if (isSystemdAvailable()) {
792
930
  loaded = await isServiceActive();
793
931
  } else {
794
- // Check if server responds on the port
795
- try {
796
- const resp = await fetch(`http://127.0.0.1:${readGlobalConfig().port || DEFAULT_PORT}/health`);
797
- loaded = resp.ok;
798
- } catch { loaded = false; }
932
+ loaded = "n/a"; // no daemon manager on this platform
799
933
  }
800
- const vaults = listVaults();
801
- const globalConfig = readGlobalConfig();
934
+ const health = await checkHealth(port);
802
935
 
803
936
  console.log("Parachute Vault\n");
804
-
805
- // Server
806
- console.log(` Server: ${loaded ? "running" : "stopped"} on port ${globalConfig.port}`);
937
+ console.log(` Daemon: ${renderLoaded(loaded)}`);
938
+ console.log(` Server: ${renderHealth(health, port)}`);
807
939
  console.log(` Config: ${CONFIG_DIR}`);
808
940
 
809
941
  // Vaults
@@ -825,17 +957,766 @@ async function cmdStatus() {
825
957
  console.log(` Triggers: none configured`);
826
958
  }
827
959
 
828
- // Quick health check if daemon is running
829
- if (loaded) {
830
- try {
831
- const resp = await fetch(`http://127.0.0.1:${globalConfig.port}/health`);
832
- if (resp.ok) {
833
- console.log(`\n Health: ok`);
960
+ // If loaded but not healthy, surface the recent error log. This is the
961
+ // "daemon is running but wedged" case that bit us when start.sh pointed
962
+ // at a moved repo — launchctl said it was loaded, the port was closed,
963
+ // and the cause was sitting in vault.err.
964
+ if (loaded === true && health.status !== "healthy") {
965
+ printErrLogTail(20);
966
+ }
967
+ }
968
+
969
+ function renderLoaded(loaded: boolean | "n/a"): string {
970
+ if (loaded === "n/a") return "(no daemon manager on this platform)";
971
+ if (!loaded) return "not loaded";
972
+ // Keep the manager name honest per-platform so Linux users don't see
973
+ // "launchctl" in their status output.
974
+ const manager = process.platform === "darwin" ? "launchctl" : "systemd";
975
+ return `loaded (${manager})`;
976
+ }
977
+
978
+ function renderHealth(h: HealthResult, port: number): string {
979
+ switch (h.status) {
980
+ case "healthy":
981
+ return `healthy — http://127.0.0.1:${port} (${h.latencyMs}ms)`;
982
+ case "unhealthy":
983
+ return `responding but unhealthy — HTTP ${h.statusCode} on port ${port}`;
984
+ case "not-listening":
985
+ return `not listening — nothing bound to port ${port}`;
986
+ case "error":
987
+ return `unreachable — ${h.error ?? "unknown error"}`;
988
+ }
989
+ }
990
+
991
+ function printErrLogTail(n: number) {
992
+ const tail = tailFile(ERR_PATH, n);
993
+ console.log(`\n Recent errors from ${ERR_PATH}:`);
994
+ if (tail === null) {
995
+ console.log(` (no log file at ${ERR_PATH})`);
996
+ return;
997
+ }
998
+ if (tail === "") {
999
+ console.log(` (log file is empty)`);
1000
+ return;
1001
+ }
1002
+ for (const line of tail.split("\n")) {
1003
+ console.log(` ${line}`);
1004
+ }
1005
+ }
1006
+
1007
+ // ---------------------------------------------------------------------------
1008
+ // Uninstall / Doctor / URL
1009
+ // ---------------------------------------------------------------------------
1010
+
1011
+ async function cmdUninstall(argsList: string[]) {
1012
+ const wipe = argsList.includes("--wipe");
1013
+ const skipPrompts = argsList.includes("--yes") || argsList.includes("-y");
1014
+
1015
+ console.log("Parachute Vault uninstall\n");
1016
+ console.log("This removes the daemon registration and wrapper script.");
1017
+ if (wipe) {
1018
+ console.log("`--wipe` will ALSO remove vaults, .env, config.yaml, and daemon logs.\n");
1019
+ } else {
1020
+ console.log("User data (~/.parachute/vaults, ~/.parachute/.env) is left alone.\n");
1021
+ }
1022
+
1023
+ // Scripted `--yes --wipe` bypasses both interactive confirms. That's the
1024
+ // intended contract for unattended uninstalls, but it should not be
1025
+ // silent — print a single audit line so logs show when a destructive
1026
+ // wipe ran and which paths it targeted. Non-interactive callers won't
1027
+ // miss this; interactive users already see the prompts.
1028
+ if (skipPrompts && wipe) {
1029
+ const ts = new Date().toISOString();
1030
+ const targets = [VAULTS_DIR, ENV_PATH, GLOBAL_CONFIG_PATH, LOG_PATH, ERR_PATH].join(", ");
1031
+ console.log(`[${ts}] scripted destructive wipe: ${targets}`);
1032
+ }
1033
+
1034
+ if (!skipPrompts) {
1035
+ const ok = await confirm("Proceed?");
1036
+ if (!ok) {
1037
+ console.log("Cancelled.");
1038
+ return;
1039
+ }
1040
+ }
1041
+
1042
+ // 1. Stop and remove the daemon registration.
1043
+ if (process.platform === "darwin") {
1044
+ console.log("Removing launchd agent...");
1045
+ await uninstallAgent();
1046
+ // Scheduled backup agent lives in a separate plist — uninstall it too,
1047
+ // otherwise `uninstall` leaves the backup job firing forever on an
1048
+ // install whose daemon has been removed.
1049
+ await uninstallBackupAgent();
1050
+ } else if (isSystemdAvailable()) {
1051
+ console.log("Removing systemd service...");
1052
+ await uninstallSystemdService();
1053
+ } else {
1054
+ console.log("No daemon manager on this platform — skipping service removal.");
1055
+ }
1056
+
1057
+ // 2. Remove wrapper + pointer file (shared across platforms).
1058
+ console.log("Removing wrapper and server-path pointer...");
1059
+ await removeDaemonWrapper();
1060
+
1061
+ // 3. Clear the MCP entry in ~/.claude.json so Claude Code doesn't keep
1062
+ // retrying a dead server every session. If ~/.claude.json doesn't exist
1063
+ // or has no matching entry, this is a silent no-op.
1064
+ console.log("Removing MCP entry from ~/.claude.json...");
1065
+ removeMcpConfig();
1066
+
1067
+ // 4. Optionally wipe user data. The second confirm below defaults to
1068
+ // NO so a distracted Enter-presser can't lose their vault. `--yes`
1069
+ // explicitly opts into the destructive path for scripted uninstalls.
1070
+ if (wipe) {
1071
+ // Inventory what's actually on disk. Paths that don't exist are a
1072
+ // silent no-op on removal, but we also skip listing them so the
1073
+ // "would be removed" summary doesn't lie to the user.
1074
+ const vaultsExist = existsSync(VAULTS_DIR);
1075
+ const envExists = existsSync(ENV_PATH);
1076
+ const configExists = existsSync(GLOBAL_CONFIG_PATH);
1077
+ const logExists = existsSync(LOG_PATH);
1078
+ const errExists = existsSync(ERR_PATH);
1079
+
1080
+ const anyExist = vaultsExist || envExists || configExists || logExists || errExists;
1081
+ if (!anyExist) {
1082
+ console.log("No user data to remove.");
1083
+ } else {
1084
+ console.log("\nUser data that would be removed:");
1085
+ if (vaultsExist) console.log(` ${VAULTS_DIR} (SQLite vaults)`);
1086
+ if (envExists) console.log(` ${ENV_PATH} (.env config + secrets)`);
1087
+ if (configExists) console.log(` ${GLOBAL_CONFIG_PATH} (global config)`);
1088
+ if (logExists) console.log(` ${LOG_PATH} (daemon log)`);
1089
+ if (errExists) console.log(` ${ERR_PATH} (daemon error log)`);
1090
+
1091
+ // Default to NO on the wipe confirm — this is the "don't lose a
1092
+ // vault to muscle memory" guard. `--yes` is an explicit opt-in to
1093
+ // the whole destructive path.
1094
+ let doWipe = skipPrompts;
1095
+ if (!skipPrompts) {
1096
+ doWipe = await confirm("Delete this data? (cannot be undone)", false);
1097
+ }
1098
+ if (doWipe) {
1099
+ if (vaultsExist) rmSync(VAULTS_DIR, { recursive: true, force: true });
1100
+ if (envExists) rmSync(ENV_PATH, { force: true });
1101
+ if (configExists) rmSync(GLOBAL_CONFIG_PATH, { force: true });
1102
+ if (logExists) rmSync(LOG_PATH, { force: true });
1103
+ if (errExists) rmSync(ERR_PATH, { force: true });
1104
+ console.log("User data removed.");
1105
+ } else {
1106
+ console.log("Kept user data.");
1107
+ }
1108
+ }
1109
+ }
1110
+
1111
+ console.log("\nDone. To reinstall: `parachute vault init`.");
1112
+ }
1113
+
1114
+ interface DoctorCheck {
1115
+ name: string;
1116
+ status: "pass" | "warn" | "fail";
1117
+ detail?: string;
1118
+ fix?: string;
1119
+ }
1120
+
1121
+ async function cmdDoctor() {
1122
+ const checks: DoctorCheck[] = [];
1123
+
1124
+ // Pointer file. The stale-path failure mode shows up here first.
1125
+ if (!existsSync(SERVER_PATH_FILE)) {
1126
+ checks.push({
1127
+ name: "server-path pointer",
1128
+ status: "fail",
1129
+ detail: `missing: ${SERVER_PATH_FILE}`,
1130
+ fix: "Run `parachute vault init` to create it.",
1131
+ });
1132
+ } else {
1133
+ const pointed = readServerPathPointer();
1134
+ if (!pointed) {
1135
+ checks.push({
1136
+ name: "server-path pointer",
1137
+ status: "fail",
1138
+ detail: `empty: ${SERVER_PATH_FILE}`,
1139
+ fix: "Run `parachute vault init` to rewrite it.",
1140
+ });
1141
+ } else if (!existsSync(pointed)) {
1142
+ checks.push({
1143
+ name: "server.ts at pointer target",
1144
+ status: "fail",
1145
+ detail: `points to ${pointed}, which does not exist`,
1146
+ fix: "Run `parachute vault init` from the current repo location.",
1147
+ });
1148
+ } else {
1149
+ checks.push({
1150
+ name: "server-path pointer",
1151
+ status: "pass",
1152
+ detail: `→ ${pointed}`,
1153
+ });
1154
+ }
1155
+ }
1156
+
1157
+ // Wrapper script. Independent of the pointer — a missing wrapper means
1158
+ // launchd/systemd has nothing to exec.
1159
+ if (!existsSync(WRAPPER_PATH)) {
1160
+ checks.push({
1161
+ name: "wrapper script",
1162
+ status: "fail",
1163
+ detail: `missing: ${WRAPPER_PATH}`,
1164
+ fix: "Run `parachute vault init`.",
1165
+ });
1166
+ } else {
1167
+ checks.push({ name: "wrapper script", status: "pass", detail: WRAPPER_PATH });
1168
+ }
1169
+
1170
+ // Daemon registration.
1171
+ if (process.platform === "darwin") {
1172
+ const loaded = await isAgentLoaded();
1173
+ checks.push({
1174
+ name: "launchd agent",
1175
+ status: loaded ? "pass" : "warn",
1176
+ detail: loaded ? "loaded" : "not loaded",
1177
+ fix: loaded ? undefined : "Run `parachute vault init` or `parachute vault restart`.",
1178
+ });
1179
+ } else if (isSystemdAvailable()) {
1180
+ const active = await isServiceActive();
1181
+ checks.push({
1182
+ name: "systemd service",
1183
+ status: active ? "pass" : "warn",
1184
+ detail: active ? "active" : "not active",
1185
+ fix: active ? undefined : "Run `parachute vault init` or `parachute vault restart`.",
1186
+ });
1187
+ }
1188
+
1189
+ // bun on PATH. Not strictly required for an already-installed vault
1190
+ // (start.sh embeds an absolute bun path at init time), but missing `bun`
1191
+ // is the #1 failure mode for first-time OSS users, so surface it clearly.
1192
+ const bunOnPath = Bun.which("bun");
1193
+ if (bunOnPath) {
1194
+ checks.push({ name: "bun on PATH", status: "pass", detail: bunOnPath });
1195
+ } else {
1196
+ checks.push({
1197
+ name: "bun on PATH",
1198
+ status: "warn",
1199
+ detail: "`bun` not resolvable via PATH",
1200
+ fix: "Install Bun: curl -fsSL https://bun.sh/install | bash (then restart your shell).",
1201
+ });
1202
+ }
1203
+
1204
+ // MCP entry in ~/.claude.json. Split into three separate checks so the
1205
+ // user can see exactly which condition fails: "entry present", "port
1206
+ // matches vault", "daemon reachable over MCP URL". A common failure is
1207
+ // "entry exists but port is stale" after the user changed PORT without
1208
+ // re-running `mcp-install`.
1209
+ const port = resolveVaultPort();
1210
+ const mcpEntry = readMcpEntry();
1211
+ if (!mcpEntry.found) {
1212
+ checks.push({
1213
+ name: "MCP entry in ~/.claude.json",
1214
+ status: "warn",
1215
+ detail: mcpEntry.reason,
1216
+ fix: "Run `parachute vault mcp-install` to register the vault with Claude.",
1217
+ });
1218
+ } else {
1219
+ checks.push({
1220
+ name: "MCP entry in ~/.claude.json",
1221
+ status: "pass",
1222
+ detail: mcpEntry.url,
1223
+ });
1224
+
1225
+ // Port match. Fall back to a warn if the URL has no parseable port
1226
+ // (malformed entry) rather than a hard fail — we can still tell the
1227
+ // user what we saw.
1228
+ if (mcpEntry.port === port) {
1229
+ checks.push({
1230
+ name: "MCP URL port matches vault",
1231
+ status: "pass",
1232
+ detail: `port ${port}`,
1233
+ });
1234
+ } else {
1235
+ checks.push({
1236
+ name: "MCP URL port matches vault",
1237
+ status: "warn",
1238
+ detail: `MCP URL port ${mcpEntry.port ?? "(unparseable)"} ≠ vault port ${port}`,
1239
+ fix: "Re-run `parachute vault mcp-install` to refresh the MCP URL.",
1240
+ });
1241
+ }
1242
+
1243
+ // Reachability probe. We do NOT require the daemon to be up for doctor
1244
+ // to pass: a user who ran `vault status` already knows the daemon is
1245
+ // off. This line is just a bonus telltale. Treat any HTTP response
1246
+ // (even 401/404) as "reachable" — we're testing TCP+HTTP, not auth.
1247
+ const reach = await probeMcpUrl(mcpEntry.url);
1248
+ if (reach.ok) {
1249
+ checks.push({
1250
+ name: "MCP URL reachable",
1251
+ status: "pass",
1252
+ detail: reach.detail,
1253
+ });
1254
+ } else {
1255
+ checks.push({
1256
+ name: "MCP URL reachable",
1257
+ status: "warn",
1258
+ detail: reach.detail,
1259
+ fix: "Start the daemon: `parachute vault restart` (or `init` if not yet installed).",
1260
+ });
1261
+ }
1262
+ }
1263
+
1264
+ // Port collision. If something's holding the vault's configured port
1265
+ // that ISN'T our daemon, the server will fail to bind on restart — a
1266
+ // silent-until-crash-loop failure we want to catch at doctor time.
1267
+ const collision = await checkPortCollision(port);
1268
+ switch (collision.status) {
1269
+ case "free":
1270
+ checks.push({
1271
+ name: `port ${port} availability`,
1272
+ status: "pass",
1273
+ detail: "no listener (ready to bind)",
1274
+ });
1275
+ break;
1276
+ case "ours":
1277
+ checks.push({
1278
+ name: `port ${port} availability`,
1279
+ status: "pass",
1280
+ detail: `held by our daemon (pid ${collision.pids.join(", ")})`,
1281
+ });
1282
+ break;
1283
+ case "foreign":
1284
+ checks.push({
1285
+ name: `port ${port} availability`,
1286
+ status: "warn",
1287
+ detail: `port in use by non-vault process: ${collision.detail}`,
1288
+ fix: "Stop the conflicting process, or set a different PORT in ~/.parachute/.env and re-run `parachute vault init`.",
1289
+ });
1290
+ break;
1291
+ case "unknown":
1292
+ // Tool unavailable (no lsof / ss). Silent: we prefer a missing check
1293
+ // over a spurious warning on minimal Linux images.
1294
+ break;
1295
+ }
1296
+
1297
+ // Backup — only verify when the user has asked for automatic backups.
1298
+ // Manual mode is the default; showing a "not loaded" check when the user
1299
+ // explicitly didn't configure scheduled backups is noise, not a finding.
1300
+ const backupCfg = readGlobalConfig().backup;
1301
+ if (backupCfg && backupCfg.schedule !== "manual") {
1302
+ // 1. Agent loaded? (macOS only — systemd path for backup lands in a
1303
+ // follow-up PR; on Linux we silently skip this half of the check.)
1304
+ if (process.platform === "darwin") {
1305
+ const loaded = await isBackupAgentLoaded();
1306
+ checks.push({
1307
+ name: "backup agent",
1308
+ status: loaded ? "pass" : "warn",
1309
+ detail: loaded ? `loaded (schedule: ${backupCfg.schedule})` : `not loaded (schedule: ${backupCfg.schedule})`,
1310
+ fix: loaded ? undefined : `Re-run \`parachute vault backup --schedule ${backupCfg.schedule}\` to reinstall the agent.`,
1311
+ });
1312
+ }
1313
+
1314
+ // 2. Destination writability. A backup agent that fires into a path that
1315
+ // doesn't exist (or is read-only) is the worst failure mode — silent
1316
+ // until the user needs the backup, then "I have no backups." We try to
1317
+ // mkdir the configured path and tap it for write access.
1318
+ if (backupCfg.destinations.length === 0) {
1319
+ checks.push({
1320
+ name: "backup destinations",
1321
+ status: "warn",
1322
+ detail: "schedule is active but no destinations configured",
1323
+ fix: "Edit ~/.parachute/config.yaml and add at least one destination under `backup.destinations`.",
1324
+ });
1325
+ } else {
1326
+ for (const dest of backupCfg.destinations) {
1327
+ const res = checkDestinationWritable(dest);
1328
+ checks.push({
1329
+ name: `backup destination (${dest.kind})`,
1330
+ status: res.ok ? "pass" : "warn",
1331
+ detail: res.ok ? res.path : `${res.path}: ${res.error}`,
1332
+ fix: res.ok ? undefined : "Ensure the path exists and is writable, or update it in ~/.parachute/config.yaml.",
1333
+ });
834
1334
  }
1335
+ }
1336
+ }
1337
+
1338
+
1339
+ // Render.
1340
+ const icons = { pass: " ✓", warn: " !", fail: " ✗" } as const;
1341
+ console.log("Parachute Vault — doctor\n");
1342
+ for (const c of checks) {
1343
+ const icon = icons[c.status];
1344
+ console.log(` ${icon} ${c.name}${c.detail ? ` (${c.detail})` : ""}`);
1345
+ if (c.fix) console.log(` fix: ${c.fix}`);
1346
+ }
1347
+
1348
+ const hasFailure = checks.some((c) => c.status === "fail");
1349
+ const hasWarn = checks.some((c) => c.status === "warn");
1350
+ console.log();
1351
+ if (hasFailure) {
1352
+ console.log("doctor: problems found (exit 1). See `parachute vault status` for runtime details.");
1353
+ process.exit(1);
1354
+ } else if (hasWarn) {
1355
+ console.log("doctor: warnings only. `parachute vault status` has live runtime detail.");
1356
+ } else {
1357
+ console.log("doctor: all checks passed. For live runtime state: `parachute vault status`.");
1358
+ }
1359
+ }
1360
+
1361
+ function cmdUrl() {
1362
+ // Intentionally minimal — scripts parse this, so print only the URL.
1363
+ console.log(`http://127.0.0.1:${resolveVaultPort()}`);
1364
+ }
1365
+
1366
+ /**
1367
+ * Resolve the vault's port the way `status`, `restart`, `url`, and `doctor`
1368
+ * all need to agree on: env override (~/.parachute/.env) wins, then
1369
+ * config.yaml, then DEFAULT_PORT. Sources .env as a side effect so callers
1370
+ * running this before any env read still see PORT.
1371
+ */
1372
+ function resolveVaultPort(): number {
1373
+ loadEnvFile();
1374
+ const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
1375
+ return envPort ?? readGlobalConfig().port ?? DEFAULT_PORT;
1376
+ }
1377
+
1378
+ // ---------------------------------------------------------------------------
1379
+ // Doctor helpers — MCP entry / port collision
1380
+ // ---------------------------------------------------------------------------
1381
+
1382
+ type McpEntryLookup =
1383
+ | { found: false; reason: string }
1384
+ | { found: true; url: string; port: number | null };
1385
+
1386
+ /**
1387
+ * Read `~/.claude.json` and return the shape of the `parachute-vault` MCP
1388
+ * entry if present. The entry is always an HTTP MCP pointing at the local
1389
+ * daemon — `{ type: "http", url: "http://127.0.0.1:<port>/vaults/<name>/mcp" }`
1390
+ * — so we parse the URL's port for the port-match check.
1391
+ *
1392
+ * Invariant: the check is NON-fatal. A missing ~/.claude.json is a warn,
1393
+ * not a fail: plenty of users install the vault first and wire it to
1394
+ * Claude later. We just make the "is it wired up?" state legible.
1395
+ */
1396
+ function readMcpEntry(): McpEntryLookup {
1397
+ const claudeJsonPath = resolve(homedir(), ".claude.json");
1398
+ if (!existsSync(claudeJsonPath)) {
1399
+ return { found: false, reason: `${claudeJsonPath} does not exist` };
1400
+ }
1401
+ let config: any;
1402
+ try {
1403
+ config = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
1404
+ } catch (err: any) {
1405
+ return {
1406
+ found: false,
1407
+ reason: `${claudeJsonPath} is not valid JSON: ${String(err?.message ?? err)}`,
1408
+ };
1409
+ }
1410
+ const entry = config?.mcpServers?.["parachute-vault"];
1411
+ if (!entry) {
1412
+ return {
1413
+ found: false,
1414
+ reason: `no mcpServers["parachute-vault"] entry in ${claudeJsonPath}`,
1415
+ };
1416
+ }
1417
+ // The entry is always a URL-bearing HTTP MCP. Non-URL shapes are
1418
+ // unexpected (Claude Code would ignore them anyway) but we surface the
1419
+ // raw shape so the user can see what's there.
1420
+ const url = typeof entry.url === "string" ? entry.url : null;
1421
+ if (!url) {
1422
+ return {
1423
+ found: false,
1424
+ reason: `mcpServers["parachute-vault"] has no \`url\` field (got ${JSON.stringify(entry).slice(0, 80)})`,
1425
+ };
1426
+ }
1427
+ let entryPort: number | null = null;
1428
+ try {
1429
+ const parsed = new URL(url);
1430
+ entryPort = parsed.port ? Number(parsed.port) : null;
1431
+ } catch {
1432
+ entryPort = null;
1433
+ }
1434
+ return { found: true, url, port: entryPort };
1435
+ }
1436
+
1437
+ /**
1438
+ * HEAD-probe the MCP URL to tell "entry present but daemon unreachable"
1439
+ * apart from "entry present and daemon happily responding." Any HTTP
1440
+ * response — including auth failures — counts as reachable: the check is
1441
+ * about TCP + HTTP liveness, not correctness of the user's token.
1442
+ *
1443
+ * 2s timeout keeps `doctor` snappy when the daemon is down.
1444
+ */
1445
+ async function probeMcpUrl(url: string): Promise<{ ok: boolean; detail: string }> {
1446
+ const controller = new AbortController();
1447
+ const timer = setTimeout(() => controller.abort(), 2000);
1448
+ try {
1449
+ const resp = await fetch(url, { method: "HEAD", signal: controller.signal });
1450
+ return { ok: true, detail: `HTTP ${resp.status}` };
1451
+ } catch (err: any) {
1452
+ const msg = String(err?.message ?? err);
1453
+ if (
1454
+ /ECONNREFUSED|ConnectionRefused|Unable to connect|refused/i.test(msg) ||
1455
+ err?.code === "ECONNREFUSED"
1456
+ ) {
1457
+ return { ok: false, detail: `connection refused (daemon not running?)` };
1458
+ }
1459
+ if (err?.name === "AbortError" || /aborted|timeout/i.test(msg)) {
1460
+ return { ok: false, detail: `timeout after 2000ms` };
1461
+ }
1462
+ return { ok: false, detail: msg };
1463
+ } finally {
1464
+ clearTimeout(timer);
1465
+ }
1466
+ }
1467
+
1468
+ type PortCollision =
1469
+ | { status: "free" }
1470
+ | { status: "ours"; pids: number[] }
1471
+ | { status: "foreign"; pids: number[]; detail: string }
1472
+ | { status: "unknown" }; // no probe tool available
1473
+
1474
+ /**
1475
+ * Probe which process (if any) holds a TCP LISTEN socket on `port`.
1476
+ *
1477
+ * macOS: lsof is ubiquitous; we prefer it.
1478
+ * Linux: lsof is common but not guaranteed. Fall back to `ss -tlnp`. If
1479
+ * neither exists, return "unknown" rather than a misleading "free."
1480
+ *
1481
+ * To decide whether the holder is "ours," we pull `ps -o command= -p <pid>`
1482
+ * for each listening PID and look for a marker unique to our daemon —
1483
+ * either the wrapper path or the pointer-file's server.ts path. This is
1484
+ * heuristic but good enough to avoid warning when the vault is just
1485
+ * running normally.
1486
+ */
1487
+ async function checkPortCollision(port: number): Promise<PortCollision> {
1488
+ const pids = await listListeningPids(port);
1489
+ if (pids === null) return { status: "unknown" };
1490
+ if (pids.length === 0) return { status: "free" };
1491
+
1492
+ // Build the set of path fragments that mark a process as ours. We check
1493
+ // multiple signals because `doctor` may be running from a different
1494
+ // PARACHUTE_HOME than the live daemon (tempdirs, Docker, developer
1495
+ // machines): the wrapper path alone isn't enough. The pointer target and
1496
+ // the currently-resolved server.ts path cover the common deployments.
1497
+ const marks: string[] = [WRAPPER_PATH, resolveServerPath()];
1498
+ const pointed = readServerPathPointer();
1499
+ if (pointed) marks.push(pointed);
1500
+
1501
+ const descriptions: string[] = [];
1502
+ let allOurs = true;
1503
+ for (const pid of pids) {
1504
+ const cmdline = await describeProcess(pid);
1505
+ descriptions.push(`pid ${pid}: ${cmdline ?? "(unknown)"}`);
1506
+ const mine = cmdline !== null && marks.some((m) => cmdline.includes(m));
1507
+ if (!mine) allOurs = false;
1508
+ }
1509
+ if (allOurs) return { status: "ours", pids };
1510
+ return { status: "foreign", pids, detail: descriptions.join("; ") };
1511
+ }
1512
+
1513
+ /**
1514
+ * Return PIDs holding a TCP LISTEN socket on `port`, or null if we can't
1515
+ * tell (no probe tool). Empty array means no listener.
1516
+ */
1517
+ async function listListeningPids(port: number): Promise<number[] | null> {
1518
+ // Prefer lsof — same invocation works on macOS and Linux where present.
1519
+ if (Bun.which("lsof")) {
1520
+ try {
1521
+ const r = await Bun.$`lsof -nP -iTCP:${port} -sTCP:LISTEN -Fp`.quiet().nothrow();
1522
+ // lsof exits 1 with no output when nothing matches; that's "free," not
1523
+ // an error. We distinguish by inspecting stdout rather than exitCode.
1524
+ const out = r.stdout.toString();
1525
+ const pids = [...out.matchAll(/^p(\d+)/gm)].map((m) => Number(m[1]));
1526
+ return [...new Set(pids)];
835
1527
  } catch {
836
- console.log(`\n Health: daemon loaded but not responding`);
1528
+ // Fall through to ss if lsof itself blew up unexpectedly.
837
1529
  }
838
1530
  }
1531
+ if (Bun.which("ss")) {
1532
+ try {
1533
+ const r = await Bun.$`ss -tlnpH sport = :${port}`.quiet().nothrow();
1534
+ const out = r.stdout.toString();
1535
+ // `users:(("bun",pid=12345,fd=7))` — we pull every pid=NNNN.
1536
+ const pids = [...out.matchAll(/pid=(\d+)/g)].map((m) => Number(m[1]));
1537
+ if (pids.length > 0) return [...new Set(pids)];
1538
+ // No users field but a listener row present? Treat as foreign with no
1539
+ // attributable PID. Parsing the local address is overkill for a warn.
1540
+ if (/LISTEN/.test(out)) return [-1];
1541
+ return [];
1542
+ } catch {}
1543
+ }
1544
+ return null;
1545
+ }
1546
+
1547
+ /**
1548
+ * Fetch a one-line description of a process by PID. Returns null if the
1549
+ * PID is gone or ps is missing. Used only to classify a listener as ours
1550
+ * vs foreign, so "good enough" beats "precisely parsed."
1551
+ */
1552
+ async function describeProcess(pid: number): Promise<string | null> {
1553
+ if (pid < 0) return null;
1554
+ try {
1555
+ const r = await Bun.$`ps -o command= -p ${pid}`.quiet().nothrow();
1556
+ if (r.exitCode !== 0) return null;
1557
+ return r.stdout.toString().trim() || null;
1558
+ } catch {
1559
+ return null;
1560
+ }
1561
+ }
1562
+
1563
+ // ---------------------------------------------------------------------------
1564
+ // Backup — parachute vault backup [--schedule <freq> | status]
1565
+ // ---------------------------------------------------------------------------
1566
+
1567
+ async function cmdBackup(args: string[]) {
1568
+ // Subcommand: `status`
1569
+ if (args[0] === "status") {
1570
+ await cmdBackupStatus();
1571
+ return;
1572
+ }
1573
+
1574
+ // Flag: `--schedule <freq>`
1575
+ const schedFlag = args.indexOf("--schedule");
1576
+ if (schedFlag !== -1) {
1577
+ const raw = args[schedFlag + 1];
1578
+ if (!raw) {
1579
+ console.error("Usage: parachute vault backup --schedule <hourly|daily|weekly|manual>");
1580
+ process.exit(1);
1581
+ }
1582
+ if (raw !== "hourly" && raw !== "daily" && raw !== "weekly" && raw !== "manual") {
1583
+ console.error(`Invalid schedule: ${raw}. Must be one of: hourly, daily, weekly, manual.`);
1584
+ process.exit(1);
1585
+ }
1586
+ await cmdBackupSchedule(raw);
1587
+ return;
1588
+ }
1589
+
1590
+ // Default: one-shot backup.
1591
+ await cmdBackupRun();
1592
+ }
1593
+
1594
+ async function cmdBackupRun() {
1595
+ const cfg = readGlobalConfig().backup ?? defaultBackupConfig();
1596
+ if (cfg.destinations.length === 0) {
1597
+ console.error("No backup destinations configured. Edit ~/.parachute/config.yaml:");
1598
+ console.error(" backup:");
1599
+ console.error(" destinations:");
1600
+ console.error(" - kind: local");
1601
+ console.error(` path: ~/Library/Mobile Documents/com~apple~CloudDocs/parachute-backups`);
1602
+ process.exit(1);
1603
+ }
1604
+
1605
+ console.log("Running backup...");
1606
+ const result = await runBackup({ backup: cfg });
1607
+ const bytes = result.bytes;
1608
+ const kb = Math.round(bytes / 1024);
1609
+ console.log(` Snapshot: ${result.contents.dbSnapshots.length} DB(s), ${result.contents.configFiles.length} config file(s), ${kb} KB`);
1610
+
1611
+ let ok = 0;
1612
+ for (const r of result.destinations) {
1613
+ if (r.error) {
1614
+ console.error(` ${r.destination.kind} — FAILED: ${r.error}`);
1615
+ continue;
1616
+ }
1617
+ ok++;
1618
+ const prunedStr = r.pruned > 0 ? ` (pruned ${r.pruned} older)` : "";
1619
+ console.log(` ${r.destination.kind} → ${r.writtenPath}${prunedStr}`);
1620
+ }
1621
+ if (ok === 0) {
1622
+ process.exit(1);
1623
+ }
1624
+ }
1625
+
1626
+ async function cmdBackupSchedule(schedule: BackupSchedule) {
1627
+ const cfg = updateBackupConfig({ schedule });
1628
+
1629
+ if (process.platform !== "darwin") {
1630
+ console.log(`Schedule set to: ${schedule}`);
1631
+ console.log("Note: scheduled backups are only registered on macOS in this MVP.");
1632
+ console.log("Linux systemd-timer support is a follow-up PR.");
1633
+ return;
1634
+ }
1635
+
1636
+ if (schedule === "manual") {
1637
+ await uninstallBackupAgent();
1638
+ console.log("Schedule: manual — backup agent removed.");
1639
+ console.log("Run `parachute vault backup` to trigger a backup on demand.");
1640
+ return;
1641
+ }
1642
+
1643
+ if (cfg.destinations.length === 0) {
1644
+ console.log(`Schedule set to: ${schedule}`);
1645
+ console.log();
1646
+ console.log("WARNING: no destinations configured — scheduled runs will fail.");
1647
+ console.log("Edit ~/.parachute/config.yaml and add at least one destination under `backup.destinations`.");
1648
+ }
1649
+
1650
+ await installBackupAgent(schedule);
1651
+ console.log(`Schedule: ${schedule} — backup agent installed.`);
1652
+ console.log(` Plist: ${BACKUP_PLIST_PATH}`);
1653
+ console.log(` Next run: ${describeNextRun(schedule)}`);
1654
+ }
1655
+
1656
+ async function cmdBackupStatus() {
1657
+ const cfg = readGlobalConfig().backup ?? defaultBackupConfig();
1658
+ const last = readLastBackup();
1659
+
1660
+ console.log("Parachute Vault — backup\n");
1661
+ console.log(` Schedule: ${cfg.schedule}`);
1662
+ // Tiered retention is always rendered as a one-line summary; per-tier
1663
+ // counts from real destinations go under each destination below.
1664
+ const r = cfg.retention;
1665
+ const yearlyStr = r.yearly === null ? "∞" : String(r.yearly);
1666
+ console.log(` Retention: ${r.daily} daily / ${r.weekly} weekly / ${r.monthly} monthly / ${yearlyStr} yearly`);
1667
+
1668
+ if (cfg.destinations.length === 0) {
1669
+ console.log(` Destinations: (none)`);
1670
+ } else {
1671
+ console.log(` Destinations:`);
1672
+ for (const d of cfg.destinations) {
1673
+ if (d.kind === "local") {
1674
+ const path = expandTilde(d.path);
1675
+ console.log(` - local: ${path}`);
1676
+ // Per-destination tier breakdown — counts the snapshots on disk and
1677
+ // each tier's individual contribution to the keep set. A snapshot
1678
+ // can satisfy multiple tiers, so per-tier counts may sum to > total.
1679
+ const t = tierTally(path, cfg.retention);
1680
+ if (t.total > 0) {
1681
+ console.log(` on-disk: ${t.total} snapshot(s) — ${t.daily}d / ${t.weekly}w / ${t.monthly}mo / ${t.yearly}y`);
1682
+ }
1683
+ }
1684
+ }
1685
+ }
1686
+
1687
+ if (process.platform === "darwin") {
1688
+ const loaded = await isBackupAgentLoaded();
1689
+ console.log(` Agent: ${loaded ? "loaded (launchctl)" : "not loaded"}`);
1690
+ }
1691
+
1692
+ if (last) {
1693
+ console.log();
1694
+ console.log(` Last run: ${last.timestamp}`);
1695
+ console.log(` Size: ${Math.round(last.bytes / 1024)} KB`);
1696
+ for (const d of last.destinations) {
1697
+ if (d.error) {
1698
+ console.log(` FAILED: ${d.error}`);
1699
+ } else if (d.path) {
1700
+ console.log(` Wrote: ${d.path}`);
1701
+ }
1702
+ }
1703
+ } else {
1704
+ console.log();
1705
+ console.log(" Last run: (never)");
1706
+ }
1707
+
1708
+ if (cfg.schedule !== "manual") {
1709
+ console.log();
1710
+ console.log(` Next run: ${describeNextRun(cfg.schedule)}`);
1711
+ }
1712
+ }
1713
+
1714
+ function describeNextRun(schedule: BackupSchedule): string {
1715
+ const est = nextRunEstimate(schedule, new Date());
1716
+ if (!est) return "(manual — on demand only)";
1717
+ // Render in local time so the user's sense of "around 3am" matches what
1718
+ // launchd will actually do.
1719
+ return est.toLocaleString();
839
1720
  }
840
1721
 
841
1722
  // ---------------------------------------------------------------------------
@@ -930,7 +1811,7 @@ async function cmdImport(args: string[]) {
930
1811
 
931
1812
  for (const note of notes) {
932
1813
  // Skip if a note with this path already exists
933
- const existing = store.getNoteByPath(note.path);
1814
+ const existing = await store.getNoteByPath(note.path);
934
1815
  if (existing) {
935
1816
  skipped++;
936
1817
  continue;
@@ -939,7 +1820,7 @@ async function cmdImport(args: string[]) {
939
1820
  // Build metadata from frontmatter (excluding tags, already extracted)
940
1821
  const metadata = Object.keys(note.frontmatter).length > 0 ? note.frontmatter : undefined;
941
1822
 
942
- store.createNoteRaw(note.content, {
1823
+ await store.createNoteRaw(note.content, {
943
1824
  path: note.path,
944
1825
  tags: note.tags.length > 0 ? note.tags : undefined,
945
1826
  metadata: metadata as Record<string, unknown>,
@@ -952,7 +1833,7 @@ async function cmdImport(args: string[]) {
952
1833
  if (skipped > 0) console.log(`Skipped ${skipped} notes (path already exists)`);
953
1834
 
954
1835
  if (imported > 0) {
955
- const linkResult = store.syncAllWikilinks();
1836
+ const linkResult = await store.syncAllWikilinks();
956
1837
  console.log(`Resolved ${linkResult.totalAdded} wikilinks across ${linkResult.synced} notes.`);
957
1838
  }
958
1839
  }
@@ -992,7 +1873,7 @@ async function cmdExport(args: string[]) {
992
1873
  const { getVaultStore } = await import("./vault-store.ts");
993
1874
 
994
1875
  const store = getVaultStore(vaultName);
995
- const notes = store.queryNotes({ limit: 100000, sort: "asc" });
1876
+ const notes = await store.queryNotes({ limit: 100000, sort: "asc" });
996
1877
 
997
1878
  console.log(`Exporting ${notes.length} notes from vault "${vaultName}" to ${fullPath}`);
998
1879
  mkdir(fullPath, { recursive: true });
@@ -1087,8 +1968,14 @@ function usage() {
1087
1968
  Parachute Vault — self-hosted knowledge graph
1088
1969
 
1089
1970
  Setup:
1090
- parachute vault init Set up everything (one command)
1971
+ parachute vault init Set up everything (one command, idempotent)
1091
1972
  parachute vault status Check what's running
1973
+ parachute vault doctor Diagnose install/config issues
1974
+ parachute vault uninstall [--wipe] [--yes]
1975
+ Remove daemon + MCP entry; --wipe also removes vaults, .env,
1976
+ config.yaml, and daemon logs (vault.log, vault.err).
1977
+ --yes skips prompts (DANGEROUS with --wipe: no confirmation).
1978
+ parachute vault url Print the local server URL (for scripts)
1092
1979
 
1093
1980
  Vaults:
1094
1981
  parachute vault create <name> Create a new vault
@@ -1118,6 +2005,11 @@ Config:
1118
2005
  parachute vault config set <key> <val> Set a config value
1119
2006
  parachute vault config unset <key> Remove a config value
1120
2007
 
2008
+ Backup:
2009
+ parachute vault backup One-shot backup to configured destinations
2010
+ parachute vault backup --schedule <freq> hourly | daily | weekly | manual (macOS launchd)
2011
+ parachute vault backup status Show schedule, last run, destinations, next run
2012
+
1121
2013
  Import/Export:
1122
2014
  parachute vault import <path> Import an Obsidian vault
1123
2015
  parachute vault import <path> --dry-run Preview import without writing