@openparachute/vault 0.4.6-rc.3 → 0.4.6
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 +41 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +161 -1
- package/src/auth.ts +57 -3
- package/src/cli.ts +420 -22
- package/src/export-watch.test.ts +811 -0
- package/src/export-watch.ts +255 -0
- package/src/mcp-config.test.ts +260 -0
- package/src/mcp-install.test.ts +60 -0
- package/src/mcp-install.ts +61 -0
package/src/cli.ts
CHANGED
|
@@ -53,6 +53,7 @@ import type { VaultConfig } from "./config.ts";
|
|
|
53
53
|
import { DATA_DIR } from "./config.ts";
|
|
54
54
|
import { installAgent, uninstallAgent, isAgentLoaded, restartAgent } from "./launchd.ts";
|
|
55
55
|
import {
|
|
56
|
+
buildMcpConfigJson,
|
|
56
57
|
buildMcpEntryPlan,
|
|
57
58
|
chooseHubOrigin,
|
|
58
59
|
detectInstallContext,
|
|
@@ -169,6 +170,9 @@ switch (command) {
|
|
|
169
170
|
case "mcp-install":
|
|
170
171
|
await cmdMcpInstall(cmdArgs);
|
|
171
172
|
break;
|
|
173
|
+
case "mcp-config":
|
|
174
|
+
await cmdMcpConfig(cmdArgs);
|
|
175
|
+
break;
|
|
172
176
|
case "remove":
|
|
173
177
|
case "rm":
|
|
174
178
|
cmdRemove(cmdArgs);
|
|
@@ -965,6 +969,7 @@ async function cmdMcpInstall(args: string[]): Promise<void> {
|
|
|
965
969
|
"--install-scope",
|
|
966
970
|
"--vault",
|
|
967
971
|
"--client",
|
|
972
|
+
"--dry-run",
|
|
968
973
|
];
|
|
969
974
|
|
|
970
975
|
const hasFlag = args.some((a) => MCP_INSTALL_FLAG_NAMES.includes(a));
|
|
@@ -1053,6 +1058,12 @@ async function cmdMcpInstall(args: string[]): Promise<void> {
|
|
|
1053
1058
|
}
|
|
1054
1059
|
|
|
1055
1060
|
// --- Vault target. Default = default_vault; validate existence. ---
|
|
1061
|
+
// Read --dry-run *before* the vault-existence guard so a probe like
|
|
1062
|
+
// `mcp-install --vault future-vault --dry-run` can describe the
|
|
1063
|
+
// intended write even when the vault hasn't been created yet. The
|
|
1064
|
+
// dry-run contract ("no side effects, including failures on state
|
|
1065
|
+
// the caller is asking us about") is broken otherwise.
|
|
1066
|
+
const dryRun = args.includes("--dry-run");
|
|
1056
1067
|
const vaultArg = takeArgValue(args, "--vault");
|
|
1057
1068
|
if (vaultArg.missingValue) {
|
|
1058
1069
|
console.error("--vault requires a value (the vault name).");
|
|
@@ -1062,7 +1073,7 @@ async function cmdMcpInstall(args: string[]): Promise<void> {
|
|
|
1062
1073
|
const defaultVault = globalConfig.default_vault || "default";
|
|
1063
1074
|
const vaultName = vaultArg.value ?? defaultVault;
|
|
1064
1075
|
const vaultExplicit = vaultArg.value !== undefined;
|
|
1065
|
-
if (!readVaultConfig(vaultName)) {
|
|
1076
|
+
if (!dryRun && !readVaultConfig(vaultName)) {
|
|
1066
1077
|
console.error(`Vault "${vaultName}" not found. Available: ${listVaults().join(", ") || "(none)"}.`);
|
|
1067
1078
|
process.exit(1);
|
|
1068
1079
|
}
|
|
@@ -1075,6 +1086,7 @@ async function cmdMcpInstall(args: string[]): Promise<void> {
|
|
|
1075
1086
|
vaultExplicit,
|
|
1076
1087
|
pastedToken: mode === "token" ? tokenArg.value : undefined,
|
|
1077
1088
|
globalConfig,
|
|
1089
|
+
dryRun,
|
|
1078
1090
|
});
|
|
1079
1091
|
}
|
|
1080
1092
|
|
|
@@ -1110,6 +1122,107 @@ async function cmdMcpInstallInteractive(): Promise<void> {
|
|
|
1110
1122
|
});
|
|
1111
1123
|
}
|
|
1112
1124
|
|
|
1125
|
+
/**
|
|
1126
|
+
* `parachute-vault mcp-config <vault-name>` — emit the JSON shape
|
|
1127
|
+
* `claude -p --mcp-config '<json>'` consumes, scoped to a named vault.
|
|
1128
|
+
* Pattern from Aaron's Gitcoin Brain runner:
|
|
1129
|
+
*
|
|
1130
|
+
* claude -p --mcp-config "$(parachute-vault mcp-config gitcoin)" \
|
|
1131
|
+
* --strict-mcp-config ...
|
|
1132
|
+
*
|
|
1133
|
+
* Synthesizing this JSON by hand was per-script boilerplate every runner
|
|
1134
|
+
* that spawned `claude -p` against a vault had to write. This command is
|
|
1135
|
+
* the canonical synthesizer — the JSON shape is owned here once.
|
|
1136
|
+
*
|
|
1137
|
+
* No state is mutated; this only emits to stdout. The bearer is read from
|
|
1138
|
+
* `--token <pvt_...>` or `PARACHUTE_VAULT_TOKEN` env (deliberate — we don't
|
|
1139
|
+
* mint here, since this is a stdout-piped subprocess where prompting would
|
|
1140
|
+
* deadlock the parent script). If neither is present, we exit 1 with a
|
|
1141
|
+
* clear stderr message; runners get a fail-fast.
|
|
1142
|
+
*
|
|
1143
|
+
* Flags:
|
|
1144
|
+
* --token <bearer> Bearer to embed verbatim (alternative: PARACHUTE_VAULT_TOKEN).
|
|
1145
|
+
* --base-url <url> Override the auto-detected hub origin (default: chooseHubOrigin).
|
|
1146
|
+
* Useful for tailnet-exposed hubs (`--base-url https://hub.tail.ts.net`).
|
|
1147
|
+
* --env-vars Emit the template form with `${PARACHUTE_HUB_URL}` and
|
|
1148
|
+
* `${PARACHUTE_VAULT_TOKEN}` placeholders rather than
|
|
1149
|
+
* inlined values. Safe to commit; the shell expands at
|
|
1150
|
+
* runtime.
|
|
1151
|
+
*/
|
|
1152
|
+
async function cmdMcpConfig(args: string[]): Promise<void> {
|
|
1153
|
+
const vaultName = args[0];
|
|
1154
|
+
if (!vaultName || vaultName.startsWith("--")) {
|
|
1155
|
+
console.error("Usage: parachute-vault mcp-config <vault-name> [--token <pvt_...>] [--base-url <url>] [--env-vars]");
|
|
1156
|
+
console.error("");
|
|
1157
|
+
console.error("Emits the JSON config consumed by `claude -p --mcp-config '<json>'`.");
|
|
1158
|
+
console.error("Pattern: claude -p --mcp-config \"$(parachute-vault mcp-config <name>)\" --strict-mcp-config ...");
|
|
1159
|
+
process.exit(1);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const tokenArg = takeArgValue(args, "--token");
|
|
1163
|
+
if (tokenArg.missingValue) {
|
|
1164
|
+
console.error("--token requires a value (the bearer to embed).");
|
|
1165
|
+
process.exit(1);
|
|
1166
|
+
}
|
|
1167
|
+
const baseUrlArg = takeArgValue(args, "--base-url");
|
|
1168
|
+
if (baseUrlArg.missingValue) {
|
|
1169
|
+
console.error("--base-url requires a value (e.g. https://hub.example.ts.net).");
|
|
1170
|
+
process.exit(1);
|
|
1171
|
+
}
|
|
1172
|
+
const useEnvVars = args.includes("--env-vars");
|
|
1173
|
+
|
|
1174
|
+
// --env-vars mode is shape-only — no need to resolve the vault, mint a
|
|
1175
|
+
// token, or even verify the vault exists. Operators committing the
|
|
1176
|
+
// template don't necessarily have the target vault on the machine
|
|
1177
|
+
// generating the config.
|
|
1178
|
+
if (useEnvVars) {
|
|
1179
|
+
const json = buildMcpConfigJson({
|
|
1180
|
+
vaultName,
|
|
1181
|
+
baseUrl: "",
|
|
1182
|
+
bearer: "",
|
|
1183
|
+
useEnvVars: true,
|
|
1184
|
+
});
|
|
1185
|
+
process.stdout.write(json + "\n");
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// Literal mode: verify the vault exists locally, resolve the URL, and
|
|
1190
|
+
// require a bearer (either --token or PARACHUTE_VAULT_TOKEN env).
|
|
1191
|
+
if (!readVaultConfig(vaultName)) {
|
|
1192
|
+
const available = listVaults();
|
|
1193
|
+
console.error(`Vault "${vaultName}" not found. Available: ${available.join(", ") || "(none — run `parachute-vault create <name>`)"}.`);
|
|
1194
|
+
process.exit(1);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const bearer = tokenArg.value ?? process.env.PARACHUTE_VAULT_TOKEN;
|
|
1198
|
+
if (!bearer) {
|
|
1199
|
+
console.error("No bearer token provided. Pass --token <bearer> or set PARACHUTE_VAULT_TOKEN.");
|
|
1200
|
+
console.error(" Mint a token with: parachute-vault tokens create --vault " + vaultName);
|
|
1201
|
+
console.error(" Or use --env-vars to emit the template form (safe to commit; expands at runtime).");
|
|
1202
|
+
process.exit(1);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const globalConfig = readGlobalConfig();
|
|
1206
|
+
const port = globalConfig.port || DEFAULT_PORT;
|
|
1207
|
+
let baseUrl: string;
|
|
1208
|
+
if (baseUrlArg.value) {
|
|
1209
|
+
baseUrl = baseUrlArg.value;
|
|
1210
|
+
} else {
|
|
1211
|
+
// chooseHubOrigin walks PARACHUTE_HUB_ORIGIN → expose-state → loopback;
|
|
1212
|
+
// for a script invoking `claude -p` against a local vault, loopback is
|
|
1213
|
+
// the right default.
|
|
1214
|
+
baseUrl = chooseHubOrigin(port).url;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const json = buildMcpConfigJson({
|
|
1218
|
+
vaultName,
|
|
1219
|
+
baseUrl,
|
|
1220
|
+
bearer,
|
|
1221
|
+
useEnvVars: false,
|
|
1222
|
+
});
|
|
1223
|
+
process.stdout.write(json + "\n");
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1113
1226
|
interface ExecuteMcpInstallOpts {
|
|
1114
1227
|
mode: "mint" | "token" | "legacy-pat";
|
|
1115
1228
|
/** Full scope string (e.g. "vault:read"). The verb segment narrows downstream. */
|
|
@@ -1131,6 +1244,16 @@ interface ExecuteMcpInstallOpts {
|
|
|
1131
1244
|
existingEntryKey?: string;
|
|
1132
1245
|
/** Reused across the call chain to avoid re-parsing config.yaml. */
|
|
1133
1246
|
globalConfig: ReturnType<typeof readGlobalConfig>;
|
|
1247
|
+
/**
|
|
1248
|
+
* When true, describe the write that *would* happen (target path,
|
|
1249
|
+
* entry key, URL, install scope) and exit 0 without touching the
|
|
1250
|
+
* filesystem or hitting the network for a hub-mint. Useful for
|
|
1251
|
+
* probing the command from a script that's setting up a runner.
|
|
1252
|
+
* Aaron hit this when accidentally creating an empty
|
|
1253
|
+
* `projects[<cwd>]` entry while just running `--help` — see the
|
|
1254
|
+
* `mcp-config` PR notes.
|
|
1255
|
+
*/
|
|
1256
|
+
dryRun?: boolean;
|
|
1134
1257
|
}
|
|
1135
1258
|
|
|
1136
1259
|
/**
|
|
@@ -1140,7 +1263,7 @@ interface ExecuteMcpInstallOpts {
|
|
|
1140
1263
|
* preview-and-confirm step so a cancel skips the network mint entirely.
|
|
1141
1264
|
*/
|
|
1142
1265
|
async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
|
|
1143
|
-
const { mode, rawScope, installScope, vaultName, vaultExplicit, pastedToken, globalConfig, existingEntryKey } = opts;
|
|
1266
|
+
const { mode, rawScope, installScope, vaultName, vaultExplicit, pastedToken, globalConfig, existingEntryKey, dryRun } = opts;
|
|
1144
1267
|
const verb = rawScope.split(":")[1]!;
|
|
1145
1268
|
const target = resolveInstallTarget(installScope);
|
|
1146
1269
|
// Single source of truth shared with the interactive walkthrough's
|
|
@@ -1154,6 +1277,29 @@ async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
|
|
|
1154
1277
|
...(existingEntryKey ? { existingEntryKey } : {}),
|
|
1155
1278
|
});
|
|
1156
1279
|
|
|
1280
|
+
// --dry-run: describe the write that *would* happen without touching
|
|
1281
|
+
// the filesystem or minting any token. Print to stdout (scripts may
|
|
1282
|
+
// parse this); exit 0. Skip the auth-acquisition entirely — the point
|
|
1283
|
+
// of --dry-run is to inspect without side effects, including the
|
|
1284
|
+
// network round-trip for hub-mint *and* the vault-existence guard
|
|
1285
|
+
// (probing the install for a not-yet-created vault is a legitimate
|
|
1286
|
+
// pre-create check, not an error).
|
|
1287
|
+
if (dryRun) {
|
|
1288
|
+
const vaultExists = readVaultConfig(vaultName) !== null;
|
|
1289
|
+
console.log(`[dry-run] No changes written.`);
|
|
1290
|
+
console.log(` Target file: ${target.path}`);
|
|
1291
|
+
console.log(` Install scope: ${target.scope}`);
|
|
1292
|
+
if (target.localProjectKey) {
|
|
1293
|
+
console.log(` Project key: ${target.localProjectKey}`);
|
|
1294
|
+
}
|
|
1295
|
+
console.log(` Entry key: ${entryKey}`);
|
|
1296
|
+
console.log(` MCP URL: ${url} (${source})`);
|
|
1297
|
+
console.log(` Auth mode: ${mode}${mode === "mint" ? ` (scope vault:${vaultName}:${verb})` : ""}`);
|
|
1298
|
+
console.log(` Vault: ${vaultName}${vaultExists ? "" : " (does not exist yet — create with `parachute-vault create " + vaultName + "`)"}`);
|
|
1299
|
+
console.log(` Re-run without --dry-run to apply.`);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1157
1303
|
let bearer: string;
|
|
1158
1304
|
if (mode === "token") {
|
|
1159
1305
|
if (!pastedToken) {
|
|
@@ -1248,6 +1394,12 @@ async function executeMcpInstall(opts: ExecuteMcpInstallOpts): Promise<void> {
|
|
|
1248
1394
|
console.log(`Added MCP server "${entryKey}" to ${target.path} under projects["${target.localProjectKey}"] (local scope).`);
|
|
1249
1395
|
console.log(` Installed locally for this directory only — runs only when Claude Code launches from ${target.localProjectKey}.`);
|
|
1250
1396
|
console.log(` To install globally instead, re-run with --install-scope user.`);
|
|
1397
|
+
// Headless-flow heads-up: local-scope MCP entries do NOT propagate
|
|
1398
|
+
// to subprocesses spawned by `claude -p` (script / cron / runner
|
|
1399
|
+
// shapes), even with --setting-sources covering local. Operators
|
|
1400
|
+
// wiring up a runner usually want `--install-scope user` so the
|
|
1401
|
+
// entry reaches every `claude` invocation on this machine.
|
|
1402
|
+
console.log(` Headless flows (claude -p in scripts, cron, runners): prefer --install-scope user — local-scope entries don't propagate to claude -p subprocesses.`);
|
|
1251
1403
|
} else {
|
|
1252
1404
|
console.log(`Added MCP server "${entryKey}" to ${target.path}`);
|
|
1253
1405
|
}
|
|
@@ -2714,6 +2866,12 @@ async function cmdExport(args: string[]) {
|
|
|
2714
2866
|
let vaultName = "default";
|
|
2715
2867
|
let outputPath = "";
|
|
2716
2868
|
let since: string | undefined;
|
|
2869
|
+
let watch = false;
|
|
2870
|
+
let intervalSeconds = 5;
|
|
2871
|
+
let gitCommit = false;
|
|
2872
|
+
const { DEFAULT_COMMIT_TEMPLATE } = await import("./export-watch.ts");
|
|
2873
|
+
let gitMessageTemplate = DEFAULT_COMMIT_TEMPLATE;
|
|
2874
|
+
let gitPush = false;
|
|
2717
2875
|
|
|
2718
2876
|
const positional: string[] = [];
|
|
2719
2877
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -2740,6 +2898,31 @@ async function cmdExport(args: string[]) {
|
|
|
2740
2898
|
process.exit(1);
|
|
2741
2899
|
}
|
|
2742
2900
|
since = v;
|
|
2901
|
+
} else if (arg === "--watch") {
|
|
2902
|
+
watch = true;
|
|
2903
|
+
} else if (arg === "--interval") {
|
|
2904
|
+
const v = args[++i];
|
|
2905
|
+
if (!v) {
|
|
2906
|
+
console.error("--interval requires a number of seconds.");
|
|
2907
|
+
process.exit(1);
|
|
2908
|
+
}
|
|
2909
|
+
const n = Number(v);
|
|
2910
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
2911
|
+
console.error(`--interval: must be a positive number of seconds (got '${v}')`);
|
|
2912
|
+
process.exit(1);
|
|
2913
|
+
}
|
|
2914
|
+
intervalSeconds = n;
|
|
2915
|
+
} else if (arg === "--git-commit") {
|
|
2916
|
+
gitCommit = true;
|
|
2917
|
+
} else if (arg === "--git-message-template") {
|
|
2918
|
+
const v = args[++i];
|
|
2919
|
+
if (!v) {
|
|
2920
|
+
console.error("--git-message-template requires a value.");
|
|
2921
|
+
process.exit(1);
|
|
2922
|
+
}
|
|
2923
|
+
gitMessageTemplate = v;
|
|
2924
|
+
} else if (arg === "--git-push") {
|
|
2925
|
+
gitPush = true;
|
|
2743
2926
|
} else {
|
|
2744
2927
|
positional.push(arg);
|
|
2745
2928
|
}
|
|
@@ -2747,13 +2930,37 @@ async function cmdExport(args: string[]) {
|
|
|
2747
2930
|
outputPath = positional[0] ?? "";
|
|
2748
2931
|
|
|
2749
2932
|
if (!outputPath) {
|
|
2750
|
-
console.error(
|
|
2933
|
+
console.error(
|
|
2934
|
+
"Usage: parachute-vault export <dir> [--since <iso>] [--vault <name>]\n" +
|
|
2935
|
+
" [--watch [--interval <seconds>]]\n" +
|
|
2936
|
+
" [--git-commit [--git-message-template <tpl>] [--git-push]]",
|
|
2937
|
+
);
|
|
2751
2938
|
console.error("\nExports a Parachute Vault as portable markdown — round-trippable across");
|
|
2752
2939
|
console.error("Obsidian / Logseq / Foam / Quartz / Dendron and most markdown SSGs.");
|
|
2753
2940
|
console.error("\nOptions:");
|
|
2754
|
-
console.error(" --vault <name>
|
|
2755
|
-
console.error(" --since <iso>
|
|
2756
|
-
console.error("
|
|
2941
|
+
console.error(" --vault <name> Source vault (default: 'default')");
|
|
2942
|
+
console.error(" --since <iso> Only export notes whose updated_at >= ISO timestamp");
|
|
2943
|
+
console.error(" (incremental — useful for git-projection cadences)");
|
|
2944
|
+
console.error(" --watch Stay alive; re-export incrementally on every");
|
|
2945
|
+
console.error(" vault write. Polls every --interval seconds.");
|
|
2946
|
+
console.error(" --interval <seconds> Watch poll interval (default: 5)");
|
|
2947
|
+
console.error(" --git-commit After each export, `git add -A` + commit in <dir>.");
|
|
2948
|
+
console.error(" Requires <dir> to be an initialized git repo.");
|
|
2949
|
+
console.error(" Combine with --watch for auto-history.");
|
|
2950
|
+
console.error(" --git-message-template <t> Commit message template. Variables:");
|
|
2951
|
+
console.error(" {{date}}, {{notes_changed}}, {{plural}},");
|
|
2952
|
+
console.error(" {{first_note_title}}, {{vault_name}}");
|
|
2953
|
+
console.error(" (default: \"export: {{date}} ({{notes_changed}} note{{plural}})\")");
|
|
2954
|
+
console.error(" --git-push After commit, run `git push` (non-fatal on failure).");
|
|
2955
|
+
process.exit(1);
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
if (intervalSeconds !== 5 && !watch) {
|
|
2959
|
+
console.error("--interval only applies with --watch.");
|
|
2960
|
+
process.exit(1);
|
|
2961
|
+
}
|
|
2962
|
+
if ((gitPush || gitMessageTemplate !== DEFAULT_COMMIT_TEMPLATE) && !gitCommit) {
|
|
2963
|
+
console.error("--git-push / --git-message-template only apply with --git-commit.");
|
|
2757
2964
|
process.exit(1);
|
|
2758
2965
|
}
|
|
2759
2966
|
|
|
@@ -2772,25 +2979,180 @@ async function cmdExport(args: string[]) {
|
|
|
2772
2979
|
|
|
2773
2980
|
const store = getVaultStore(vaultName);
|
|
2774
2981
|
const assetsDirPath = assetsDir(vaultName);
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2982
|
+
const vaultDescription = config.description;
|
|
2983
|
+
|
|
2984
|
+
// Git-repo precheck: fail fast and loud before we run a long-lived loop.
|
|
2985
|
+
if (gitCommit) {
|
|
2986
|
+
await ensureGitRepo(fullPath);
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
/**
|
|
2990
|
+
* Run a single export cycle: export → optionally commit/push. Returns the
|
|
2991
|
+
* stats + a freshly-captured cursor for the next incremental run. We
|
|
2992
|
+
* capture the cursor *before* the export starts, so any write that lands
|
|
2993
|
+
* during the export is picked up next cycle (cost: at most one note
|
|
2994
|
+
* re-exported next round when its `updated_at` equals our cursor).
|
|
2995
|
+
*/
|
|
2996
|
+
async function runCycle(opts: { sinceCursor?: string; isInitial: boolean }): Promise<{
|
|
2997
|
+
stats: Awaited<ReturnType<typeof exportVaultToDir>>;
|
|
2998
|
+
nextCursor: string;
|
|
2999
|
+
committed: boolean;
|
|
3000
|
+
}> {
|
|
3001
|
+
const nextCursor = new Date().toISOString();
|
|
3002
|
+
if (opts.isInitial) {
|
|
3003
|
+
console.log(
|
|
3004
|
+
`Exporting vault "${vaultName}" to ${fullPath}${opts.sinceCursor ? ` (since ${opts.sinceCursor})` : ""}`,
|
|
3005
|
+
);
|
|
3006
|
+
}
|
|
3007
|
+
const stats = await exportVaultToDir(store, {
|
|
3008
|
+
outDir: fullPath,
|
|
3009
|
+
vaultName,
|
|
3010
|
+
assetsDir: assetsDirPath,
|
|
3011
|
+
...(vaultDescription ? { vaultDescription } : {}),
|
|
3012
|
+
...(opts.sinceCursor ? { since: opts.sinceCursor } : {}),
|
|
3013
|
+
});
|
|
3014
|
+
|
|
3015
|
+
if (opts.isInitial) {
|
|
3016
|
+
console.log(
|
|
3017
|
+
`Exported ${stats.notes} note(s), ${stats.schemas} tag schema(s), ${stats.attachments} attachment(s).`,
|
|
3018
|
+
);
|
|
3019
|
+
console.log(`Sidecar: ${fullPath}/.parachute/`);
|
|
3020
|
+
if (stats.filtered_by_since) {
|
|
3021
|
+
console.log(`Incremental: filtered by --since ${opts.sinceCursor}.`);
|
|
3022
|
+
}
|
|
3023
|
+
if (stats.skipped_traversal > 0) {
|
|
3024
|
+
console.log(
|
|
3025
|
+
`Note: ${stats.skipped_traversal} note(s) skipped (path-traversal). See [export] warnings above.`,
|
|
3026
|
+
);
|
|
3027
|
+
}
|
|
3028
|
+
if (stats.skipped_attachments.length > 0) {
|
|
3029
|
+
console.log(
|
|
3030
|
+
`Note: ${stats.skipped_attachments.length} attachment(s) skipped. See [export] warnings above.`,
|
|
3031
|
+
);
|
|
3032
|
+
}
|
|
3033
|
+
} else {
|
|
3034
|
+
// Watch-mode status line: keep tight; the loop logs every interval.
|
|
3035
|
+
if (stats.notes > 0) {
|
|
3036
|
+
console.log(
|
|
3037
|
+
`[watch] exported ${stats.notes} note${stats.notes === 1 ? "" : "s"} (cursor: ${nextCursor})`,
|
|
3038
|
+
);
|
|
3039
|
+
} else {
|
|
3040
|
+
console.log(`[watch] no changes`);
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
2783
3043
|
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
3044
|
+
let committed = false;
|
|
3045
|
+
if (gitCommit) {
|
|
3046
|
+
const { runGitCommitCycle } = await import("./export-watch.ts");
|
|
3047
|
+
const commitResult = await runGitCommitCycle({
|
|
3048
|
+
repoDir: fullPath,
|
|
3049
|
+
template: gitMessageTemplate,
|
|
3050
|
+
notesChanged: stats.notes,
|
|
3051
|
+
vaultName,
|
|
3052
|
+
firstNoteTitle: await firstChangedNoteTitle(store, opts.sinceCursor),
|
|
3053
|
+
push: gitPush,
|
|
3054
|
+
});
|
|
3055
|
+
committed = commitResult.committed;
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
return { stats, nextCursor, committed };
|
|
2788
3059
|
}
|
|
2789
|
-
|
|
2790
|
-
|
|
3060
|
+
|
|
3061
|
+
// ---- Single-shot mode ----
|
|
3062
|
+
if (!watch) {
|
|
3063
|
+
await runCycle({ sinceCursor: since, isInitial: true });
|
|
3064
|
+
return;
|
|
2791
3065
|
}
|
|
2792
|
-
|
|
2793
|
-
|
|
3066
|
+
|
|
3067
|
+
// ---- Watch mode ----
|
|
3068
|
+
// Initial full (or since-filtered) export, then poll every interval.
|
|
3069
|
+
const initial = await runCycle({ sinceCursor: since, isInitial: true });
|
|
3070
|
+
let cursor = initial.nextCursor;
|
|
3071
|
+
console.log(`[watch] polling every ${intervalSeconds}s; press Ctrl-C to stop.`);
|
|
3072
|
+
|
|
3073
|
+
let stopping = false;
|
|
3074
|
+
let inFlight = false;
|
|
3075
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
3076
|
+
const onStop = () => {
|
|
3077
|
+
if (stopping) return;
|
|
3078
|
+
stopping = true;
|
|
3079
|
+
// Clear the interval immediately so the timer can't fire one more
|
|
3080
|
+
// time during the in-flight settle window — `stopping` already guards
|
|
3081
|
+
// re-entry, but symmetry beats relying on that guard.
|
|
3082
|
+
if (timer) clearInterval(timer);
|
|
3083
|
+
console.log("\n[watch] stopping watch");
|
|
3084
|
+
// Give an in-flight cycle a brief moment to settle, then exit. Don't
|
|
3085
|
+
// hang forever — operator hit Ctrl-C, they want out.
|
|
3086
|
+
setTimeout(() => process.exit(0), inFlight ? 250 : 0);
|
|
3087
|
+
};
|
|
3088
|
+
process.on("SIGINT", onStop);
|
|
3089
|
+
process.on("SIGTERM", onStop);
|
|
3090
|
+
|
|
3091
|
+
timer = setInterval(async () => {
|
|
3092
|
+
if (stopping || inFlight) return;
|
|
3093
|
+
inFlight = true;
|
|
3094
|
+
try {
|
|
3095
|
+
const cycle = await runCycle({ sinceCursor: cursor, isInitial: false });
|
|
3096
|
+
cursor = cycle.nextCursor;
|
|
3097
|
+
} catch (err) {
|
|
3098
|
+
// Don't kill the loop on a transient export error — log and keep
|
|
3099
|
+
// polling. Operator can Ctrl-C if they want to bail.
|
|
3100
|
+
console.error(`[watch] export error: ${(err as Error).message ?? err}`);
|
|
3101
|
+
} finally {
|
|
3102
|
+
inFlight = false;
|
|
3103
|
+
}
|
|
3104
|
+
}, intervalSeconds * 1000);
|
|
3105
|
+
// Keep a reference so the timer isn't GC'd; nothing else holds the loop open.
|
|
3106
|
+
timer.unref?.();
|
|
3107
|
+
// Block forever (signal handler is the exit path).
|
|
3108
|
+
await new Promise(() => {});
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
// ---------------------------------------------------------------------------
|
|
3112
|
+
// Export-watch glue. The git-shell + commit-message logic lives in
|
|
3113
|
+
// `./export-watch.ts` for unit-testability; cli.ts just wires it in.
|
|
3114
|
+
// ---------------------------------------------------------------------------
|
|
3115
|
+
|
|
3116
|
+
async function ensureGitRepo(dir: string): Promise<void> {
|
|
3117
|
+
const { isGitRepo } = await import("./export-watch.ts");
|
|
3118
|
+
if (!(await isGitRepo(dir))) {
|
|
3119
|
+
console.error(
|
|
3120
|
+
`error: --git-commit requires "${dir}" to be a git repo.\n` +
|
|
3121
|
+
`Initialize it first:\n` +
|
|
3122
|
+
` cd "${dir}" && git init && git add -A && git commit -m "initial"\n` +
|
|
3123
|
+
`Then re-run the export.`,
|
|
3124
|
+
);
|
|
3125
|
+
process.exit(1);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
/**
|
|
3130
|
+
* Snapshot the title of the first changed note since `cursor` — used as the
|
|
3131
|
+
* `{{first_note_title}}` template variable for single-note commit messages.
|
|
3132
|
+
* Best-effort: returns empty string when nothing matches, or when the cursor
|
|
3133
|
+
* is unset (initial export, where "first changed note" is ambiguous).
|
|
3134
|
+
*
|
|
3135
|
+
* Filters at the DB layer via `dateFilter: { field: "updated_at", from:
|
|
3136
|
+
* cursor }` — earlier versions used `sort: "asc"` + `limit: 1` + a
|
|
3137
|
+
* client-side `stamp >= cursor` post-filter, which fetched the vault's
|
|
3138
|
+
* oldest note and almost always failed the filter, rendering the template
|
|
3139
|
+
* variable as empty in production. See vault#346 reviewer note.
|
|
3140
|
+
*/
|
|
3141
|
+
async function firstChangedNoteTitle(
|
|
3142
|
+
store: ReturnType<typeof import("./vault-store.ts")["getVaultStore"]>,
|
|
3143
|
+
cursor: string | undefined,
|
|
3144
|
+
): Promise<string> {
|
|
3145
|
+
if (!cursor) return "";
|
|
3146
|
+
try {
|
|
3147
|
+
const notes = await store.queryNotes({
|
|
3148
|
+
limit: 1,
|
|
3149
|
+
sort: "asc",
|
|
3150
|
+
dateFilter: { field: "updated_at", from: cursor },
|
|
3151
|
+
});
|
|
3152
|
+
return notes[0]?.path ?? notes[0]?.id ?? "";
|
|
3153
|
+
} catch {
|
|
3154
|
+
// Best-effort; template var defaults to empty.
|
|
3155
|
+
return "";
|
|
2794
3156
|
}
|
|
2795
3157
|
}
|
|
2796
3158
|
|
|
@@ -2921,6 +3283,7 @@ Vaults:
|
|
|
2921
3283
|
[--scope vault:read|vault:write|vault:admin]
|
|
2922
3284
|
[--install-scope local|user|project]
|
|
2923
3285
|
[--vault <name>] [--client claude-code]
|
|
3286
|
+
[--dry-run]
|
|
2924
3287
|
Install vault MCP into a client config.
|
|
2925
3288
|
From a terminal with no flags: walks you
|
|
2926
3289
|
through a contextual conversation (vault,
|
|
@@ -2944,9 +3307,38 @@ Vaults:
|
|
|
2944
3307
|
directory only). user writes top-level
|
|
2945
3308
|
mcpServers (every project). project
|
|
2946
3309
|
writes ./.mcp.json (check into the repo).
|
|
3310
|
+
For headless flows (\`claude -p\` in
|
|
3311
|
+
scripts, cron jobs, runners), prefer
|
|
3312
|
+
--install-scope user — local-scope
|
|
3313
|
+
entries don't propagate to claude -p
|
|
3314
|
+
subprocesses; interactive sessions
|
|
3315
|
+
from the install directory still see
|
|
3316
|
+
local just fine.
|
|
2947
3317
|
--vault <name> targets a specific
|
|
2948
3318
|
vault and keys the entry as
|
|
2949
3319
|
parachute-vault-<name>.
|
|
3320
|
+
--dry-run prints the write that would
|
|
3321
|
+
happen (target file, entry key, URL)
|
|
3322
|
+
without touching disk or hitting the
|
|
3323
|
+
hub. Useful for probing.
|
|
3324
|
+
|
|
3325
|
+
parachute-vault mcp-config <vault-name> [--token <pvt_...>] [--base-url <url>]
|
|
3326
|
+
[--env-vars]
|
|
3327
|
+
Emit the JSON config consumed by
|
|
3328
|
+
\`claude -p --mcp-config '<json>'\`.
|
|
3329
|
+
Pattern:
|
|
3330
|
+
claude -p --mcp-config \\
|
|
3331
|
+
"$(parachute-vault mcp-config <name>)" \\
|
|
3332
|
+
--strict-mcp-config ...
|
|
3333
|
+
--token or PARACHUTE_VAULT_TOKEN env
|
|
3334
|
+
supplies the bearer; both absent
|
|
3335
|
+
exits 1 with a clear error.
|
|
3336
|
+
--base-url overrides the auto-detected
|
|
3337
|
+
origin (e.g. tailnet-exposed hubs).
|
|
3338
|
+
--env-vars emits the template form
|
|
3339
|
+
with \${PARACHUTE_HUB_URL} and
|
|
3340
|
+
\${PARACHUTE_VAULT_TOKEN} placeholders
|
|
3341
|
+
(safe to commit; expanded at runtime).
|
|
2950
3342
|
|
|
2951
3343
|
Tokens:
|
|
2952
3344
|
parachute-vault tokens List tokens (every vault)
|
|
@@ -2993,6 +3385,12 @@ Import/Export:
|
|
|
2993
3385
|
parachute-vault export <dir> Export vault as portable markdown
|
|
2994
3386
|
(Obsidian/Logseq/Foam/Quartz-compatible)
|
|
2995
3387
|
parachute-vault export <dir> --since <iso> Incremental: only notes updated_at >= ISO
|
|
3388
|
+
parachute-vault export <dir> --watch Stay alive; re-export on every write
|
|
3389
|
+
(poll every --interval seconds, default 5)
|
|
3390
|
+
parachute-vault export <dir> --git-commit After export, git add -A + commit in <dir>
|
|
3391
|
+
(combine with --watch for auto-history;
|
|
3392
|
+
template via --git-message-template;
|
|
3393
|
+
--git-push to push after commit)
|
|
2996
3394
|
|
|
2997
3395
|
── Advanced / standalone ──────────────────────────────────────────────
|
|
2998
3396
|
|