@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.
- package/CHANGELOG.md +80 -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 +57 -12
- package/src/published.test.ts +21 -21
- package/src/routes.ts +152 -70
- package/src/routing.test.ts +347 -0
- package/src/routing.ts +365 -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/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 {
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
801
|
-
const globalConfig = readGlobalConfig();
|
|
934
|
+
const health = await checkHealth(port);
|
|
802
935
|
|
|
803
936
|
console.log("Parachute Vault\n");
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
//
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
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
|