@openparachute/vault 0.1.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/.claude/settings.local.json +31 -0
- package/.dockerignore +8 -0
- package/.env.example +9 -0
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
- package/CLAUDE.md +115 -0
- package/Caddyfile +3 -0
- package/Dockerfile +22 -0
- package/LICENSE +661 -0
- package/README.md +356 -0
- package/bun.lock +219 -0
- package/bunfig.toml +2 -0
- package/core/package.json +7 -0
- package/core/src/core.test.ts +940 -0
- package/core/src/hooks.test.ts +361 -0
- package/core/src/hooks.ts +234 -0
- package/core/src/links.ts +352 -0
- package/core/src/mcp.ts +672 -0
- package/core/src/notes.ts +520 -0
- package/core/src/obsidian.test.ts +380 -0
- package/core/src/obsidian.ts +322 -0
- package/core/src/paths.test.ts +197 -0
- package/core/src/paths.ts +53 -0
- package/core/src/schema.ts +331 -0
- package/core/src/store.ts +303 -0
- package/core/src/tag-schemas.ts +104 -0
- package/core/src/test-preload.ts +8 -0
- package/core/src/types.ts +140 -0
- package/core/src/wikilinks.test.ts +277 -0
- package/core/src/wikilinks.ts +402 -0
- package/deploy/parachute-vault.service +20 -0
- package/docker-compose.yml +50 -0
- package/docs/HTTP_API.md +328 -0
- package/fly.toml +24 -0
- package/package.json +32 -0
- package/railway.json +14 -0
- 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/scripts/migrate-audio-to-opus.test.ts +237 -0
- package/scripts/migrate-audio-to-opus.ts +499 -0
- package/src/auth.ts +170 -0
- package/src/cli.ts +1131 -0
- package/src/config-triggers.test.ts +83 -0
- package/src/config.test.ts +125 -0
- package/src/config.ts +716 -0
- package/src/db.ts +14 -0
- package/src/launchd.ts +109 -0
- package/src/mcp-http.ts +113 -0
- package/src/mcp-tools.ts +155 -0
- package/src/oauth.test.ts +1242 -0
- package/src/oauth.ts +729 -0
- package/src/owner-auth.ts +159 -0
- package/src/prompt.ts +141 -0
- package/src/published.test.ts +214 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/routes.ts +822 -0
- package/src/server.ts +450 -0
- package/src/systemd.ts +84 -0
- package/src/token-store.test.ts +174 -0
- package/src/token-store.ts +241 -0
- package/src/triggers.test.ts +397 -0
- package/src/triggers.ts +412 -0
- package/src/two-factor.test.ts +246 -0
- package/src/two-factor.ts +222 -0
- package/src/vault-store.ts +47 -0
- package/src/vault.test.ts +1309 -0
- package/tsconfig.json +29 -0
- package/web/README.md +73 -0
- package/web/bun.lock +827 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +15 -0
- package/web/package.json +36 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/icons.svg +24 -0
- package/web/src/App.tsx +149 -0
- package/web/src/Graph.tsx +200 -0
- package/web/src/NoteView.tsx +155 -0
- package/web/src/Sidebar.tsx +186 -0
- package/web/src/api.ts +21 -0
- package/web/src/index.css +50 -0
- package/web/src/main.tsx +10 -0
- package/web/src/types.ts +37 -0
- package/web/src/utils.ts +107 -0
- package/web/tsconfig.app.json +25 -0
- package/web/tsconfig.json +7 -0
- package/web/tsconfig.node.json +24 -0
- package/web/vite.config.ts +15 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,1131 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parachute Vault CLI.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* parachute vault init — set up everything, one command
|
|
8
|
+
* parachute vault create <name> — create a new vault
|
|
9
|
+
* parachute vault list — list all vaults
|
|
10
|
+
* parachute vault mcp-install <name> — add vault MCP to ~/.claude.json
|
|
11
|
+
* parachute vault remove <name> — remove a vault
|
|
12
|
+
* parachute vault config — show all config
|
|
13
|
+
* parachute vault config set <key> <val> — set a config value
|
|
14
|
+
* parachute vault config unset <key> — remove a config value
|
|
15
|
+
* parachute vault serve — run the server (foreground)
|
|
16
|
+
* parachute vault status — show full status
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { resolve } from "path";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from "fs";
|
|
22
|
+
import {
|
|
23
|
+
ensureConfigDirSync,
|
|
24
|
+
readVaultConfig,
|
|
25
|
+
writeVaultConfig,
|
|
26
|
+
readGlobalConfig,
|
|
27
|
+
writeGlobalConfig,
|
|
28
|
+
readEnvFile,
|
|
29
|
+
writeEnvFile,
|
|
30
|
+
setEnvVar,
|
|
31
|
+
unsetEnvVar,
|
|
32
|
+
loadEnvFile,
|
|
33
|
+
listVaults,
|
|
34
|
+
vaultDir,
|
|
35
|
+
DEFAULT_PORT,
|
|
36
|
+
CONFIG_DIR,
|
|
37
|
+
ASSETS_DIR,
|
|
38
|
+
ENV_PATH,
|
|
39
|
+
LOG_PATH,
|
|
40
|
+
ERR_PATH,
|
|
41
|
+
} from "./config.ts";
|
|
42
|
+
import type { VaultConfig } from "./config.ts";
|
|
43
|
+
import { installAgent, uninstallAgent, isAgentLoaded, restartAgent } from "./launchd.ts";
|
|
44
|
+
import { installSystemdService, restartSystemdService, isSystemdAvailable, isServiceActive } from "./systemd.ts";
|
|
45
|
+
import { confirm, ask, askPassword, choose } from "./prompt.ts";
|
|
46
|
+
import { generateToken, createToken, listTokens, revokeToken, migrateVaultKeys } from "./token-store.ts";
|
|
47
|
+
import type { TokenPermission } from "./token-store.ts";
|
|
48
|
+
import { getVaultStore } from "./vault-store.ts";
|
|
49
|
+
import {
|
|
50
|
+
hasOwnerPassword,
|
|
51
|
+
setOwnerPassword,
|
|
52
|
+
clearOwnerPassword,
|
|
53
|
+
validatePasswordStrength,
|
|
54
|
+
getOwnerPasswordHash,
|
|
55
|
+
verifyOwnerPassword,
|
|
56
|
+
} from "./owner-auth.ts";
|
|
57
|
+
import {
|
|
58
|
+
enrollTotp,
|
|
59
|
+
disableTotp,
|
|
60
|
+
hasTotpEnrolled,
|
|
61
|
+
regenerateBackupCodes,
|
|
62
|
+
getBackupCodeCount,
|
|
63
|
+
verifyTotpCode,
|
|
64
|
+
verifyAndConsumeBackupCode,
|
|
65
|
+
getTotpSecret,
|
|
66
|
+
} from "./two-factor.ts";
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Argument parsing
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
const args = process.argv.slice(2);
|
|
73
|
+
|
|
74
|
+
// Support both `parachute vault <cmd>` and `parachute <cmd>` patterns
|
|
75
|
+
let command: string;
|
|
76
|
+
let cmdArgs: string[];
|
|
77
|
+
|
|
78
|
+
if (args[0] === "vault") {
|
|
79
|
+
command = args[1] ?? "help";
|
|
80
|
+
cmdArgs = args.slice(2);
|
|
81
|
+
} else {
|
|
82
|
+
command = args[0] ?? "help";
|
|
83
|
+
cmdArgs = args.slice(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Commands
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
switch (command) {
|
|
91
|
+
case "init":
|
|
92
|
+
await cmdInit();
|
|
93
|
+
break;
|
|
94
|
+
case "create":
|
|
95
|
+
cmdCreate(cmdArgs);
|
|
96
|
+
break;
|
|
97
|
+
case "list":
|
|
98
|
+
case "ls":
|
|
99
|
+
cmdList();
|
|
100
|
+
break;
|
|
101
|
+
case "mcp-install":
|
|
102
|
+
cmdMcpInstall(cmdArgs);
|
|
103
|
+
break;
|
|
104
|
+
case "remove":
|
|
105
|
+
case "rm":
|
|
106
|
+
cmdRemove(cmdArgs);
|
|
107
|
+
break;
|
|
108
|
+
case "config":
|
|
109
|
+
await cmdConfig(cmdArgs);
|
|
110
|
+
break;
|
|
111
|
+
case "tokens":
|
|
112
|
+
cmdTokens(cmdArgs);
|
|
113
|
+
break;
|
|
114
|
+
case "set-password":
|
|
115
|
+
await cmdSetPassword(cmdArgs);
|
|
116
|
+
break;
|
|
117
|
+
case "2fa":
|
|
118
|
+
await cmd2fa(cmdArgs);
|
|
119
|
+
break;
|
|
120
|
+
case "serve":
|
|
121
|
+
await cmdServe();
|
|
122
|
+
break;
|
|
123
|
+
case "logs":
|
|
124
|
+
await cmdLogs();
|
|
125
|
+
break;
|
|
126
|
+
case "status":
|
|
127
|
+
await cmdStatus();
|
|
128
|
+
break;
|
|
129
|
+
case "restart":
|
|
130
|
+
await cmdRestart();
|
|
131
|
+
break;
|
|
132
|
+
case "import":
|
|
133
|
+
await cmdImport(cmdArgs);
|
|
134
|
+
break;
|
|
135
|
+
case "export":
|
|
136
|
+
await cmdExport(cmdArgs);
|
|
137
|
+
break;
|
|
138
|
+
case "help":
|
|
139
|
+
case "--help":
|
|
140
|
+
case "-h":
|
|
141
|
+
usage();
|
|
142
|
+
break;
|
|
143
|
+
default:
|
|
144
|
+
console.error(`Unknown command: ${command}`);
|
|
145
|
+
usage();
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Command implementations
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
async function cmdInit() {
|
|
154
|
+
ensureConfigDirSync();
|
|
155
|
+
|
|
156
|
+
const isMac = process.platform === "darwin";
|
|
157
|
+
const isLinux = process.platform === "linux";
|
|
158
|
+
const isFirstRun = !existsSync(ENV_PATH);
|
|
159
|
+
|
|
160
|
+
console.log("Parachute Vault — self-hosted knowledge graph\n");
|
|
161
|
+
|
|
162
|
+
// 1. Create default vault if none exist
|
|
163
|
+
const vaults = listVaults();
|
|
164
|
+
let apiKey: string | undefined;
|
|
165
|
+
if (vaults.length === 0) {
|
|
166
|
+
console.log("Creating default vault...");
|
|
167
|
+
apiKey = createVault("default");
|
|
168
|
+
console.log(" Created vault: default");
|
|
169
|
+
} else {
|
|
170
|
+
console.log(`Found ${vaults.length} existing vault(s)`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 2. Write global config
|
|
174
|
+
const globalConfig = readGlobalConfig();
|
|
175
|
+
if (!globalConfig.default_vault) {
|
|
176
|
+
globalConfig.default_vault = "default";
|
|
177
|
+
}
|
|
178
|
+
writeGlobalConfig(globalConfig);
|
|
179
|
+
|
|
180
|
+
// 2b. Migrate existing legacy keys into per-vault token tables
|
|
181
|
+
for (const v of listVaults()) {
|
|
182
|
+
try {
|
|
183
|
+
const vc = readVaultConfig(v);
|
|
184
|
+
if (!vc) continue;
|
|
185
|
+
const store = getVaultStore(v);
|
|
186
|
+
migrateVaultKeys(store.db, vc.api_keys, globalConfig.api_keys);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error(` Warning: could not migrate keys for vault "${v}":`, err);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 3. Ensure assets directory exists
|
|
193
|
+
mkdirSync(ASSETS_DIR, { recursive: true });
|
|
194
|
+
|
|
195
|
+
// 4. Create .env with sensible defaults if it doesn't exist
|
|
196
|
+
const envVars: Record<string, string> = {};
|
|
197
|
+
if (isFirstRun) {
|
|
198
|
+
envVars.PORT = String(globalConfig.port || DEFAULT_PORT);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 5. Write env file (first run only)
|
|
202
|
+
if (isFirstRun) {
|
|
203
|
+
writeEnvFile(envVars);
|
|
204
|
+
console.log();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 5b. Offer to set an owner password for OAuth consent, unless one is already set.
|
|
208
|
+
if (!hasOwnerPassword()) {
|
|
209
|
+
await promptForOwnerPassword("Set an owner password for OAuth consent?");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 6. Install daemon (platform-aware)
|
|
213
|
+
console.log("Installing daemon...");
|
|
214
|
+
if (isMac) {
|
|
215
|
+
await installAgent();
|
|
216
|
+
} else if (isLinux && isSystemdAvailable()) {
|
|
217
|
+
await installSystemdService();
|
|
218
|
+
} else {
|
|
219
|
+
console.log(" Auto-start not available on this platform.");
|
|
220
|
+
console.log(" Run manually: bun src/server.ts");
|
|
221
|
+
console.log(" Or use Docker: docker compose up -d");
|
|
222
|
+
}
|
|
223
|
+
console.log(` Listening on http://0.0.0.0:${globalConfig.port || DEFAULT_PORT}`);
|
|
224
|
+
|
|
225
|
+
// 7. Install MCP for Claude Code (with token for auth)
|
|
226
|
+
installMcpConfig(apiKey);
|
|
227
|
+
console.log(` MCP server added to ~/.claude.json`);
|
|
228
|
+
|
|
229
|
+
// 8. Summary
|
|
230
|
+
console.log("\n---");
|
|
231
|
+
const port = globalConfig.port || DEFAULT_PORT;
|
|
232
|
+
if (apiKey) {
|
|
233
|
+
console.log(`\nYour API token: ${apiKey}`);
|
|
234
|
+
console.log(" Use this in Claude Desktop, curl, or any client.");
|
|
235
|
+
console.log(" Pass via: Authorization: Bearer <token>");
|
|
236
|
+
console.log(" Or via: X-API-Key: <token>");
|
|
237
|
+
console.log("\nSave this — it will not be shown again.");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log(`\nConfig: ${CONFIG_DIR}`);
|
|
241
|
+
console.log(`Server: http://0.0.0.0:${port}`);
|
|
242
|
+
|
|
243
|
+
console.log(`\nUsage examples:`);
|
|
244
|
+
console.log(` curl http://localhost:${port}/health`);
|
|
245
|
+
if (apiKey) {
|
|
246
|
+
console.log(` curl -H "Authorization: Bearer ${apiKey}" http://localhost:${port}/api/notes`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.log(`\nNext steps:`);
|
|
250
|
+
console.log(` parachute vault status check everything is running`);
|
|
251
|
+
console.log(` parachute vault config view/edit configuration`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function promptForOwnerPassword(purpose: string): Promise<boolean> {
|
|
255
|
+
console.log(`\n${purpose}`);
|
|
256
|
+
console.log(" Used on the OAuth consent page to authorize third-party clients");
|
|
257
|
+
console.log(" (Claude Web, Claude Desktop, etc.) to access this vault.");
|
|
258
|
+
console.log(` Minimum 12 characters.\n`);
|
|
259
|
+
|
|
260
|
+
while (true) {
|
|
261
|
+
const pw = await askPassword(" Password (or leave blank to skip)");
|
|
262
|
+
if (!pw) {
|
|
263
|
+
console.log(" Skipped — you can set one later with `parachute vault set-password`.");
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const err = validatePasswordStrength(pw);
|
|
268
|
+
if (err) {
|
|
269
|
+
console.log(` ${err} Try again.`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const confirmPw = await askPassword(" Confirm password");
|
|
274
|
+
if (pw !== confirmPw) {
|
|
275
|
+
console.log(" Passwords don't match. Try again.");
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
await setOwnerPassword(pw);
|
|
280
|
+
console.log(" Password set.");
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function cmdSetPassword(args: string[]) {
|
|
286
|
+
const wantsClear = args.includes("--clear") || args.includes("--unset");
|
|
287
|
+
if (wantsClear) {
|
|
288
|
+
if (!hasOwnerPassword()) {
|
|
289
|
+
console.log("No owner password is set.");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const twoFaNote = hasTotpEnrolled()
|
|
293
|
+
? " Note: 2FA management operations will require your authenticator app or a backup code instead."
|
|
294
|
+
: "";
|
|
295
|
+
const ok = await confirm(
|
|
296
|
+
`Remove the owner password? OAuth consent will fall back to vault-token auth.${twoFaNote}`,
|
|
297
|
+
false,
|
|
298
|
+
);
|
|
299
|
+
if (!ok) {
|
|
300
|
+
console.log("Cancelled.");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
clearOwnerPassword();
|
|
304
|
+
console.log("Owner password cleared.");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const purpose = hasOwnerPassword()
|
|
309
|
+
? "Change owner password"
|
|
310
|
+
: "Set owner password";
|
|
311
|
+
await promptForOwnerPassword(purpose);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// 2FA — parachute vault 2fa [enroll | disable | backup-codes | status]
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
async function confirmOwnerPassword(purpose: string): Promise<boolean> {
|
|
319
|
+
const hash = getOwnerPasswordHash();
|
|
320
|
+
if (!hash) {
|
|
321
|
+
console.error("No owner password is set. Run: parachute vault set-password");
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
console.log(purpose);
|
|
325
|
+
const pw = await askPassword(" Current password");
|
|
326
|
+
if (!pw) {
|
|
327
|
+
console.log(" Cancelled.");
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
const ok = await verifyOwnerPassword(pw, hash);
|
|
331
|
+
if (!ok) {
|
|
332
|
+
console.error(" Incorrect password.");
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Confirm ownership for 2FA-management commands. Prefers the password when
|
|
340
|
+
* one is set; otherwise falls back to a TOTP or backup code so the owner
|
|
341
|
+
* isn't locked out if they cleared the password while 2FA was still enrolled.
|
|
342
|
+
*/
|
|
343
|
+
async function confirmForTwoFactor(purpose: string): Promise<boolean> {
|
|
344
|
+
if (hasOwnerPassword()) {
|
|
345
|
+
return confirmOwnerPassword(purpose);
|
|
346
|
+
}
|
|
347
|
+
// Fallback path: no password, must prove via current TOTP or backup code.
|
|
348
|
+
const secret = getTotpSecret();
|
|
349
|
+
if (!secret) {
|
|
350
|
+
console.error("2FA is not enabled.");
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
console.log(purpose);
|
|
354
|
+
console.log(" (No owner password set — confirm with an authenticator code or a backup code.)");
|
|
355
|
+
const totp = (await ask(" Authenticator code (blank to use a backup code)")).trim();
|
|
356
|
+
if (totp) {
|
|
357
|
+
if (verifyTotpCode(secret, totp)) return true;
|
|
358
|
+
console.error(" Invalid authenticator code.");
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
console.log(" This will consume one of your backup codes.");
|
|
362
|
+
const backup = (await ask(" Backup code")).trim();
|
|
363
|
+
if (!backup) {
|
|
364
|
+
console.log(" Cancelled.");
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
const ok = await verifyAndConsumeBackupCode(backup);
|
|
368
|
+
if (!ok) {
|
|
369
|
+
console.error(" Invalid or already-used backup code.");
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
console.log(" (Backup code consumed.)");
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function cmd2fa(args: string[]) {
|
|
377
|
+
const sub = args[0] ?? "status";
|
|
378
|
+
|
|
379
|
+
if (sub === "status") {
|
|
380
|
+
if (hasTotpEnrolled()) {
|
|
381
|
+
console.log(`2FA: enabled (${getBackupCodeCount()} backup code(s) remaining)`);
|
|
382
|
+
} else {
|
|
383
|
+
console.log("2FA: not enabled");
|
|
384
|
+
console.log(" Enable with: parachute vault 2fa enroll");
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (sub === "enroll") {
|
|
390
|
+
if (!hasOwnerPassword()) {
|
|
391
|
+
console.error("Set an owner password first: parachute vault set-password");
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|
|
394
|
+
if (hasTotpEnrolled()) {
|
|
395
|
+
const ok = await confirm("2FA is already enabled. Re-enroll (invalidates existing authenticator + backup codes)?", false);
|
|
396
|
+
if (!ok) {
|
|
397
|
+
console.log("Cancelled.");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (!(await confirmOwnerPassword("Confirm your owner password to enroll 2FA:"))) {
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const result = await enrollTotp();
|
|
406
|
+
// qrcode-terminal ships no types; shape: { generate(text, {small}, cb) }.
|
|
407
|
+
const qrcode = (await import("qrcode-terminal")).default as {
|
|
408
|
+
generate: (text: string, opts: { small: boolean }, cb: (q: string) => void) => void;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
console.log("\nScan this QR code with your authenticator app:\n");
|
|
412
|
+
await new Promise<void>((resolve) => {
|
|
413
|
+
qrcode.generate(result.otpauthUrl, { small: true }, (q: string) => {
|
|
414
|
+
console.log(q);
|
|
415
|
+
resolve();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
console.log(`Or enter this secret manually:\n ${result.secret}\n`);
|
|
419
|
+
|
|
420
|
+
// Confirmation step: require a code from the newly-enrolled app before
|
|
421
|
+
// we consider enrollment final. Protects against the user scanning wrong
|
|
422
|
+
// and locking themselves out.
|
|
423
|
+
let confirmed = false;
|
|
424
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
425
|
+
const entered = (await ask("Enter the 6-digit code from your authenticator to confirm")).trim();
|
|
426
|
+
// markUsed=false — don't consume the code here; the user may need it
|
|
427
|
+
// again immediately for the consent page.
|
|
428
|
+
if (verifyTotpCode(result.secret, entered, false)) {
|
|
429
|
+
confirmed = true;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
console.log(` Incorrect code. (${2 - attempt} attempt(s) left)`);
|
|
433
|
+
}
|
|
434
|
+
if (!confirmed) {
|
|
435
|
+
console.error("Enrollment failed — rolling back. Re-run `parachute vault 2fa enroll` to try again.");
|
|
436
|
+
disableTotp();
|
|
437
|
+
process.exit(1);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
console.log("\nBackup codes (single-use; store somewhere safe — they are NOT retrievable):");
|
|
441
|
+
for (const code of result.backupCodes) {
|
|
442
|
+
console.log(` ${code}`);
|
|
443
|
+
}
|
|
444
|
+
console.log("\n2FA is now active for OAuth consent on this vault.");
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (sub === "disable") {
|
|
449
|
+
if (!hasTotpEnrolled()) {
|
|
450
|
+
console.log("2FA is not enabled.");
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (!(await confirmForTwoFactor("Confirm ownership to disable 2FA:"))) {
|
|
454
|
+
process.exit(1);
|
|
455
|
+
}
|
|
456
|
+
disableTotp();
|
|
457
|
+
console.log("2FA disabled. Backup codes cleared.");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (sub === "backup-codes") {
|
|
462
|
+
if (!hasTotpEnrolled()) {
|
|
463
|
+
console.error("2FA is not enabled. Run: parachute vault 2fa enroll");
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
if (!(await confirmForTwoFactor("Confirm ownership to regenerate backup codes:"))) {
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
const codes = await regenerateBackupCodes();
|
|
470
|
+
console.log("\nNew backup codes (previous codes are now invalid):");
|
|
471
|
+
for (const code of codes) {
|
|
472
|
+
console.log(` ${code}`);
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
console.error(`Unknown 2fa command: ${sub}`);
|
|
478
|
+
console.error("Usage: parachute vault 2fa [status | enroll | disable | backup-codes]");
|
|
479
|
+
process.exit(1);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function cmdCreate(args: string[]) {
|
|
483
|
+
const name = args[0];
|
|
484
|
+
if (!name) {
|
|
485
|
+
console.error("Usage: parachute vault create <name>");
|
|
486
|
+
process.exit(1);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
490
|
+
console.error("Vault name must contain only letters, numbers, hyphens, and underscores.");
|
|
491
|
+
process.exit(1);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const existing = readVaultConfig(name);
|
|
495
|
+
if (existing) {
|
|
496
|
+
console.error(`Vault "${name}" already exists.`);
|
|
497
|
+
process.exit(1);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
ensureConfigDirSync();
|
|
501
|
+
const key = createVault(name);
|
|
502
|
+
|
|
503
|
+
console.log(`Vault "${name}" created.`);
|
|
504
|
+
console.log(` Path: ${vaultDir(name)}`);
|
|
505
|
+
console.log(` API token: ${key}`);
|
|
506
|
+
console.log(` Save this — it will not be shown again.`);
|
|
507
|
+
console.log();
|
|
508
|
+
console.log(`To add MCP to Claude: parachute vault mcp-install ${name}`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function cmdList() {
|
|
512
|
+
const vaults = listVaults();
|
|
513
|
+
if (vaults.length === 0) {
|
|
514
|
+
console.log("No vaults. Run: parachute vault init");
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
for (const name of vaults) {
|
|
519
|
+
const config = readVaultConfig(name);
|
|
520
|
+
const keys = config?.api_keys.length ?? 0;
|
|
521
|
+
const desc = config?.description ? ` — ${config.description}` : "";
|
|
522
|
+
console.log(` ${name}${desc} (${keys} key${keys !== 1 ? "s" : ""})`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function cmdMcpInstall(_args: string[]) {
|
|
527
|
+
installMcpConfig();
|
|
528
|
+
console.log(`Added MCP server "parachute-vault" to ~/.claude.json`);
|
|
529
|
+
console.log(`All vaults accessible via the 'vault' parameter on each tool.`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function cmdRemove(args: string[]) {
|
|
533
|
+
const name = args[0];
|
|
534
|
+
if (!name) {
|
|
535
|
+
console.error("Usage: parachute vault remove <name>");
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const config = readVaultConfig(name);
|
|
540
|
+
if (!config) {
|
|
541
|
+
console.error(`Vault "${name}" not found.`);
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const force = args.includes("--yes") || args.includes("-y");
|
|
546
|
+
if (!force) {
|
|
547
|
+
console.log(`This will permanently delete vault "${name}" and all its data.`);
|
|
548
|
+
console.log(` Path: ${vaultDir(name)}`);
|
|
549
|
+
console.log(`\nTo confirm: parachute vault remove ${name} --yes`);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
rmSync(vaultDir(name), { recursive: true, force: true });
|
|
554
|
+
console.log(`Vault "${name}" removed.`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function cmdConfig(args: string[]) {
|
|
558
|
+
const subcmd = args[0];
|
|
559
|
+
|
|
560
|
+
// parachute vault config — show current config
|
|
561
|
+
if (!subcmd) {
|
|
562
|
+
loadEnvFile();
|
|
563
|
+
const env = readEnvFile();
|
|
564
|
+
const globalConfig = readGlobalConfig();
|
|
565
|
+
|
|
566
|
+
console.log("Parachute Vault Configuration");
|
|
567
|
+
console.log(` Config dir: ${CONFIG_DIR}`);
|
|
568
|
+
console.log(` Env file: ${ENV_PATH}`);
|
|
569
|
+
console.log(` Port: ${globalConfig.port}`);
|
|
570
|
+
console.log();
|
|
571
|
+
|
|
572
|
+
if (Object.keys(env).length === 0) {
|
|
573
|
+
console.log(" No env vars set. Use: parachute vault config set <key> <value>");
|
|
574
|
+
} else {
|
|
575
|
+
for (const [key, val] of Object.entries(env)) {
|
|
576
|
+
// Mask sensitive values
|
|
577
|
+
const display = key.includes("KEY") || key.includes("SECRET")
|
|
578
|
+
? val.slice(0, 8) + "..."
|
|
579
|
+
: val;
|
|
580
|
+
console.log(` ${key}=${display}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// parachute vault config set <key> <value>
|
|
588
|
+
if (subcmd === "set") {
|
|
589
|
+
const key = args[1];
|
|
590
|
+
const value = args.slice(2).join(" ");
|
|
591
|
+
if (!key || !value) {
|
|
592
|
+
console.error("Usage: parachute vault config set <key> <value>");
|
|
593
|
+
process.exit(1);
|
|
594
|
+
}
|
|
595
|
+
setEnvVar(key, value);
|
|
596
|
+
console.log(`Set ${key}=${key.includes("KEY") ? value.slice(0, 8) + "..." : value}`);
|
|
597
|
+
console.log("Restart the daemon to apply: parachute vault restart");
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// parachute vault config unset <key>
|
|
602
|
+
if (subcmd === "unset") {
|
|
603
|
+
const key = args[1];
|
|
604
|
+
if (!key) {
|
|
605
|
+
console.error("Usage: parachute vault config unset <key>");
|
|
606
|
+
process.exit(1);
|
|
607
|
+
}
|
|
608
|
+
unsetEnvVar(key);
|
|
609
|
+
console.log(`Removed ${key}`);
|
|
610
|
+
console.log("Restart the daemon to apply: parachute vault restart");
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
console.error(`Unknown config command: ${subcmd}`);
|
|
615
|
+
console.error("Usage: parachute vault config [set <key> <value> | unset <key>]");
|
|
616
|
+
process.exit(1);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ---------------------------------------------------------------------------
|
|
620
|
+
// Tokens — parachute vault tokens [create | list | revoke]
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
function cmdTokens(args: string[]) {
|
|
624
|
+
const subcmd = args[0];
|
|
625
|
+
|
|
626
|
+
// parachute vault tokens — list all tokens (across all vaults)
|
|
627
|
+
if (!subcmd || subcmd === "list") {
|
|
628
|
+
const vaults = listVaults();
|
|
629
|
+
let anyTokens = false;
|
|
630
|
+
|
|
631
|
+
for (const vaultName of vaults) {
|
|
632
|
+
const vc = readVaultConfig(vaultName);
|
|
633
|
+
if (!vc) continue;
|
|
634
|
+
const store = getVaultStore(vaultName);
|
|
635
|
+
// Ensure legacy keys are migrated
|
|
636
|
+
const globalCfg = readGlobalConfig();
|
|
637
|
+
migrateVaultKeys(store.db, vc.api_keys, globalCfg.api_keys);
|
|
638
|
+
|
|
639
|
+
const tokens = listTokens(store.db);
|
|
640
|
+
if (tokens.length === 0) continue;
|
|
641
|
+
anyTokens = true;
|
|
642
|
+
|
|
643
|
+
console.log(`Vault "${vaultName}" tokens:`);
|
|
644
|
+
for (const t of tokens) {
|
|
645
|
+
const expiry = t.expires_at ? ` (expires: ${t.expires_at})` : "";
|
|
646
|
+
const lastUsed = t.last_used_at ? ` (last used: ${t.last_used_at})` : "";
|
|
647
|
+
console.log(` ${t.id} ${t.label} [${t.permission}]${expiry}${lastUsed}`);
|
|
648
|
+
}
|
|
649
|
+
console.log();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (!anyTokens) {
|
|
653
|
+
console.log("No tokens found. Create one: parachute vault tokens create");
|
|
654
|
+
}
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// parachute vault tokens create --vault <name> [--permission full|read]
|
|
659
|
+
// [--expires <duration>] [--label <label>]
|
|
660
|
+
if (subcmd === "create") {
|
|
661
|
+
const vaultFlag = args.indexOf("--vault");
|
|
662
|
+
const vaultName = vaultFlag !== -1 ? args[vaultFlag + 1] : (readGlobalConfig().default_vault || "default");
|
|
663
|
+
|
|
664
|
+
const vc = readVaultConfig(vaultName);
|
|
665
|
+
if (!vc) {
|
|
666
|
+
console.error(`Vault "${vaultName}" not found.`);
|
|
667
|
+
process.exit(1);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// --read shorthand or --permission full|read
|
|
671
|
+
const isReadShorthand = args.includes("--read");
|
|
672
|
+
const permFlag = args.indexOf("--permission");
|
|
673
|
+
const rawPerm = isReadShorthand ? "read" : (permFlag !== -1 ? args[permFlag + 1] : "full");
|
|
674
|
+
const permission: TokenPermission = rawPerm === "read" ? "read" : "full";
|
|
675
|
+
if (!["full", "read", "admin", "write"].includes(rawPerm)) {
|
|
676
|
+
console.error(`Invalid permission: ${rawPerm}. Must be full or read.`);
|
|
677
|
+
process.exit(1);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const expiresFlag = args.indexOf("--expires");
|
|
681
|
+
let expiresAt: string | null = null;
|
|
682
|
+
if (expiresFlag !== -1) {
|
|
683
|
+
const dur = args[expiresFlag + 1];
|
|
684
|
+
expiresAt = parseDuration(dur);
|
|
685
|
+
if (!expiresAt) {
|
|
686
|
+
console.error(`Invalid duration: ${dur}. Use format like 7d, 30d, 24h, 1y.`);
|
|
687
|
+
process.exit(1);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const labelFlag = args.indexOf("--label");
|
|
692
|
+
const label = labelFlag !== -1 ? args[labelFlag + 1] : "default";
|
|
693
|
+
|
|
694
|
+
const store = getVaultStore(vaultName);
|
|
695
|
+
const { fullToken } = generateToken();
|
|
696
|
+
createToken(store.db, fullToken, {
|
|
697
|
+
label,
|
|
698
|
+
permission,
|
|
699
|
+
expires_at: expiresAt,
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
console.log(`Created token for vault "${vaultName}":`);
|
|
703
|
+
console.log(` Token: ${fullToken}`);
|
|
704
|
+
console.log(` Permission: ${permission}`);
|
|
705
|
+
if (expiresAt) console.log(` Expires: ${expiresAt}`);
|
|
706
|
+
console.log(` Label: ${label}`);
|
|
707
|
+
console.log();
|
|
708
|
+
console.log("Save this token — it will not be shown again.");
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// parachute vault tokens revoke <token-id> --vault <name>
|
|
713
|
+
if (subcmd === "revoke") {
|
|
714
|
+
const tokenId = args[1];
|
|
715
|
+
if (!tokenId) {
|
|
716
|
+
console.error("Usage: parachute vault tokens revoke <token-id> --vault <name>");
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const vaultFlag = args.indexOf("--vault");
|
|
721
|
+
const vaultName = vaultFlag !== -1 ? args[vaultFlag + 1] : (readGlobalConfig().default_vault || "default");
|
|
722
|
+
|
|
723
|
+
const vc = readVaultConfig(vaultName);
|
|
724
|
+
if (!vc) {
|
|
725
|
+
console.error(`Vault "${vaultName}" not found.`);
|
|
726
|
+
process.exit(1);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const store = getVaultStore(vaultName);
|
|
730
|
+
if (revokeToken(store.db, tokenId)) {
|
|
731
|
+
console.log(`Revoked token: ${tokenId}`);
|
|
732
|
+
} else {
|
|
733
|
+
console.error(`Token "${tokenId}" not found in vault "${vaultName}".`);
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
console.error(`Unknown tokens command: ${subcmd}`);
|
|
740
|
+
console.error("Usage: parachute vault tokens [create | list | revoke <id>]");
|
|
741
|
+
process.exit(1);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function parseDuration(dur: string): string | null {
|
|
745
|
+
const match = dur.match(/^(\d+)(h|d|w|m|y)$/);
|
|
746
|
+
if (!match) return null;
|
|
747
|
+
const n = parseInt(match[1], 10);
|
|
748
|
+
const unit = match[2];
|
|
749
|
+
const now = new Date();
|
|
750
|
+
switch (unit) {
|
|
751
|
+
case "h": now.setHours(now.getHours() + n); break;
|
|
752
|
+
case "d": now.setDate(now.getDate() + n); break;
|
|
753
|
+
case "w": now.setDate(now.getDate() + n * 7); break;
|
|
754
|
+
case "m": now.setMonth(now.getMonth() + n); break;
|
|
755
|
+
case "y": now.setFullYear(now.getFullYear() + n); break;
|
|
756
|
+
default: return null;
|
|
757
|
+
}
|
|
758
|
+
return now.toISOString();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function cmdServe() {
|
|
762
|
+
await import("./server.ts");
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function cmdLogs() {
|
|
766
|
+
const proc = Bun.spawn(["tail", "-f", LOG_PATH, ERR_PATH], {
|
|
767
|
+
stdout: "inherit",
|
|
768
|
+
stderr: "inherit",
|
|
769
|
+
});
|
|
770
|
+
await proc.exited;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function cmdRestart() {
|
|
774
|
+
console.log("Restarting daemon...");
|
|
775
|
+
if (process.platform === "darwin") {
|
|
776
|
+
await restartAgent();
|
|
777
|
+
} else if (isSystemdAvailable()) {
|
|
778
|
+
await restartSystemdService();
|
|
779
|
+
} else {
|
|
780
|
+
console.error("No daemon manager available. Restart manually or use Docker.");
|
|
781
|
+
process.exit(1);
|
|
782
|
+
}
|
|
783
|
+
console.log("Done.");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function cmdStatus() {
|
|
787
|
+
loadEnvFile();
|
|
788
|
+
let loaded: boolean;
|
|
789
|
+
if (process.platform === "darwin") {
|
|
790
|
+
loaded = await isAgentLoaded();
|
|
791
|
+
} else if (isSystemdAvailable()) {
|
|
792
|
+
loaded = await isServiceActive();
|
|
793
|
+
} else {
|
|
794
|
+
// Check if server responds on the port
|
|
795
|
+
try {
|
|
796
|
+
const resp = await fetch(`http://127.0.0.1:${readGlobalConfig().port || DEFAULT_PORT}/health`);
|
|
797
|
+
loaded = resp.ok;
|
|
798
|
+
} catch { loaded = false; }
|
|
799
|
+
}
|
|
800
|
+
const vaults = listVaults();
|
|
801
|
+
const globalConfig = readGlobalConfig();
|
|
802
|
+
|
|
803
|
+
console.log("Parachute Vault\n");
|
|
804
|
+
|
|
805
|
+
// Server
|
|
806
|
+
console.log(` Server: ${loaded ? "running" : "stopped"} on port ${globalConfig.port}`);
|
|
807
|
+
console.log(` Config: ${CONFIG_DIR}`);
|
|
808
|
+
|
|
809
|
+
// Vaults
|
|
810
|
+
console.log(` Vaults: ${vaults.length}`);
|
|
811
|
+
for (const name of vaults) {
|
|
812
|
+
const config = readVaultConfig(name);
|
|
813
|
+
const desc = config?.description ? ` — ${config.description}` : "";
|
|
814
|
+
console.log(` ${name}${desc}`);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Triggers
|
|
818
|
+
console.log();
|
|
819
|
+
if (globalConfig.triggers?.length) {
|
|
820
|
+
console.log(` Triggers: ${globalConfig.triggers.length}`);
|
|
821
|
+
for (const t of globalConfig.triggers) {
|
|
822
|
+
console.log(` ${t.name} → ${t.action.webhook}`);
|
|
823
|
+
}
|
|
824
|
+
} else {
|
|
825
|
+
console.log(` Triggers: none configured`);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Quick health check if daemon is running
|
|
829
|
+
if (loaded) {
|
|
830
|
+
try {
|
|
831
|
+
const resp = await fetch(`http://127.0.0.1:${globalConfig.port}/health`);
|
|
832
|
+
if (resp.ok) {
|
|
833
|
+
console.log(`\n Health: ok`);
|
|
834
|
+
}
|
|
835
|
+
} catch {
|
|
836
|
+
console.log(`\n Health: daemon loaded but not responding`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ---------------------------------------------------------------------------
|
|
842
|
+
// Import / Export
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
|
|
845
|
+
async function cmdImport(args: string[]) {
|
|
846
|
+
// Parse flags
|
|
847
|
+
let format = "obsidian";
|
|
848
|
+
let vaultName = "default";
|
|
849
|
+
let sourcePath = "";
|
|
850
|
+
let dryRun = false;
|
|
851
|
+
|
|
852
|
+
const positional: string[] = [];
|
|
853
|
+
for (let i = 0; i < args.length; i++) {
|
|
854
|
+
if (args[i] === "--format") {
|
|
855
|
+
format = args[++i];
|
|
856
|
+
} else if (args[i] === "--vault") {
|
|
857
|
+
vaultName = args[++i];
|
|
858
|
+
} else if (args[i] === "--dry-run") {
|
|
859
|
+
dryRun = true;
|
|
860
|
+
} else if (args[i] === "--obsidian") {
|
|
861
|
+
format = "obsidian";
|
|
862
|
+
} else {
|
|
863
|
+
positional.push(args[i]);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
sourcePath = positional[0] ?? "";
|
|
867
|
+
|
|
868
|
+
if (!sourcePath) {
|
|
869
|
+
console.error("Usage: parachute vault import <path> [--vault <name>] [--dry-run]");
|
|
870
|
+
console.error("\nImports an Obsidian vault into Parachute Vault.");
|
|
871
|
+
console.error("\nOptions:");
|
|
872
|
+
console.error(" --vault <name> Target vault (default: 'default')");
|
|
873
|
+
console.error(" --dry-run Show what would be imported without importing");
|
|
874
|
+
process.exit(1);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const { resolve: resolvePath } = await import("path");
|
|
878
|
+
const fullPath = resolvePath(sourcePath);
|
|
879
|
+
|
|
880
|
+
if (!existsSync(fullPath)) {
|
|
881
|
+
console.error(`Path not found: ${fullPath}`);
|
|
882
|
+
process.exit(1);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Verify vault exists
|
|
886
|
+
const config = readVaultConfig(vaultName);
|
|
887
|
+
if (!config) {
|
|
888
|
+
console.error(`Vault "${vaultName}" not found. Run: parachute vault create ${vaultName}`);
|
|
889
|
+
process.exit(1);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const { parseObsidianVault } = await import("../core/src/obsidian.ts");
|
|
893
|
+
const { getVaultStore } = await import("./vault-store.ts");
|
|
894
|
+
|
|
895
|
+
console.log(`Parsing Obsidian vault: ${fullPath}`);
|
|
896
|
+
const { notes, errors } = parseObsidianVault(fullPath);
|
|
897
|
+
|
|
898
|
+
if (errors.length > 0) {
|
|
899
|
+
console.error(`\n${errors.length} file(s) failed to parse:`);
|
|
900
|
+
for (const err of errors.slice(0, 10)) {
|
|
901
|
+
console.error(` ${err.path}: ${err.error}`);
|
|
902
|
+
}
|
|
903
|
+
if (errors.length > 10) console.error(` ... and ${errors.length - 10} more`);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
console.log(`Found ${notes.length} notes`);
|
|
907
|
+
|
|
908
|
+
// Collect all unique tags
|
|
909
|
+
const allTags = new Set<string>();
|
|
910
|
+
for (const note of notes) {
|
|
911
|
+
for (const tag of note.tags) allTags.add(tag);
|
|
912
|
+
}
|
|
913
|
+
console.log(`Tags: ${allTags.size} unique (${[...allTags].slice(0, 10).join(", ")}${allTags.size > 10 ? "..." : ""})`);
|
|
914
|
+
|
|
915
|
+
if (dryRun) {
|
|
916
|
+
console.log("\n[Dry run] Would import:");
|
|
917
|
+
for (const note of notes.slice(0, 20)) {
|
|
918
|
+
const tagStr = note.tags.length > 0 ? ` [${note.tags.join(", ")}]` : "";
|
|
919
|
+
console.log(` ${note.path}${tagStr}`);
|
|
920
|
+
}
|
|
921
|
+
if (notes.length > 20) console.log(` ... and ${notes.length - 20} more`);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Import into vault — use createNoteRaw to skip per-note wikilink sync,
|
|
926
|
+
// then do a single pass after all notes are imported (much faster for large vaults).
|
|
927
|
+
const store = getVaultStore(vaultName);
|
|
928
|
+
let imported = 0;
|
|
929
|
+
let skipped = 0;
|
|
930
|
+
|
|
931
|
+
for (const note of notes) {
|
|
932
|
+
// Skip if a note with this path already exists
|
|
933
|
+
const existing = store.getNoteByPath(note.path);
|
|
934
|
+
if (existing) {
|
|
935
|
+
skipped++;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Build metadata from frontmatter (excluding tags, already extracted)
|
|
940
|
+
const metadata = Object.keys(note.frontmatter).length > 0 ? note.frontmatter : undefined;
|
|
941
|
+
|
|
942
|
+
store.createNoteRaw(note.content, {
|
|
943
|
+
path: note.path,
|
|
944
|
+
tags: note.tags.length > 0 ? note.tags : undefined,
|
|
945
|
+
metadata: metadata as Record<string, unknown>,
|
|
946
|
+
});
|
|
947
|
+
imported++;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Single-pass wikilink sync after all notes exist
|
|
951
|
+
console.log(`\nImported ${imported} notes into vault "${vaultName}"`);
|
|
952
|
+
if (skipped > 0) console.log(`Skipped ${skipped} notes (path already exists)`);
|
|
953
|
+
|
|
954
|
+
if (imported > 0) {
|
|
955
|
+
const linkResult = store.syncAllWikilinks();
|
|
956
|
+
console.log(`Resolved ${linkResult.totalAdded} wikilinks across ${linkResult.synced} notes.`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function cmdExport(args: string[]) {
|
|
961
|
+
let vaultName = "default";
|
|
962
|
+
let outputPath = "";
|
|
963
|
+
|
|
964
|
+
const positional: string[] = [];
|
|
965
|
+
for (let i = 0; i < args.length; i++) {
|
|
966
|
+
if (args[i] === "--vault") {
|
|
967
|
+
vaultName = args[++i];
|
|
968
|
+
} else {
|
|
969
|
+
positional.push(args[i]);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
outputPath = positional[0] ?? "";
|
|
973
|
+
|
|
974
|
+
if (!outputPath) {
|
|
975
|
+
console.error("Usage: parachute vault export <output-path> [--vault <name>]");
|
|
976
|
+
console.error("\nExports a Parachute Vault as Obsidian-compatible markdown files.");
|
|
977
|
+
process.exit(1);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const { resolve: resolvePath } = await import("path");
|
|
981
|
+
const { mkdirSync: mkdir, writeFileSync: writeFile } = await import("fs");
|
|
982
|
+
const { join, dirname } = await import("path");
|
|
983
|
+
const fullPath = resolvePath(outputPath);
|
|
984
|
+
|
|
985
|
+
const config = readVaultConfig(vaultName);
|
|
986
|
+
if (!config) {
|
|
987
|
+
console.error(`Vault "${vaultName}" not found.`);
|
|
988
|
+
process.exit(1);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const { toObsidianMarkdown, exportFilePath } = await import("../core/src/obsidian.ts");
|
|
992
|
+
const { getVaultStore } = await import("./vault-store.ts");
|
|
993
|
+
|
|
994
|
+
const store = getVaultStore(vaultName);
|
|
995
|
+
const notes = store.queryNotes({ limit: 100000, sort: "asc" });
|
|
996
|
+
|
|
997
|
+
console.log(`Exporting ${notes.length} notes from vault "${vaultName}" to ${fullPath}`);
|
|
998
|
+
mkdir(fullPath, { recursive: true });
|
|
999
|
+
|
|
1000
|
+
let exported = 0;
|
|
1001
|
+
for (const note of notes) {
|
|
1002
|
+
const filePath = exportFilePath(note);
|
|
1003
|
+
const fullFilePath = join(fullPath, filePath);
|
|
1004
|
+
const dir = dirname(fullFilePath);
|
|
1005
|
+
mkdir(dir, { recursive: true });
|
|
1006
|
+
|
|
1007
|
+
const markdown = toObsidianMarkdown(note);
|
|
1008
|
+
writeFile(fullFilePath, markdown);
|
|
1009
|
+
exported++;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
console.log(`Exported ${exported} notes as markdown files.`);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// ---------------------------------------------------------------------------
|
|
1016
|
+
// Helpers
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
function createVault(name: string): string {
|
|
1020
|
+
const config: VaultConfig = {
|
|
1021
|
+
name,
|
|
1022
|
+
api_keys: [],
|
|
1023
|
+
created_at: new Date().toISOString(),
|
|
1024
|
+
};
|
|
1025
|
+
writeVaultConfig(config);
|
|
1026
|
+
|
|
1027
|
+
// Create a pvt_ token in the vault's DB
|
|
1028
|
+
const store = getVaultStore(name);
|
|
1029
|
+
const { fullToken } = generateToken();
|
|
1030
|
+
createToken(store.db, fullToken, { label: "default", permission: "full" });
|
|
1031
|
+
return fullToken;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function installMcpConfig(apiKey?: string) {
|
|
1035
|
+
const claudeJsonPath = resolve(homedir(), ".claude.json");
|
|
1036
|
+
let config: any = {};
|
|
1037
|
+
if (existsSync(claudeJsonPath)) {
|
|
1038
|
+
try {
|
|
1039
|
+
config = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
|
|
1040
|
+
} catch {}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
1044
|
+
|
|
1045
|
+
const globalConfig = readGlobalConfig();
|
|
1046
|
+
const port = globalConfig.port || DEFAULT_PORT;
|
|
1047
|
+
|
|
1048
|
+
// Clean up old per-vault stdio entries
|
|
1049
|
+
for (const key of Object.keys(config.mcpServers)) {
|
|
1050
|
+
if (key.startsWith("parachute-vault/")) {
|
|
1051
|
+
delete config.mcpServers[key];
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Single HTTP MCP entry — use per-vault endpoint so pvt_ tokens work
|
|
1056
|
+
const defaultVault = globalConfig.default_vault || "default";
|
|
1057
|
+
const mcpEntry: Record<string, unknown> = {
|
|
1058
|
+
type: "http",
|
|
1059
|
+
url: `http://127.0.0.1:${port}/vaults/${defaultVault}/mcp`,
|
|
1060
|
+
};
|
|
1061
|
+
if (apiKey) {
|
|
1062
|
+
mcpEntry.headers = { Authorization: `Bearer ${apiKey}` };
|
|
1063
|
+
}
|
|
1064
|
+
config.mcpServers["parachute-vault"] = mcpEntry;
|
|
1065
|
+
|
|
1066
|
+
writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2) + "\n");
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function removeMcpConfig() {
|
|
1070
|
+
const claudeJsonPath = resolve(homedir(), ".claude.json");
|
|
1071
|
+
if (!existsSync(claudeJsonPath)) return;
|
|
1072
|
+
try {
|
|
1073
|
+
const config = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
|
|
1074
|
+
delete config.mcpServers?.["parachute-vault"];
|
|
1075
|
+
// Also clean up any old per-vault entries
|
|
1076
|
+
for (const key of Object.keys(config.mcpServers ?? {})) {
|
|
1077
|
+
if (key.startsWith("parachute-vault/")) {
|
|
1078
|
+
delete config.mcpServers[key];
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
writeFileSync(claudeJsonPath, JSON.stringify(config, null, 2) + "\n");
|
|
1082
|
+
} catch {}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function usage() {
|
|
1086
|
+
console.log(`
|
|
1087
|
+
Parachute Vault — self-hosted knowledge graph
|
|
1088
|
+
|
|
1089
|
+
Setup:
|
|
1090
|
+
parachute vault init Set up everything (one command)
|
|
1091
|
+
parachute vault status Check what's running
|
|
1092
|
+
|
|
1093
|
+
Vaults:
|
|
1094
|
+
parachute vault create <name> Create a new vault
|
|
1095
|
+
parachute vault list List all vaults
|
|
1096
|
+
parachute vault remove <name> [--yes] Remove a vault
|
|
1097
|
+
parachute vault mcp-install Add vault MCP to Claude
|
|
1098
|
+
|
|
1099
|
+
Tokens:
|
|
1100
|
+
parachute vault tokens List all tokens
|
|
1101
|
+
parachute vault tokens create Create a full-access token in the default vault
|
|
1102
|
+
parachute vault tokens create --vault <name> Create a token in a specific vault
|
|
1103
|
+
parachute vault tokens create --read Read-only token
|
|
1104
|
+
parachute vault tokens create --label x Set a label
|
|
1105
|
+
parachute vault tokens create --expires 30d Expiring token
|
|
1106
|
+
parachute vault tokens revoke <token-id> Revoke a token (default vault)
|
|
1107
|
+
|
|
1108
|
+
OAuth:
|
|
1109
|
+
parachute vault set-password Set/change the owner password (for consent page)
|
|
1110
|
+
parachute vault set-password --clear Remove the owner password
|
|
1111
|
+
parachute vault 2fa status Show 2FA state
|
|
1112
|
+
parachute vault 2fa enroll Enable TOTP 2FA (QR + backup codes)
|
|
1113
|
+
parachute vault 2fa disable Disable 2FA (requires password)
|
|
1114
|
+
parachute vault 2fa backup-codes Regenerate backup codes
|
|
1115
|
+
|
|
1116
|
+
Config:
|
|
1117
|
+
parachute vault config Show current configuration
|
|
1118
|
+
parachute vault config set <key> <val> Set a config value
|
|
1119
|
+
parachute vault config unset <key> Remove a config value
|
|
1120
|
+
|
|
1121
|
+
Import/Export:
|
|
1122
|
+
parachute vault import <path> Import an Obsidian vault
|
|
1123
|
+
parachute vault import <path> --dry-run Preview import without writing
|
|
1124
|
+
parachute vault export <path> Export vault as Obsidian markdown
|
|
1125
|
+
|
|
1126
|
+
Server:
|
|
1127
|
+
parachute vault serve Run server (foreground)
|
|
1128
|
+
parachute vault logs Stream server logs
|
|
1129
|
+
parachute vault restart Restart the daemon
|
|
1130
|
+
`);
|
|
1131
|
+
}
|