@openparachute/vault 0.4.3 → 0.4.4-rc.11
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/README.md +58 -2
- package/core/src/core.test.ts +116 -0
- package/core/src/mcp.ts +94 -4
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +19 -1
- package/core/src/store.ts +13 -0
- package/core/src/types.ts +15 -0
- package/package.json +1 -1
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/routes.ts +19 -3
- package/src/vault.test.ts +141 -0
package/src/cli.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* parachute-vault init — set up everything, one command
|
|
8
8
|
* parachute-vault create <name> — create a new vault
|
|
9
9
|
* parachute-vault list — list all vaults
|
|
10
|
-
* parachute-vault mcp-install
|
|
10
|
+
* parachute-vault mcp-install [flags] — install vault MCP into a client config
|
|
11
|
+
* (defaults: --mint into ~/.claude.json)
|
|
11
12
|
* parachute-vault remove <name> — remove a vault
|
|
12
13
|
* parachute-vault config — show all config
|
|
13
14
|
* parachute-vault config set <key> <val> — set a config value
|
|
@@ -51,7 +52,21 @@ import {
|
|
|
51
52
|
import type { VaultConfig } from "./config.ts";
|
|
52
53
|
import { DATA_DIR } from "./config.ts";
|
|
53
54
|
import { installAgent, uninstallAgent, isAgentLoaded, restartAgent } from "./launchd.ts";
|
|
54
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
buildMcpEntryPlan,
|
|
57
|
+
chooseHubOrigin,
|
|
58
|
+
detectInstallContext,
|
|
59
|
+
mintHubJwt,
|
|
60
|
+
readOperatorToken,
|
|
61
|
+
removeMcpConfig,
|
|
62
|
+
resolveInstallTarget,
|
|
63
|
+
type InstallScope,
|
|
64
|
+
} from "./mcp-install.ts";
|
|
65
|
+
import {
|
|
66
|
+
defaultInteractiveIO,
|
|
67
|
+
runInteractiveInstall,
|
|
68
|
+
type InteractiveIO,
|
|
69
|
+
} from "./mcp-install-interactive.ts";
|
|
55
70
|
import { buildInitSummaryLines } from "./init-summary.ts";
|
|
56
71
|
import {
|
|
57
72
|
runBackup,
|
|
@@ -152,7 +167,7 @@ switch (command) {
|
|
|
152
167
|
cmdList();
|
|
153
168
|
break;
|
|
154
169
|
case "mcp-install":
|
|
155
|
-
cmdMcpInstall(cmdArgs);
|
|
170
|
+
await cmdMcpInstall(cmdArgs);
|
|
156
171
|
break;
|
|
157
172
|
case "remove":
|
|
158
173
|
case "rm":
|
|
@@ -503,7 +518,25 @@ async function cmdInit(args: string[] = []) {
|
|
|
503
518
|
}
|
|
504
519
|
|
|
505
520
|
if (addMcp) {
|
|
506
|
-
|
|
521
|
+
// Init's bootstrap path stays on the pvt_* shape so a fresh-install
|
|
522
|
+
// without a hub still works out of the box. Operators with a hub can
|
|
523
|
+
// re-run `parachute-vault mcp-install` (defaults to hub-mint) to
|
|
524
|
+
// upgrade. Goes through `buildMcpEntryPlan` for entryKey + url so this
|
|
525
|
+
// path shares the writer-side invariant with `executeMcpInstall` — a
|
|
526
|
+
// future URL-shape change can't drift between init and mcp-install.
|
|
527
|
+
const target = resolveInstallTarget("user");
|
|
528
|
+
const { entryKey, url, source } = buildMcpEntryPlan({
|
|
529
|
+
vaultName: defaultVault,
|
|
530
|
+
vaultExplicit: false,
|
|
531
|
+
port: globalConfig.port || DEFAULT_PORT,
|
|
532
|
+
});
|
|
533
|
+
installMcpConfig({
|
|
534
|
+
targetPath: target.path,
|
|
535
|
+
entryKey,
|
|
536
|
+
url,
|
|
537
|
+
bearer: apiKey,
|
|
538
|
+
});
|
|
539
|
+
console.log(`MCP URL: ${url} (${source})`);
|
|
507
540
|
console.log(` MCP server added to ~/.claude.json`);
|
|
508
541
|
} else {
|
|
509
542
|
console.log(" Skipped adding MCP to ~/.claude.json.");
|
|
@@ -880,10 +913,344 @@ function cmdList() {
|
|
|
880
913
|
}
|
|
881
914
|
}
|
|
882
915
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
916
|
+
/**
|
|
917
|
+
* Parse a `--flag value` pair from an args array. Returns the value (or
|
|
918
|
+
* undefined when the flag isn't present) and a flag whether the flag's value
|
|
919
|
+
* was actually present (catches `--flag` without an argument).
|
|
920
|
+
*/
|
|
921
|
+
function takeArgValue(args: string[], name: string): { value?: string; missingValue?: boolean } {
|
|
922
|
+
const idx = args.indexOf(name);
|
|
923
|
+
if (idx === -1) return {};
|
|
924
|
+
const next = args[idx + 1];
|
|
925
|
+
if (!next || next.startsWith("--")) return { missingValue: true };
|
|
926
|
+
return { value: next };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* `parachute-vault mcp-install` — install the vault MCP server into an
|
|
931
|
+
* AI client's config. Three auth modes (mutually exclusive):
|
|
932
|
+
*
|
|
933
|
+
* --mint (default) Mint a hub JWT via `POST <hub>/api/auth/mint-token`
|
|
934
|
+
* using the local operator token.
|
|
935
|
+
* --token <bearer> Use an existing token (hub JWT, pvt_*, anything).
|
|
936
|
+
* --legacy-pat Mint a vault-DB `pvt_*` token (deprecated;
|
|
937
|
+
* self-hosted-without-hub setups).
|
|
938
|
+
*
|
|
939
|
+
* Targeting:
|
|
940
|
+
* --scope <verb> vault:read | vault:write | vault:admin (default: vault:read).
|
|
941
|
+
* For --mint, expands to vault:<vault-name>:<verb>.
|
|
942
|
+
* --install-scope <s> local (default) | user | project. local writes to
|
|
943
|
+
* ~/.claude.json under projects[<cwd>].mcpServers
|
|
944
|
+
* (private, this directory only — matches Claude
|
|
945
|
+
* Code's own default). user writes to top-level
|
|
946
|
+
* mcpServers (every project). project writes to
|
|
947
|
+
* ./.mcp.json (check into the repo).
|
|
948
|
+
* --vault <name> Vault to target (default: default_vault). Multi-vault
|
|
949
|
+
* installs key the entry as `parachute-vault-<name>`.
|
|
950
|
+
* --client <name> Reserved for Phase C. Only `claude-code` accepted.
|
|
951
|
+
*/
|
|
952
|
+
async function cmdMcpInstall(args: string[]): Promise<void> {
|
|
953
|
+
// Set of install-shaping flags. Any one flips dispatch to non-interactive
|
|
954
|
+
// — flag-passing semantics are "I know what I want."
|
|
955
|
+
//
|
|
956
|
+
// Declared inside the function (rather than module-top) because this
|
|
957
|
+
// file's top-level dispatch await runs cmdMcpInstall during module
|
|
958
|
+
// initialization, and a module-top `const` is still in its temporal
|
|
959
|
+
// dead zone when the function body first executes from that dispatch.
|
|
960
|
+
const MCP_INSTALL_FLAG_NAMES = [
|
|
961
|
+
"--mint",
|
|
962
|
+
"--legacy-pat",
|
|
963
|
+
"--token",
|
|
964
|
+
"--scope",
|
|
965
|
+
"--install-scope",
|
|
966
|
+
"--vault",
|
|
967
|
+
"--client",
|
|
968
|
+
];
|
|
969
|
+
|
|
970
|
+
const hasFlag = args.some((a) => MCP_INSTALL_FLAG_NAMES.includes(a));
|
|
971
|
+
const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
972
|
+
|
|
973
|
+
// Interactive dispatch fires when the operator passed no install-shaping
|
|
974
|
+
// flags AND stdin is a TTY. No separate `--interactive` flag — TTY+
|
|
975
|
+
// no-flags already covers the "walk me through it" case, and forcing
|
|
976
|
+
// interactive with partial flags is a half-feature that silently
|
|
977
|
+
// discards co-present flag values (vault#292 review F1). Non-TTY
|
|
978
|
+
// bare invocation falls through to the flag-driven defaults.
|
|
979
|
+
if (isTTY && !hasFlag) {
|
|
980
|
+
return await cmdMcpInstallInteractive();
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// --- Auth-mode parsing (mutually exclusive) ---
|
|
984
|
+
const wantMint = args.includes("--mint");
|
|
985
|
+
const wantLegacy = args.includes("--legacy-pat");
|
|
986
|
+
const tokenArg = takeArgValue(args, "--token");
|
|
987
|
+
if (tokenArg.missingValue) {
|
|
988
|
+
console.error("--token requires a value (the bearer token to embed).");
|
|
989
|
+
process.exit(1);
|
|
990
|
+
}
|
|
991
|
+
const wantToken = tokenArg.value !== undefined;
|
|
992
|
+
const modesSet = (wantMint ? 1 : 0) + (wantLegacy ? 1 : 0) + (wantToken ? 1 : 0);
|
|
993
|
+
if (modesSet > 1) {
|
|
994
|
+
console.error("--mint, --token, and --legacy-pat are mutually exclusive.");
|
|
995
|
+
process.exit(1);
|
|
996
|
+
}
|
|
997
|
+
const mode: "mint" | "token" | "legacy-pat" =
|
|
998
|
+
wantToken ? "token" : wantLegacy ? "legacy-pat" : "mint";
|
|
999
|
+
|
|
1000
|
+
// --- Scope parsing. Default vault:read (least-privilege). ---
|
|
1001
|
+
const scopeArg = takeArgValue(args, "--scope");
|
|
1002
|
+
if (scopeArg.missingValue) {
|
|
1003
|
+
console.error("--scope requires a value: vault:read, vault:write, or vault:admin.");
|
|
1004
|
+
process.exit(1);
|
|
1005
|
+
}
|
|
1006
|
+
const rawScope = scopeArg.value ?? "vault:read";
|
|
1007
|
+
if (!(VAULT_SCOPES as readonly string[]).includes(rawScope)) {
|
|
1008
|
+
console.error(
|
|
1009
|
+
`--scope must be one of: ${VAULT_SCOPES.join(", ")}. Got: ${rawScope}.`,
|
|
1010
|
+
);
|
|
1011
|
+
process.exit(1);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// --- Install scope: local (default), user, or project. ---
|
|
1015
|
+
// `local` matches Claude Code's own `claude mcp add` default — entry sits
|
|
1016
|
+
// under `projects[<cwd>].mcpServers` in ~/.claude.json, private to this
|
|
1017
|
+
// machine + this directory. Operators wanting a global install pass
|
|
1018
|
+
// `--install-scope user`; team-shared installs pass `--install-scope project`.
|
|
1019
|
+
const installScopeArg = takeArgValue(args, "--install-scope");
|
|
1020
|
+
if (installScopeArg.missingValue) {
|
|
1021
|
+
console.error("--install-scope requires a value: local, user, or project.");
|
|
1022
|
+
process.exit(1);
|
|
1023
|
+
}
|
|
1024
|
+
const installScopeRaw = installScopeArg.value ?? "local";
|
|
1025
|
+
if (
|
|
1026
|
+
installScopeRaw !== "user" &&
|
|
1027
|
+
installScopeRaw !== "project" &&
|
|
1028
|
+
installScopeRaw !== "local"
|
|
1029
|
+
) {
|
|
1030
|
+
console.error(
|
|
1031
|
+
`--install-scope must be "local", "user", or "project". Got: ${installScopeRaw}.`,
|
|
1032
|
+
);
|
|
1033
|
+
process.exit(1);
|
|
1034
|
+
}
|
|
1035
|
+
const installScope: InstallScope = installScopeRaw;
|
|
1036
|
+
|
|
1037
|
+
// --- Client (Phase C placeholder). Reject anything other than claude-code. ---
|
|
1038
|
+
// Validated before filesystem-dependent vault-existence so a static flag
|
|
1039
|
+
// typo fails on its own terms ("--client cursor not supported") rather
|
|
1040
|
+
// than getting masked by an unrelated "vault not found" error.
|
|
1041
|
+
const clientArg = takeArgValue(args, "--client");
|
|
1042
|
+
if (clientArg.missingValue) {
|
|
1043
|
+
console.error("--client requires a value.");
|
|
1044
|
+
process.exit(1);
|
|
1045
|
+
}
|
|
1046
|
+
const client = clientArg.value ?? "claude-code";
|
|
1047
|
+
if (client !== "claude-code") {
|
|
1048
|
+
console.error(
|
|
1049
|
+
`--client "${client}" is not yet supported. Only "claude-code" is wired up; ` +
|
|
1050
|
+
`Cursor / Claude Desktop / Codex / Zed are Phase C work.`,
|
|
1051
|
+
);
|
|
1052
|
+
process.exit(1);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// --- Vault target. Default = default_vault; validate existence. ---
|
|
1056
|
+
const vaultArg = takeArgValue(args, "--vault");
|
|
1057
|
+
if (vaultArg.missingValue) {
|
|
1058
|
+
console.error("--vault requires a value (the vault name).");
|
|
1059
|
+
process.exit(1);
|
|
1060
|
+
}
|
|
1061
|
+
const globalConfig = readGlobalConfig();
|
|
1062
|
+
const defaultVault = globalConfig.default_vault || "default";
|
|
1063
|
+
const vaultName = vaultArg.value ?? defaultVault;
|
|
1064
|
+
const vaultExplicit = vaultArg.value !== undefined;
|
|
1065
|
+
if (!readVaultConfig(vaultName)) {
|
|
1066
|
+
console.error(`Vault "${vaultName}" not found. Available: ${listVaults().join(", ") || "(none)"}.`);
|
|
1067
|
+
process.exit(1);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
await executeMcpInstall({
|
|
1071
|
+
mode,
|
|
1072
|
+
rawScope,
|
|
1073
|
+
installScope,
|
|
1074
|
+
vaultName,
|
|
1075
|
+
vaultExplicit,
|
|
1076
|
+
pastedToken: mode === "token" ? tokenArg.value : undefined,
|
|
1077
|
+
globalConfig,
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Interactive front-end for `mcp-install`. Builds the install context,
|
|
1083
|
+
* walks the operator through prompts, then hands the resolved decision
|
|
1084
|
+
* to `executeMcpInstall`. Failure modes from the walkthrough (operator
|
|
1085
|
+
* aborted, no vaults yet, etc.) exit cleanly without touching disk.
|
|
1086
|
+
*/
|
|
1087
|
+
async function cmdMcpInstallInteractive(): Promise<void> {
|
|
1088
|
+
const globalConfig = readGlobalConfig();
|
|
1089
|
+
const defaultVault = globalConfig.default_vault || "default";
|
|
1090
|
+
const port = globalConfig.port || DEFAULT_PORT;
|
|
1091
|
+
const ctx = detectInstallContext({
|
|
1092
|
+
vaults: listVaults(),
|
|
1093
|
+
defaultVault,
|
|
1094
|
+
port,
|
|
1095
|
+
});
|
|
1096
|
+
const io = await defaultInteractiveIO();
|
|
1097
|
+
const decision = await runInteractiveInstall(ctx, io);
|
|
1098
|
+
if (decision === "abort") {
|
|
1099
|
+
process.exit(0);
|
|
1100
|
+
}
|
|
1101
|
+
await executeMcpInstall({
|
|
1102
|
+
mode: decision.mode,
|
|
1103
|
+
rawScope: decision.scope,
|
|
1104
|
+
installScope: decision.installScope,
|
|
1105
|
+
vaultName: decision.vaultName,
|
|
1106
|
+
vaultExplicit: decision.vaultExplicit,
|
|
1107
|
+
pastedToken: decision.pastedToken,
|
|
1108
|
+
existingEntryKey: decision.existingEntryKey,
|
|
1109
|
+
globalConfig,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
interface ExecuteMcpInstallOpts {
|
|
1114
|
+
mode: "mint" | "token" | "legacy-pat";
|
|
1115
|
+
/** Full scope string (e.g. "vault:read"). The verb segment narrows downstream. */
|
|
1116
|
+
rawScope: string;
|
|
1117
|
+
installScope: InstallScope;
|
|
1118
|
+
vaultName: string;
|
|
1119
|
+
/** Whether vault target was explicit (controls singular vs per-vault entry key). */
|
|
1120
|
+
vaultExplicit: boolean;
|
|
1121
|
+
/** Bearer the operator pasted in `--token` / interactive paste mode. */
|
|
1122
|
+
pastedToken?: string;
|
|
1123
|
+
/**
|
|
1124
|
+
* When the interactive walkthrough is updating an existing entry, the
|
|
1125
|
+
* walkthrough's preview pinned a specific key (e.g. `parachute-vault-work`
|
|
1126
|
+
* because that's what was already there). Passing it through ensures the
|
|
1127
|
+
* write lands at the same key the operator just confirmed. Absent for
|
|
1128
|
+
* fresh installs and for the flag-driven path (which always synthesizes).
|
|
1129
|
+
* See vault#293.
|
|
1130
|
+
*/
|
|
1131
|
+
existingEntryKey?: string;
|
|
1132
|
+
/** Reused across the call chain to avoid re-parsing config.yaml. */
|
|
1133
|
+
globalConfig: ReturnType<typeof readGlobalConfig>;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Shared backend for both the flag-driven path and the interactive
|
|
1138
|
+
* walkthrough. Acquires the bearer per mode, writes the entry, prints the
|
|
1139
|
+
* result. The interactive path delays this call until *after* the
|
|
1140
|
+
* preview-and-confirm step so a cancel skips the network mint entirely.
|
|
1141
|
+
*/
|
|
1142
|
+
async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
|
|
1143
|
+
const { mode, rawScope, installScope, vaultName, vaultExplicit, pastedToken, globalConfig, existingEntryKey } = opts;
|
|
1144
|
+
const verb = rawScope.split(":")[1]!;
|
|
1145
|
+
const target = resolveInstallTarget(installScope);
|
|
1146
|
+
// Single source of truth shared with the interactive walkthrough's
|
|
1147
|
+
// preview — preview-shows-this-shape ⇒ this-shape-lands-on-disk. The
|
|
1148
|
+
// helper closes both halves of the invariant (entryKey + url) — see
|
|
1149
|
+
// vault#293 for the seam, vault#302 for closing the writer-side URL.
|
|
1150
|
+
const { entryKey, url, source } = buildMcpEntryPlan({
|
|
1151
|
+
vaultName,
|
|
1152
|
+
vaultExplicit,
|
|
1153
|
+
port: globalConfig.port || DEFAULT_PORT,
|
|
1154
|
+
...(existingEntryKey ? { existingEntryKey } : {}),
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
let bearer: string;
|
|
1158
|
+
if (mode === "token") {
|
|
1159
|
+
if (!pastedToken) {
|
|
1160
|
+
console.error("Internal error: token mode missing pastedToken.");
|
|
1161
|
+
process.exit(1);
|
|
1162
|
+
}
|
|
1163
|
+
bearer = pastedToken;
|
|
1164
|
+
console.log(`Using supplied token (skipping mint).`);
|
|
1165
|
+
} else if (mode === "legacy-pat") {
|
|
1166
|
+
console.error(
|
|
1167
|
+
"Note: --legacy-pat mints a vault-DB pvt_* token. The hub-issued JWT path (--mint, default) " +
|
|
1168
|
+
"is the canonical install going forward; pvt_* support is preserved for self-hosted-without-hub " +
|
|
1169
|
+
"setups, tracked at vault#288, planned removal 0.6.0.",
|
|
1170
|
+
);
|
|
1171
|
+
const store = getVaultStore(vaultName);
|
|
1172
|
+
const { fullToken } = generateToken();
|
|
1173
|
+
// Narrow the pvt_* to the requested verb's scope set when not full-admin.
|
|
1174
|
+
// `scopes: undefined` leaves the token at full vault permissions
|
|
1175
|
+
// (admin); narrowing to a single-scope array gates it to that verb.
|
|
1176
|
+
const createTokenOpts: Parameters<typeof createToken>[2] = {
|
|
1177
|
+
label: "mcp-install",
|
|
1178
|
+
permission: verb === "read" ? "read" : "full",
|
|
1179
|
+
vault_name: vaultName,
|
|
1180
|
+
};
|
|
1181
|
+
if (verb !== "admin") {
|
|
1182
|
+
createTokenOpts.scopes = [rawScope];
|
|
1183
|
+
}
|
|
1184
|
+
createToken(store.db, fullToken, createTokenOpts);
|
|
1185
|
+
bearer = fullToken;
|
|
1186
|
+
} else {
|
|
1187
|
+
// mode === "mint"
|
|
1188
|
+
const operatorToken = readOperatorToken();
|
|
1189
|
+
if (!operatorToken) {
|
|
1190
|
+
console.error(
|
|
1191
|
+
"No operator token found at ~/.parachute/operator.token. The default install path " +
|
|
1192
|
+
"(--mint) requires a hub-issued operator token to mint scope-narrow JWTs.\n" +
|
|
1193
|
+
" Fix: run `parachute auth rotate-operator` to create one, then re-run.\n" +
|
|
1194
|
+
" Or: use `--token <bearer>` to paste an existing token, or `--legacy-pat` to " +
|
|
1195
|
+
"mint a vault-DB pvt_* token (self-hosted-without-hub).",
|
|
1196
|
+
);
|
|
1197
|
+
process.exit(1);
|
|
1198
|
+
}
|
|
1199
|
+
const port = globalConfig.port || DEFAULT_PORT;
|
|
1200
|
+
const hub = chooseHubOrigin(port);
|
|
1201
|
+
if (hub.source === "loopback") {
|
|
1202
|
+
console.error(
|
|
1203
|
+
"No hub origin configured (PARACHUTE_HUB_ORIGIN unset, no active expose-state). " +
|
|
1204
|
+
"Hub-mint (--mint) needs a real hub URL to call. Either:\n" +
|
|
1205
|
+
" - Start the hub and set PARACHUTE_HUB_ORIGIN, OR\n" +
|
|
1206
|
+
" - Bring up an exposure (`parachute expose tailnet`), OR\n" +
|
|
1207
|
+
" - Use --legacy-pat to mint a vault-DB pvt_* token instead.",
|
|
1208
|
+
);
|
|
1209
|
+
process.exit(1);
|
|
1210
|
+
}
|
|
1211
|
+
const narrowScope = `vault:${vaultName}:${verb}`;
|
|
1212
|
+
const result = await mintHubJwt({
|
|
1213
|
+
hubOrigin: hub.url,
|
|
1214
|
+
operatorToken,
|
|
1215
|
+
scope: narrowScope,
|
|
1216
|
+
subject: "parachute-vault-mcp",
|
|
1217
|
+
});
|
|
1218
|
+
if ("kind" in result) {
|
|
1219
|
+
switch (result.kind) {
|
|
1220
|
+
case "network":
|
|
1221
|
+
console.error(
|
|
1222
|
+
`Hub unreachable at ${result.origin} — ${result.cause}.\n` +
|
|
1223
|
+
` Fix: verify the hub is running and PARACHUTE_HUB_ORIGIN is set, ` +
|
|
1224
|
+
`or use --legacy-pat to skip hub-mint.`,
|
|
1225
|
+
);
|
|
1226
|
+
break;
|
|
1227
|
+
case "api-error":
|
|
1228
|
+
console.error(
|
|
1229
|
+
`Hub mint-token rejected (HTTP ${result.status}, ${result.error}): ${result.description}`,
|
|
1230
|
+
);
|
|
1231
|
+
break;
|
|
1232
|
+
}
|
|
1233
|
+
process.exit(1);
|
|
1234
|
+
}
|
|
1235
|
+
bearer = result.token;
|
|
1236
|
+
console.log(`Minted hub JWT (jti=${result.jti}, expires ${result.expires_at}, scope ${result.scope}).`);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
installMcpConfig({
|
|
1240
|
+
targetPath: target.path,
|
|
1241
|
+
entryKey,
|
|
1242
|
+
url,
|
|
1243
|
+
bearer,
|
|
1244
|
+
...(target.localProjectKey ? { localProjectKey: target.localProjectKey } : {}),
|
|
1245
|
+
});
|
|
1246
|
+
console.log(`MCP URL: ${url} (${source})`);
|
|
1247
|
+
if (target.scope === "local") {
|
|
1248
|
+
console.log(`Added MCP server "${entryKey}" to ${target.path} under projects["${target.localProjectKey}"] (local scope).`);
|
|
1249
|
+
console.log(` Installed locally for this directory only — runs only when Claude Code launches from ${target.localProjectKey}.`);
|
|
1250
|
+
console.log(` To install globally instead, re-run with --install-scope user.`);
|
|
1251
|
+
} else {
|
|
1252
|
+
console.log(`Added MCP server "${entryKey}" to ${target.path}`);
|
|
1253
|
+
}
|
|
887
1254
|
}
|
|
888
1255
|
|
|
889
1256
|
function cmdRemove(args: string[]) {
|
|
@@ -1364,6 +1731,16 @@ function printErrLogTail(n: number) {
|
|
|
1364
1731
|
async function cmdUninstall(argsList: string[]) {
|
|
1365
1732
|
const wipe = argsList.includes("--wipe");
|
|
1366
1733
|
const skipPrompts = argsList.includes("--yes") || argsList.includes("-y");
|
|
1734
|
+
// Test-isolation escape hatch (vault#296): skip the launchd / systemd /
|
|
1735
|
+
// backup-agent uninstall calls. Those target hardcoded labels
|
|
1736
|
+
// (`computer.parachute.vault*`) that ignore `PARACHUTE_HOME`, so a test
|
|
1737
|
+
// run on a developer's machine would `launchctl bootout` their real
|
|
1738
|
+
// daemon. With this flag tests can exercise the rest of the uninstall
|
|
1739
|
+
// flow (wrapper removal, MCP cleanup, ordering, exit codes) safely.
|
|
1740
|
+
// Intentionally not documented in `usage()` — humans should never need
|
|
1741
|
+
// it; surfacing it would invite "I'll just skip the daemon step" misuse
|
|
1742
|
+
// that leaves an orphaned daemon firing on a missing wrapper.
|
|
1743
|
+
const skipDaemon = argsList.includes("--skip-daemon");
|
|
1367
1744
|
|
|
1368
1745
|
console.log("Parachute Vault uninstall\n");
|
|
1369
1746
|
console.log("This removes the daemon registration and wrapper script.");
|
|
@@ -1393,7 +1770,9 @@ async function cmdUninstall(argsList: string[]) {
|
|
|
1393
1770
|
}
|
|
1394
1771
|
|
|
1395
1772
|
// 1. Stop and remove the daemon registration.
|
|
1396
|
-
if (
|
|
1773
|
+
if (skipDaemon) {
|
|
1774
|
+
console.log("Skipping daemon removal (--skip-daemon).");
|
|
1775
|
+
} else if (process.platform === "darwin") {
|
|
1397
1776
|
console.log("Removing launchd agent...");
|
|
1398
1777
|
await uninstallAgent();
|
|
1399
1778
|
// Scheduled backup agent lives in a separate plist — uninstall it too,
|
|
@@ -1554,23 +1933,23 @@ async function cmdDoctor() {
|
|
|
1554
1933
|
});
|
|
1555
1934
|
}
|
|
1556
1935
|
|
|
1557
|
-
// MCP entry in ~/.claude.json. Split into three separate
|
|
1558
|
-
// user can see exactly which condition fails: "entry
|
|
1559
|
-
// matches vault", "daemon reachable over MCP URL". A
|
|
1560
|
-
// "entry exists but port is stale" after the user
|
|
1561
|
-
// re-running `mcp-install`.
|
|
1936
|
+
// MCP entry in ~/.claude.json or ./.mcp.json. Split into three separate
|
|
1937
|
+
// checks so the user can see exactly which condition fails: "entry
|
|
1938
|
+
// present", "port matches vault", "daemon reachable over MCP URL". A
|
|
1939
|
+
// common failure is "entry exists but port is stale" after the user
|
|
1940
|
+
// changed PORT without re-running `mcp-install`.
|
|
1562
1941
|
const port = resolveVaultPort();
|
|
1563
1942
|
const mcpEntry = readMcpEntry();
|
|
1564
1943
|
if (!mcpEntry.found) {
|
|
1565
1944
|
checks.push({
|
|
1566
|
-
name: "MCP entry in
|
|
1945
|
+
name: "MCP entry in MCP client config",
|
|
1567
1946
|
status: "warn",
|
|
1568
1947
|
detail: mcpEntry.reason,
|
|
1569
1948
|
fix: "Run `parachute-vault mcp-install` to register the vault with Claude.",
|
|
1570
1949
|
});
|
|
1571
1950
|
} else {
|
|
1572
1951
|
checks.push({
|
|
1573
|
-
name:
|
|
1952
|
+
name: `MCP entry in ${mcpEntry.locationLabel}`,
|
|
1574
1953
|
status: "pass",
|
|
1575
1954
|
detail: mcpEntry.url,
|
|
1576
1955
|
});
|
|
@@ -1734,57 +2113,92 @@ function resolveVaultPort(): number {
|
|
|
1734
2113
|
|
|
1735
2114
|
type McpEntryLookup =
|
|
1736
2115
|
| { found: false; reason: string }
|
|
1737
|
-
| { found: true; url: string; port: number | null };
|
|
2116
|
+
| { found: true; url: string; port: number | null; locationLabel: string };
|
|
1738
2117
|
|
|
1739
2118
|
/**
|
|
1740
|
-
* Read
|
|
1741
|
-
*
|
|
1742
|
-
*
|
|
1743
|
-
* —
|
|
2119
|
+
* Read the parachute vault MCP entry from either `~/.claude.json` (user
|
|
2120
|
+
* scope) or `./.mcp.json` (project scope), preferring user-level. Accepts
|
|
2121
|
+
* the singular `parachute-vault` key or any per-vault `parachute-vault-<name>`
|
|
2122
|
+
* key — multi-vault installs key per-vault. The entry is always an HTTP MCP
|
|
2123
|
+
* pointing at the local daemon, so we parse the URL's port for the
|
|
2124
|
+
* port-match check.
|
|
1744
2125
|
*
|
|
1745
|
-
* Invariant: the check is NON-fatal. A missing
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
2126
|
+
* Invariant: the check is NON-fatal. A missing entry is a warn, not a fail:
|
|
2127
|
+
* plenty of users install the vault first and wire it to Claude later. We
|
|
2128
|
+
* just make the "is it wired up?" state legible.
|
|
1748
2129
|
*/
|
|
1749
2130
|
function readMcpEntry(): McpEntryLookup {
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
2131
|
+
// Bun's process.cwd() already follows symlinks on macOS, so resolve()
|
|
2132
|
+
// matches the key the install writer used; tests assert with
|
|
2133
|
+
// fs.realpathSync to match the same shape.
|
|
2134
|
+
const cwd = resolve(process.cwd());
|
|
2135
|
+
// Two entries point at the same ~/.claude.json file but search different
|
|
2136
|
+
// key namespaces — top-level `mcpServers` (user scope) vs
|
|
2137
|
+
// `projects[<cwd>].mcpServers` (local scope). Intentional duplication: a
|
|
2138
|
+
// single read can't satisfy both without restructuring the search loop,
|
|
2139
|
+
// and the cost of reading the file twice is trivial against the doctor
|
|
2140
|
+
// run's other work.
|
|
2141
|
+
const candidates: Array<{ path: string; label: string; localProjectKey?: string }> = [
|
|
2142
|
+
{ path: resolve(homedir(), ".claude.json"), label: "~/.claude.json" },
|
|
2143
|
+
{ path: resolve(homedir(), ".claude.json"), label: `~/.claude.json (projects["${cwd}"])`, localProjectKey: cwd },
|
|
2144
|
+
{ path: resolve(cwd, ".mcp.json"), label: `${cwd}/.mcp.json` },
|
|
2145
|
+
];
|
|
2146
|
+
const checkedReasons: string[] = [];
|
|
2147
|
+
for (const { path, label, localProjectKey } of candidates) {
|
|
2148
|
+
if (!existsSync(path)) {
|
|
2149
|
+
checkedReasons.push(`${label} does not exist`);
|
|
2150
|
+
continue;
|
|
2151
|
+
}
|
|
2152
|
+
let config: any;
|
|
2153
|
+
try {
|
|
2154
|
+
config = JSON.parse(readFileSync(path, "utf-8"));
|
|
2155
|
+
} catch (err: any) {
|
|
2156
|
+
checkedReasons.push(`${label} is not valid JSON: ${String(err?.message ?? err)}`);
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
const servers = localProjectKey
|
|
2160
|
+
? (config?.projects?.[localProjectKey]?.mcpServers ?? {})
|
|
2161
|
+
: (config?.mcpServers ?? {});
|
|
2162
|
+
// Prefer the singular `parachute-vault` slot (canonical default
|
|
2163
|
+
// install); fall back to the first `parachute-vault-<name>` per-vault
|
|
2164
|
+
// entry. Multi-vault installs may have several; the doctor reports on
|
|
2165
|
+
// the one it finds first so the basic "is something wired up?" check
|
|
2166
|
+
// still works without enumerating every vault.
|
|
2167
|
+
let entry = servers["parachute-vault"];
|
|
2168
|
+
let entryKey = "parachute-vault";
|
|
2169
|
+
if (!entry) {
|
|
2170
|
+
for (const key of Object.keys(servers)) {
|
|
2171
|
+
if (key.startsWith("parachute-vault-")) {
|
|
2172
|
+
entry = servers[key];
|
|
2173
|
+
entryKey = key;
|
|
2174
|
+
break;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
if (!entry) {
|
|
2179
|
+
checkedReasons.push(`no parachute-vault entry in ${label}`);
|
|
2180
|
+
continue;
|
|
2181
|
+
}
|
|
2182
|
+
const url = typeof entry.url === "string" ? entry.url : null;
|
|
2183
|
+
if (!url) {
|
|
2184
|
+
checkedReasons.push(
|
|
2185
|
+
`${label} mcpServers["${entryKey}"] has no \`url\` field (got ${JSON.stringify(entry).slice(0, 80)})`,
|
|
2186
|
+
);
|
|
2187
|
+
continue;
|
|
2188
|
+
}
|
|
2189
|
+
let entryPort: number | null = null;
|
|
2190
|
+
try {
|
|
2191
|
+
const parsed = new URL(url);
|
|
2192
|
+
entryPort = parsed.port ? Number(parsed.port) : null;
|
|
2193
|
+
} catch {
|
|
2194
|
+
entryPort = null;
|
|
2195
|
+
}
|
|
2196
|
+
return { found: true, url, port: entryPort, locationLabel: `${label} (${entryKey})` };
|
|
1786
2197
|
}
|
|
1787
|
-
return {
|
|
2198
|
+
return {
|
|
2199
|
+
found: false,
|
|
2200
|
+
reason: checkedReasons.length > 0 ? checkedReasons.join("; ") : "no MCP config files found",
|
|
2201
|
+
};
|
|
1788
2202
|
}
|
|
1789
2203
|
|
|
1790
2204
|
/**
|
|
@@ -2078,21 +2492,21 @@ function describeNextRun(schedule: BackupSchedule): string {
|
|
|
2078
2492
|
|
|
2079
2493
|
async function cmdImport(args: string[]) {
|
|
2080
2494
|
// Parse flags
|
|
2081
|
-
let format = "obsidian";
|
|
2082
2495
|
let vaultName = "default";
|
|
2083
2496
|
let sourcePath = "";
|
|
2084
2497
|
let dryRun = false;
|
|
2498
|
+
let blowAway = false;
|
|
2499
|
+
let assumeYes = false;
|
|
2500
|
+
// `--format <name>` / `--obsidian` retained as no-op hints — autodetect
|
|
2501
|
+
// now drives the format choice (presence of `.parachute/vault.yaml`).
|
|
2502
|
+
// Surfaced in help text below; ignored at runtime to avoid surprising
|
|
2503
|
+
// operators with stale flags.
|
|
2085
2504
|
|
|
2086
2505
|
const positional: string[] = [];
|
|
2087
2506
|
for (let i = 0; i < args.length; i++) {
|
|
2088
2507
|
const arg = args[i]!;
|
|
2089
2508
|
if (arg === "--format") {
|
|
2090
|
-
|
|
2091
|
-
if (!v) {
|
|
2092
|
-
console.error("--format requires a value.");
|
|
2093
|
-
process.exit(1);
|
|
2094
|
-
}
|
|
2095
|
-
format = v;
|
|
2509
|
+
i++; // consume the value; ignored.
|
|
2096
2510
|
} else if (arg === "--vault") {
|
|
2097
2511
|
const v = args[++i];
|
|
2098
2512
|
if (!v) {
|
|
@@ -2103,7 +2517,11 @@ async function cmdImport(args: string[]) {
|
|
|
2103
2517
|
} else if (arg === "--dry-run") {
|
|
2104
2518
|
dryRun = true;
|
|
2105
2519
|
} else if (arg === "--obsidian") {
|
|
2106
|
-
|
|
2520
|
+
// no-op; autodetect.
|
|
2521
|
+
} else if (arg === "--blow-away") {
|
|
2522
|
+
blowAway = true;
|
|
2523
|
+
} else if (arg === "--yes" || arg === "-y") {
|
|
2524
|
+
assumeYes = true;
|
|
2107
2525
|
} else {
|
|
2108
2526
|
positional.push(arg);
|
|
2109
2527
|
}
|
|
@@ -2111,15 +2529,21 @@ async function cmdImport(args: string[]) {
|
|
|
2111
2529
|
sourcePath = positional[0] ?? "";
|
|
2112
2530
|
|
|
2113
2531
|
if (!sourcePath) {
|
|
2114
|
-
console.error("Usage: parachute-vault import <path> [--vault <name>] [--dry-run]");
|
|
2115
|
-
console.error("\nImports
|
|
2532
|
+
console.error("Usage: parachute-vault import <path> [--vault <name>] [--blow-away] [--yes] [--dry-run]");
|
|
2533
|
+
console.error("\nImports a portable-md export OR a legacy Obsidian vault. Format is");
|
|
2534
|
+
console.error("autodetected by the presence of `.parachute/vault.yaml` in the input dir.");
|
|
2116
2535
|
console.error("\nOptions:");
|
|
2117
2536
|
console.error(" --vault <name> Target vault (default: 'default')");
|
|
2118
|
-
console.error(" --
|
|
2537
|
+
console.error(" --blow-away DESTRUCTIVE: wipe the target vault first, then replay");
|
|
2538
|
+
console.error(" the import. Disaster-recovery path. Confirms unless");
|
|
2539
|
+
console.error(" `--yes` is set.");
|
|
2540
|
+
console.error(" --yes, -y Skip the destructive-action confirm prompt.");
|
|
2541
|
+
console.error(" --dry-run Show what would be imported without writing.");
|
|
2119
2542
|
process.exit(1);
|
|
2120
2543
|
}
|
|
2121
2544
|
|
|
2122
2545
|
const { resolve: resolvePath } = await import("path");
|
|
2546
|
+
const { join } = await import("path");
|
|
2123
2547
|
const fullPath = resolvePath(sourcePath);
|
|
2124
2548
|
|
|
2125
2549
|
if (!existsSync(fullPath)) {
|
|
@@ -2134,6 +2558,73 @@ async function cmdImport(args: string[]) {
|
|
|
2134
2558
|
process.exit(1);
|
|
2135
2559
|
}
|
|
2136
2560
|
|
|
2561
|
+
// Autodetect: portable-md export → `.parachute/vault.yaml` present.
|
|
2562
|
+
const isPortableMd = existsSync(join(fullPath, ".parachute", "vault.yaml"));
|
|
2563
|
+
|
|
2564
|
+
if (isPortableMd) {
|
|
2565
|
+
// New lossless path. Supports --blow-away for disaster recovery.
|
|
2566
|
+
const { importPortableVault } = await import("../core/src/portable-md.ts");
|
|
2567
|
+
const { getVaultStore } = await import("./vault-store.ts");
|
|
2568
|
+
const { assetsDir } = await import("./routes.ts");
|
|
2569
|
+
|
|
2570
|
+
if (blowAway && !assumeYes && !dryRun) {
|
|
2571
|
+
// Confirm-prompt the destructive action. Default NO — every other
|
|
2572
|
+
// destructive confirm in this CLI (uninstall's wipe, 2FA
|
|
2573
|
+
// re-enrollment) defaults NO, so a distracted Enter-press can't
|
|
2574
|
+
// wipe a vault. vault#319 fold F1.
|
|
2575
|
+
const proceed = await confirm(
|
|
2576
|
+
`\nDESTRUCTIVE: --blow-away will DELETE every note in vault "${vaultName}" before replaying from "${fullPath}". Proceed?`,
|
|
2577
|
+
false,
|
|
2578
|
+
);
|
|
2579
|
+
if (!proceed) {
|
|
2580
|
+
console.log("Cancelled.");
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
const store = getVaultStore(vaultName);
|
|
2586
|
+
console.log(
|
|
2587
|
+
`Importing portable-md export from ${fullPath} into vault "${vaultName}"` +
|
|
2588
|
+
`${blowAway ? " (BLOW-AWAY)" : ""}${dryRun ? " (dry-run)" : ""}`,
|
|
2589
|
+
);
|
|
2590
|
+
const stats = await importPortableVault(store, {
|
|
2591
|
+
inDir: fullPath,
|
|
2592
|
+
blowAway,
|
|
2593
|
+
assetsDir: assetsDir(vaultName),
|
|
2594
|
+
dryRun,
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2597
|
+
console.log(
|
|
2598
|
+
`\n${dryRun ? "Would " : ""}created ${stats.notes_created} note(s), ` +
|
|
2599
|
+
`updated ${stats.notes_updated}, ` +
|
|
2600
|
+
`restored ${stats.schemas_restored} schema(s), ` +
|
|
2601
|
+
`${stats.links_restored} link(s), ` +
|
|
2602
|
+
`${stats.attachments_restored} attachment(s).`,
|
|
2603
|
+
);
|
|
2604
|
+
if (stats.notes_wiped > 0) {
|
|
2605
|
+
console.log(`Blow-away: wiped ${stats.notes_wiped} pre-existing note(s).`);
|
|
2606
|
+
}
|
|
2607
|
+
if (stats.skipped_links.length > 0) {
|
|
2608
|
+
console.log(`Skipped ${stats.skipped_links.length} link(s) — target notes not in import set.`);
|
|
2609
|
+
}
|
|
2610
|
+
if (stats.skipped_attachments.length > 0) {
|
|
2611
|
+
console.log(`Skipped ${stats.skipped_attachments.length} attachment(s) — see warnings above.`);
|
|
2612
|
+
}
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
// Legacy obsidian-format path. No-op when --blow-away passed (this
|
|
2617
|
+
// path is for "ingest an external Obsidian vault" not disaster
|
|
2618
|
+
// recovery); explicit error so the operator doesn't get a surprising
|
|
2619
|
+
// partial wipe.
|
|
2620
|
+
if (blowAway) {
|
|
2621
|
+
console.error(
|
|
2622
|
+
"--blow-away requires a portable-md export (`.parachute/vault.yaml` present in the input dir). " +
|
|
2623
|
+
"Legacy Obsidian imports don't support --blow-away — they always upsert by path.",
|
|
2624
|
+
);
|
|
2625
|
+
process.exit(1);
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2137
2628
|
const { parseObsidianVault } = await import("../core/src/obsidian.ts");
|
|
2138
2629
|
const { getVaultStore } = await import("./vault-store.ts");
|
|
2139
2630
|
|
|
@@ -2205,6 +2696,7 @@ async function cmdImport(args: string[]) {
|
|
|
2205
2696
|
async function cmdExport(args: string[]) {
|
|
2206
2697
|
let vaultName = "default";
|
|
2207
2698
|
let outputPath = "";
|
|
2699
|
+
let since: string | undefined;
|
|
2208
2700
|
|
|
2209
2701
|
const positional: string[] = [];
|
|
2210
2702
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -2216,6 +2708,21 @@ async function cmdExport(args: string[]) {
|
|
|
2216
2708
|
process.exit(1);
|
|
2217
2709
|
}
|
|
2218
2710
|
vaultName = v;
|
|
2711
|
+
} else if (arg === "--since") {
|
|
2712
|
+
const v = args[++i];
|
|
2713
|
+
if (!v) {
|
|
2714
|
+
console.error("--since requires an ISO-8601 timestamp value.");
|
|
2715
|
+
process.exit(1);
|
|
2716
|
+
}
|
|
2717
|
+
// Defensive: parse to verify it's a real ISO timestamp before we
|
|
2718
|
+
// hand it to the store. Avoids silent "no matches" when the
|
|
2719
|
+
// operator typo's the date and gets an empty export.
|
|
2720
|
+
const parsed = Date.parse(v);
|
|
2721
|
+
if (Number.isNaN(parsed)) {
|
|
2722
|
+
console.error(`--since: invalid ISO-8601 timestamp '${v}'`);
|
|
2723
|
+
process.exit(1);
|
|
2724
|
+
}
|
|
2725
|
+
since = v;
|
|
2219
2726
|
} else {
|
|
2220
2727
|
positional.push(arg);
|
|
2221
2728
|
}
|
|
@@ -2223,14 +2730,17 @@ async function cmdExport(args: string[]) {
|
|
|
2223
2730
|
outputPath = positional[0] ?? "";
|
|
2224
2731
|
|
|
2225
2732
|
if (!outputPath) {
|
|
2226
|
-
console.error("Usage: parachute-vault export <
|
|
2227
|
-
console.error("\nExports a Parachute Vault as
|
|
2733
|
+
console.error("Usage: parachute-vault export <dir> [--since <iso>] [--vault <name>]");
|
|
2734
|
+
console.error("\nExports a Parachute Vault as portable markdown — round-trippable across");
|
|
2735
|
+
console.error("Obsidian / Logseq / Foam / Quartz / Dendron and most markdown SSGs.");
|
|
2736
|
+
console.error("\nOptions:");
|
|
2737
|
+
console.error(" --vault <name> Source vault (default: 'default')");
|
|
2738
|
+
console.error(" --since <iso> Only export notes whose updated_at >= ISO timestamp");
|
|
2739
|
+
console.error(" (incremental — useful for git-projection cadences)");
|
|
2228
2740
|
process.exit(1);
|
|
2229
2741
|
}
|
|
2230
2742
|
|
|
2231
2743
|
const { resolve: resolvePath } = await import("path");
|
|
2232
|
-
const { mkdirSync: mkdir, writeFileSync: writeFile } = await import("fs");
|
|
2233
|
-
const { join, dirname } = await import("path");
|
|
2234
2744
|
const fullPath = resolvePath(outputPath);
|
|
2235
2745
|
|
|
2236
2746
|
const config = readVaultConfig(vaultName);
|
|
@@ -2239,28 +2749,32 @@ async function cmdExport(args: string[]) {
|
|
|
2239
2749
|
process.exit(1);
|
|
2240
2750
|
}
|
|
2241
2751
|
|
|
2242
|
-
const {
|
|
2752
|
+
const { exportVaultToDir } = await import("../core/src/portable-md.ts");
|
|
2243
2753
|
const { getVaultStore } = await import("./vault-store.ts");
|
|
2754
|
+
const { assetsDir } = await import("./routes.ts");
|
|
2244
2755
|
|
|
2245
2756
|
const store = getVaultStore(vaultName);
|
|
2246
|
-
const
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
const dir = dirname(fullFilePath);
|
|
2256
|
-
mkdir(dir, { recursive: true });
|
|
2757
|
+
const assetsDirPath = assetsDir(vaultName);
|
|
2758
|
+
console.log(`Exporting vault "${vaultName}" to ${fullPath}${since ? ` (since ${since})` : ""}`);
|
|
2759
|
+
const stats = await exportVaultToDir(store, {
|
|
2760
|
+
outDir: fullPath,
|
|
2761
|
+
vaultName,
|
|
2762
|
+
assetsDir: assetsDirPath,
|
|
2763
|
+
...(config.description ? { vaultDescription: config.description } : {}),
|
|
2764
|
+
...(since ? { since } : {}),
|
|
2765
|
+
});
|
|
2257
2766
|
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2767
|
+
console.log(`Exported ${stats.notes} note(s), ${stats.schemas} tag schema(s), ${stats.attachments} attachment(s).`);
|
|
2768
|
+
console.log(`Sidecar: ${fullPath}/.parachute/`);
|
|
2769
|
+
if (stats.filtered_by_since) {
|
|
2770
|
+
console.log(`Incremental: filtered by --since ${since}.`);
|
|
2771
|
+
}
|
|
2772
|
+
if (stats.skipped_traversal > 0) {
|
|
2773
|
+
console.log(`Note: ${stats.skipped_traversal} note(s) skipped (path-traversal). See [export] warnings above.`);
|
|
2774
|
+
}
|
|
2775
|
+
if (stats.skipped_attachments.length > 0) {
|
|
2776
|
+
console.log(`Note: ${stats.skipped_attachments.length} attachment(s) skipped. See [export] warnings above.`);
|
|
2261
2777
|
}
|
|
2262
|
-
|
|
2263
|
-
console.log(`Exported ${exported} notes as markdown files.`);
|
|
2264
2778
|
}
|
|
2265
2779
|
|
|
2266
2780
|
// ---------------------------------------------------------------------------
|
|
@@ -2282,59 +2796,68 @@ function createVault(name: string): string {
|
|
|
2282
2796
|
return fullToken;
|
|
2283
2797
|
}
|
|
2284
2798
|
|
|
2285
|
-
|
|
2286
|
-
|
|
2799
|
+
interface InstallMcpConfigOpts {
|
|
2800
|
+
/** Absolute path to the MCP client config file. Computed by the caller via `resolveInstallTarget`. */
|
|
2801
|
+
targetPath: string;
|
|
2802
|
+
/** `mcpServers.<entryKey>` slot the entry lands at. Default is `parachute-vault`; multi-vault installs key as `parachute-vault-<name>`. */
|
|
2803
|
+
entryKey: string;
|
|
2804
|
+
/**
|
|
2805
|
+
* URL embedded in the entry's `url` field. The caller is the sole source of
|
|
2806
|
+
* truth — `buildMcpEntryPlan` produces both `entryKey` and `url` together,
|
|
2807
|
+
* and passing them through closes the preview ⇄ writer invariant (the
|
|
2808
|
+
* shape the preview promised is the shape that lands on disk). See
|
|
2809
|
+
* vault#302; #301 introduced the seam for `entryKey`, this closes it for
|
|
2810
|
+
* `url` too.
|
|
2811
|
+
*/
|
|
2812
|
+
url: string;
|
|
2813
|
+
/** Bearer token to embed in `Authorization: Bearer …`. Omitted means the entry is unauthenticated — only useful for OAuth-capable clients (Claude Code does discovery). */
|
|
2814
|
+
bearer?: string;
|
|
2815
|
+
/**
|
|
2816
|
+
* When set, the entry is keyed under `projects[<localProjectKey>].mcpServers`
|
|
2817
|
+
* inside `~/.claude.json` (Claude Code's `local` scope: private to this
|
|
2818
|
+
* machine, scoped to this directory). When unset, keyed at top-level
|
|
2819
|
+
* `mcpServers` (user scope) or written directly to `./.mcp.json`
|
|
2820
|
+
* (project scope — caller passes the project file path).
|
|
2821
|
+
*/
|
|
2822
|
+
localProjectKey?: string;
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
/**
|
|
2826
|
+
* Write the MCP entry into the target config file. Pure file-writer — the
|
|
2827
|
+
* caller has already decided `entryKey` and `url` (via `buildMcpEntryPlan`).
|
|
2828
|
+
* Returns `void`; the caller already has `url` and `source` for logging.
|
|
2829
|
+
*/
|
|
2830
|
+
function installMcpConfig(opts: InstallMcpConfigOpts): void {
|
|
2831
|
+
const { targetPath, entryKey, url, bearer, localProjectKey } = opts;
|
|
2832
|
+
|
|
2287
2833
|
let config: any = {};
|
|
2288
|
-
if (existsSync(
|
|
2834
|
+
if (existsSync(targetPath)) {
|
|
2289
2835
|
try {
|
|
2290
|
-
config = JSON.parse(readFileSync(
|
|
2836
|
+
config = JSON.parse(readFileSync(targetPath, "utf-8"));
|
|
2291
2837
|
} catch {}
|
|
2292
2838
|
}
|
|
2293
2839
|
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
const port = globalConfig.port || DEFAULT_PORT;
|
|
2298
|
-
|
|
2299
|
-
// Clean up old per-vault stdio entries
|
|
2300
|
-
for (const key of Object.keys(config.mcpServers)) {
|
|
2301
|
-
if (key.startsWith("parachute-vault/")) {
|
|
2302
|
-
delete config.mcpServers[key];
|
|
2303
|
-
}
|
|
2840
|
+
const mcpEntry: Record<string, unknown> = { type: "http", url };
|
|
2841
|
+
if (bearer) {
|
|
2842
|
+
mcpEntry.headers = { Authorization: `Bearer ${bearer}` };
|
|
2304
2843
|
}
|
|
2305
2844
|
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2845
|
+
if (localProjectKey) {
|
|
2846
|
+
if (!config.projects || typeof config.projects !== "object") config.projects = {};
|
|
2847
|
+
if (!config.projects[localProjectKey] || typeof config.projects[localProjectKey] !== "object") {
|
|
2848
|
+
config.projects[localProjectKey] = {};
|
|
2849
|
+
}
|
|
2850
|
+
const projectEntry = config.projects[localProjectKey];
|
|
2851
|
+
if (!projectEntry.mcpServers || typeof projectEntry.mcpServers !== "object") {
|
|
2852
|
+
projectEntry.mcpServers = {};
|
|
2853
|
+
}
|
|
2854
|
+
projectEntry.mcpServers[entryKey] = mcpEntry;
|
|
2855
|
+
} else {
|
|
2856
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
2857
|
+
config.mcpServers[entryKey] = mcpEntry;
|
|
2318
2858
|
}
|
|
2319
|
-
config.mcpServers["parachute-vault"] = mcpEntry;
|
|
2320
2859
|
|
|
2321
|
-
writeFileSync(
|
|
2322
|
-
}
|
|
2323
|
-
|
|
2324
|
-
function removeMcpConfig() {
|
|
2325
|
-
const claudeJsonPath = resolve(homedir(), ".claude.json");
|
|
2326
|
-
if (!existsSync(claudeJsonPath)) return;
|
|
2327
|
-
try {
|
|
2328
|
-
const config = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
|
|
2329
|
-
delete config.mcpServers?.["parachute-vault"];
|
|
2330
|
-
// Also clean up any old per-vault entries
|
|
2331
|
-
for (const key of Object.keys(config.mcpServers ?? {})) {
|
|
2332
|
-
if (key.startsWith("parachute-vault/")) {
|
|
2333
|
-
delete config.mcpServers[key];
|
|
2334
|
-
}
|
|
2335
|
-
}
|
|
2336
|
-
writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2) + "\n");
|
|
2337
|
-
} catch {}
|
|
2860
|
+
writeFileSync(targetPath, JSON.stringify(config, null, 2) + "\n");
|
|
2338
2861
|
}
|
|
2339
2862
|
|
|
2340
2863
|
function usage() {
|
|
@@ -2377,7 +2900,36 @@ Vaults:
|
|
|
2377
2900
|
parachute-vault create <name> [--json] Create a new vault (--json: emit { name, token, paths, set_as_default })
|
|
2378
2901
|
parachute-vault list List all vaults
|
|
2379
2902
|
parachute-vault remove <name> [--yes] Remove a vault
|
|
2380
|
-
parachute-vault mcp-install
|
|
2903
|
+
parachute-vault mcp-install [--mint|--token <t>|--legacy-pat]
|
|
2904
|
+
[--scope vault:read|vault:write|vault:admin]
|
|
2905
|
+
[--install-scope local|user|project]
|
|
2906
|
+
[--vault <name>] [--client claude-code]
|
|
2907
|
+
Install vault MCP into a client config.
|
|
2908
|
+
From a terminal with no flags: walks you
|
|
2909
|
+
through a contextual conversation (vault,
|
|
2910
|
+
location, auth) with smart defaults +
|
|
2911
|
+
preview before write. Pass any flag and
|
|
2912
|
+
the command runs non-interactively.
|
|
2913
|
+
Default (non-interactive): --mint
|
|
2914
|
+
(hub-issued JWT via ~/.parachute/operator.token)
|
|
2915
|
+
into ~/.claude.json under
|
|
2916
|
+
projects[<cwd>].mcpServers (local scope,
|
|
2917
|
+
matches Claude Code's claude-mcp-add
|
|
2918
|
+
default) with vault:read scope.
|
|
2919
|
+
--token <t>: paste an existing bearer
|
|
2920
|
+
(any shape) instead of minting.
|
|
2921
|
+
--legacy-pat: mint a vault-DB pvt_*
|
|
2922
|
+
token (deprecated; for self-hosted-
|
|
2923
|
+
without-hub setups).
|
|
2924
|
+
--install-scope local (default) writes
|
|
2925
|
+
~/.claude.json under
|
|
2926
|
+
projects[<cwd>].mcpServers (this
|
|
2927
|
+
directory only). user writes top-level
|
|
2928
|
+
mcpServers (every project). project
|
|
2929
|
+
writes ./.mcp.json (check into the repo).
|
|
2930
|
+
--vault <name> targets a specific
|
|
2931
|
+
vault and keys the entry as
|
|
2932
|
+
parachute-vault-<name>.
|
|
2381
2933
|
|
|
2382
2934
|
Tokens:
|
|
2383
2935
|
parachute-vault tokens List tokens (every vault)
|
|
@@ -2415,9 +2967,15 @@ Backup:
|
|
|
2415
2967
|
parachute-vault backup status Show schedule, last run, destinations, next run
|
|
2416
2968
|
|
|
2417
2969
|
Import/Export:
|
|
2418
|
-
parachute-vault import <path>
|
|
2419
|
-
|
|
2420
|
-
|
|
2970
|
+
parachute-vault import <path> Import portable-md export OR legacy
|
|
2971
|
+
Obsidian vault (autodetected by
|
|
2972
|
+
presence of .parachute/vault.yaml)
|
|
2973
|
+
parachute-vault import <path> --blow-away DESTRUCTIVE: wipe target vault, replay
|
|
2974
|
+
(disaster recovery; confirms)
|
|
2975
|
+
parachute-vault import <path> --dry-run Preview import without writing
|
|
2976
|
+
parachute-vault export <dir> Export vault as portable markdown
|
|
2977
|
+
(Obsidian/Logseq/Foam/Quartz-compatible)
|
|
2978
|
+
parachute-vault export <dir> --since <iso> Incremental: only notes updated_at >= ISO
|
|
2421
2979
|
|
|
2422
2980
|
── Advanced / standalone ──────────────────────────────────────────────
|
|
2423
2981
|
|