@openparachute/vault 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +9 -5
  3. package/core/src/core.test.ts +2252 -7
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +801 -67
  6. package/core/src/note-schemas.ts +232 -0
  7. package/core/src/notes.ts +313 -35
  8. package/core/src/obsidian.ts +3 -3
  9. package/core/src/paths.ts +1 -1
  10. package/core/src/query-operators.ts +23 -7
  11. package/core/src/schema-defaults.ts +287 -0
  12. package/core/src/schema.ts +393 -9
  13. package/core/src/store.ts +248 -6
  14. package/core/src/tag-hierarchy.ts +137 -0
  15. package/core/src/tag-schemas.ts +242 -42
  16. package/core/src/types.ts +100 -6
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +231 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +144 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +384 -78
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +296 -0
  30. package/src/hub-jwt.ts +79 -0
  31. package/src/init-summary.test.ts +133 -0
  32. package/src/init-summary.ts +90 -0
  33. package/src/init.test.ts +216 -0
  34. package/src/mcp-http.ts +30 -28
  35. package/src/mcp-install.ts +1 -1
  36. package/src/mcp-tools.ts +294 -6
  37. package/src/module-config.ts +1 -1
  38. package/src/oauth.test.ts +345 -0
  39. package/src/oauth.ts +85 -14
  40. package/src/owner-auth.ts +57 -1
  41. package/src/prompt.ts +31 -14
  42. package/src/routes.ts +686 -58
  43. package/src/routing.test.ts +466 -1
  44. package/src/routing.ts +108 -24
  45. package/src/scopes.test.ts +66 -8
  46. package/src/scopes.ts +163 -37
  47. package/src/server.ts +24 -2
  48. package/src/services-manifest.test.ts +20 -0
  49. package/src/services-manifest.ts +9 -2
  50. package/src/stop-signal.test.ts +85 -0
  51. package/src/storage.test.ts +92 -0
  52. package/src/tag-scope.ts +118 -0
  53. package/src/token-store.test.ts +47 -0
  54. package/src/token-store.ts +128 -13
  55. package/src/tokens-routes.test.ts +720 -0
  56. package/src/tokens-routes.ts +392 -0
  57. package/src/transcription-worker.test.ts +5 -0
  58. package/src/triggers.ts +1 -1
  59. package/src/two-factor.ts +2 -2
  60. package/src/vault-create.test.ts +193 -0
  61. package/src/vault-name.test.ts +123 -0
  62. package/src/vault-name.ts +80 -0
  63. package/src/vault.test.ts +868 -3
  64. package/tsconfig.json +8 -1
  65. package/.claude/settings.local.json +0 -8
  66. package/.dockerignore +0 -8
  67. package/.env.example +0 -9
  68. package/CHANGELOG.md +0 -175
  69. package/CLAUDE.md +0 -125
  70. package/Caddyfile +0 -3
  71. package/Dockerfile +0 -22
  72. package/bun.lock +0 -219
  73. package/bunfig.toml +0 -2
  74. package/deploy/parachute-vault.service +0 -20
  75. package/docker-compose.yml +0 -50
  76. package/docs/HTTP_API.md +0 -434
  77. package/docs/auth-model.md +0 -340
  78. package/fly.toml +0 -24
  79. package/package/package.json +0 -32
  80. package/railway.json +0 -14
  81. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  82. package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/cli.ts CHANGED
@@ -37,6 +37,8 @@ import {
37
37
  loadEnvFile,
38
38
  listVaults,
39
39
  vaultDir,
40
+ vaultDbPath,
41
+ vaultConfigPath,
40
42
  DEFAULT_PORT,
41
43
  CONFIG_DIR,
42
44
  ASSETS_DIR,
@@ -44,11 +46,13 @@ import {
44
46
  LOG_PATH,
45
47
  ERR_PATH,
46
48
  GLOBAL_CONFIG_PATH,
49
+ stopSignalPath,
47
50
  } from "./config.ts";
48
51
  import type { VaultConfig } from "./config.ts";
49
52
  import { DATA_DIR } from "./config.ts";
50
53
  import { installAgent, uninstallAgent, isAgentLoaded, restartAgent } from "./launchd.ts";
51
54
  import { chooseMcpUrl } from "./mcp-install.ts";
55
+ import { buildInitSummaryLines } from "./init-summary.ts";
52
56
  import {
53
57
  runBackup,
54
58
  readLastBackup,
@@ -81,6 +85,7 @@ import { resolveBindHostname } from "./bind.ts";
81
85
  import { generateToken, createToken, listTokens, revokeToken, migrateVaultKeys } from "./token-store.ts";
82
86
  import type { TokenPermission } from "./token-store.ts";
83
87
  import { resolveCreateTokenFlags, VAULT_SCOPES } from "./scopes.ts";
88
+ import { validateVaultName, decideInitVaultName } from "./vault-name.ts";
84
89
  import { getVaultStore } from "./vault-store.ts";
85
90
  import { upsertService, ServicesManifestError } from "./services-manifest.ts";
86
91
  import {
@@ -177,6 +182,9 @@ switch (command) {
177
182
  case "restart":
178
183
  await cmdRestart();
179
184
  break;
185
+ case "stop":
186
+ await cmdStop();
187
+ break;
180
188
  case "uninstall":
181
189
  await cmdUninstall(cmdArgs);
182
190
  break;
@@ -218,6 +226,27 @@ switch (command) {
218
226
  // Command implementations
219
227
  // ---------------------------------------------------------------------------
220
228
 
229
+ /**
230
+ * Compute the `paths` array for the parachute-vault entry in services.json.
231
+ * One entry advertises every vault on this server; `paths[0]` is the
232
+ * canonical mount the hub stamps into `.well-known/parachute.json`, so the
233
+ * default vault sorts first when one is set. With no vaults yet, fall back
234
+ * to "/" so an early-init registration is still well-formed.
235
+ */
236
+ function buildVaultServicePaths(
237
+ defaultVault: string | undefined,
238
+ vaults: string[],
239
+ ): string[] {
240
+ if (vaults.length === 0) return ["/"];
241
+ if (defaultVault && vaults.includes(defaultVault)) {
242
+ return [
243
+ `/vault/${defaultVault}`,
244
+ ...vaults.filter((v) => v !== defaultVault).map((v) => `/vault/${v}`),
245
+ ];
246
+ }
247
+ return vaults.map((v) => `/vault/${v}`);
248
+ }
249
+
221
250
  async function cmdInit(args: string[] = []) {
222
251
  ensureConfigDirSync();
223
252
 
@@ -225,8 +254,37 @@ async function cmdInit(args: string[] = []) {
225
254
  // --no-mcp skips it without prompting. If both passed, --no-mcp wins
226
255
  // (safer default). Neither → prompt in a TTY, default-yes in a
227
256
  // non-TTY for back-compat with existing piped install scripts.
257
+ //
258
+ // --token / --no-token follow the same pattern for whether the API
259
+ // token is surfaced to the user at the end of init (for pasting into
260
+ // other MCP clients, scripts, or curl).
261
+ //
262
+ // --vault-name <name> skips the name prompt for non-interactive installs
263
+ // (validated up front; exits non-zero on invalid input).
228
264
  const flagMcpOn = args.includes("--mcp");
229
265
  const flagMcpOff = args.includes("--no-mcp");
266
+ const flagTokenOn = args.includes("--token");
267
+ const flagTokenOff = args.includes("--no-token");
268
+ // --autostart / --no-autostart toggle daemon registration. Default is on
269
+ // (preserves historical behavior). When --no-autostart is passed, init
270
+ // skips registering with launchd / systemd AND removes any prior
271
+ // registration — for CI, dev sandboxes, Docker, or any environment where
272
+ // another supervisor manages the process. --no-autostart wins over
273
+ // --autostart on the same command line (safer-default precedence).
274
+ const flagAutostartOn = args.includes("--autostart");
275
+ const flagAutostartOff = args.includes("--no-autostart");
276
+
277
+ const nameDecision = decideInitVaultName(args, {
278
+ isTTY: !!process.stdin.isTTY,
279
+ });
280
+ if (nameDecision.kind === "error") {
281
+ console.error(nameDecision.message);
282
+ process.exit(1);
283
+ }
284
+ // Whether the user explicitly supplied --vault-name. We use this both to
285
+ // pick the chosen name on first init AND to print a friendly notice if
286
+ // they pass --vault-name on a re-run where vaults already exist.
287
+ const vaultNameFlagSupplied = args.indexOf("--vault-name") !== -1;
230
288
 
231
289
  const isMac = process.platform === "darwin";
232
290
  const isLinux = process.platform === "linux";
@@ -234,14 +292,23 @@ async function cmdInit(args: string[] = []) {
234
292
 
235
293
  console.log("Parachute Vault — self-hosted knowledge graph\n");
236
294
 
237
- // 1. Create default vault if none exist
295
+ // 1. Create the vault if none exist. The name is decided here — flag wins,
296
+ // else prompt in a TTY (default "default"), else fall back to "default" so
297
+ // piped installs keep working unchanged.
238
298
  const vaults = listVaults();
239
299
  let apiKey: string | undefined;
240
300
  if (vaults.length === 0) {
241
- console.log("Creating default vault...");
242
- apiKey = createVault("default");
243
- console.log(" Created vault: default");
301
+ const chosenName =
302
+ nameDecision.kind === "name" ? nameDecision.name : await promptVaultName();
303
+ console.log(`Creating vault "${chosenName}"...`);
304
+ apiKey = createVault(chosenName);
305
+ console.log(` Created vault: ${chosenName}`);
244
306
  } else {
307
+ if (vaultNameFlagSupplied) {
308
+ console.log(
309
+ ` --vault-name ignored: ${vaults.length} vault(s) already exist. Use \`parachute-vault create\` to add another.`,
310
+ );
311
+ }
245
312
  console.log(`Found ${vaults.length} existing vault(s)`);
246
313
  }
247
314
 
@@ -272,30 +339,28 @@ async function cmdInit(args: string[] = []) {
272
339
  }
273
340
  writeGlobalConfig(globalConfig);
274
341
 
275
- // 2a. Register in the shared services manifest so the @openparachute/cli
342
+ // 2a. Register in the shared services manifest so the @openparachute/hub
276
343
  // dispatcher can discover this service and its health endpoint. Upserts
277
344
  // by name, preserving entries for other services. Non-fatal on failure —
278
345
  // init can complete without the manifest, just with a warning.
279
346
  //
280
- // `paths[0]` is the canonical mount point — CLI uses it for the
281
- // `.well-known/parachute.json` URL and for `parachute expose`. Advertise
282
- // `/vault/<default_vault>` so MCP clients land at the scoped endpoint.
283
- // When no default vault exists yet (multi-vault, no fallback), fall back
284
- // to "/" the CLI can detect and prompt.
285
- const servicePath = globalConfig.default_vault
286
- ? `/vault/${globalConfig.default_vault}`
287
- : "/";
347
+ // `paths[0]` is the canonical mount point — the hub uses it for the
348
+ // `.well-known/parachute.json` URL and for `parachute expose`, so the
349
+ // default vault always sorts first. Remaining vaults follow so the hub
350
+ // well-known and paraclaw's attach picker see every vault on this server.
351
+ // Re-running init re-registers the full set; that doubles as the
352
+ // recovery path for installs whose services.json is stale (#208).
288
353
  try {
289
354
  upsertService({
290
355
  name: "parachute-vault",
291
356
  port: globalConfig.port || DEFAULT_PORT,
292
- paths: [servicePath],
357
+ paths: buildVaultServicePaths(globalConfig.default_vault, allVaults),
293
358
  health: "/health",
294
359
  version: pkg.version,
295
360
  });
296
361
  } catch (err) {
297
362
  const msg = err instanceof ServicesManifestError ? err.message : String(err);
298
- console.log(` Warning: could not update ~/.parachute/services.json: ${msg}`);
363
+ console.error(` Warning: could not update ~/.parachute/services.json: ${msg}`);
299
364
  }
300
365
 
301
366
  // 2b. Migrate existing legacy keys into per-vault token tables
@@ -325,28 +390,67 @@ async function cmdInit(args: string[] = []) {
325
390
  console.log();
326
391
  }
327
392
 
328
- // 5b. Offer to set an owner password for OAuth consent, unless one is already set.
393
+ // 5b. Owner password is only needed for OAuth consent (browser-based
394
+ // clients like claude.ai / ChatGPT / Claude Desktop). Those paths are
395
+ // coming in the next few weeks; until then, skip the prompt. Users who
396
+ // want to expose the vault publicly today can set one manually via
397
+ // `parachute-vault set-password`.
329
398
  if (!hasOwnerPassword()) {
330
- await promptForOwnerPassword("Set an owner password for OAuth consent?");
399
+ console.log();
400
+ console.log("Public exposure + web-AI connectors (claude.ai, ChatGPT, etc.) are coming soon.");
401
+ console.log(" When you're ready to expose this vault publicly, run:");
402
+ console.log(" parachute-vault set-password # required for OAuth consent");
331
403
  }
332
404
 
333
405
  // 6. Install daemon (platform-aware). Idempotent — safe to re-run after
334
406
  // a folder move; this refreshes ~/.parachute/server-path and bounces the
335
407
  // daemon so the new location takes effect immediately.
336
- console.log("Installing daemon...");
408
+ //
409
+ // Autostart precedence (resolved here so the user's prior config is
410
+ // honored on re-runs that don't pass a flag):
411
+ // 1. --no-autostart on this run → false (and persisted)
412
+ // 2. --autostart on this run → true (and persisted)
413
+ // 3. Existing config.autostart → that value
414
+ // 4. Default → true (historical behavior)
415
+ // When false: skip register AND uninstall any prior registration so the
416
+ // flag's intent ("don't auto-start / don't auto-restart") matches reality
417
+ // even if a previous run had registered a daemon.
418
+ let autostartEnabled: boolean;
419
+ if (flagAutostartOff) autostartEnabled = false;
420
+ else if (flagAutostartOn) autostartEnabled = true;
421
+ else if (typeof globalConfig.autostart === "boolean") autostartEnabled = globalConfig.autostart;
422
+ else autostartEnabled = true;
423
+
424
+ if (flagAutostartOff || flagAutostartOn) {
425
+ globalConfig.autostart = autostartEnabled;
426
+ writeGlobalConfig(globalConfig);
427
+ }
428
+
337
429
  let serverPath: string | null = null;
338
- if (isMac) {
339
- ({ serverPath } = await installAgent());
340
- } else if (isLinux && isSystemdAvailable()) {
341
- ({ serverPath } = await installSystemdService());
430
+ if (!autostartEnabled) {
431
+ console.log("Autostart disabled skipping daemon registration.");
432
+ if (isMac) {
433
+ await uninstallAgent();
434
+ } else if (isLinux && isSystemdAvailable()) {
435
+ await uninstallSystemdService();
436
+ }
437
+ console.log(" To run vault: parachute-vault serve (or use your own supervisor)");
438
+ console.log(" To re-enable: parachute-vault init --autostart");
342
439
  } else {
343
- console.log(" Auto-start not available on this platform.");
344
- console.log(" Run manually: bun src/server.ts");
345
- console.log(" Or use Docker: docker compose up -d");
346
- }
347
- if (serverPath) {
348
- console.log(` Server path: ${serverPath}`);
349
- console.log(` Wrapper: ~/.parachute/vault/start.sh`);
440
+ console.log("Installing daemon...");
441
+ if (isMac) {
442
+ ({ serverPath } = await installAgent());
443
+ } else if (isLinux && isSystemdAvailable()) {
444
+ ({ serverPath } = await installSystemdService());
445
+ } else {
446
+ console.log(" Auto-start not available on this platform.");
447
+ console.log(" Run manually: bun src/server.ts");
448
+ console.log(" Or use Docker: docker compose up -d");
449
+ }
450
+ if (serverPath) {
451
+ console.log(` Server path: ${serverPath}`);
452
+ console.log(` Wrapper: ~/.parachute/vault/start.sh`);
453
+ }
350
454
  }
351
455
  const bindHost = resolveBindHostname(process.env);
352
456
  console.log(` Listening on http://${bindHost}:${globalConfig.port || DEFAULT_PORT}`);
@@ -361,11 +465,43 @@ async function cmdInit(args: string[] = []) {
361
465
  } else if (flagMcpOn) {
362
466
  addMcp = true;
363
467
  } else if (process.stdin.isTTY) {
364
- addMcp = await confirm("Add Vault MCP to Claude Code (~/.claude.json)?", true);
468
+ addMcp = await confirm("Install Vault as an MCP server in Claude Code (~/.claude.json)?", true);
365
469
  } else {
366
470
  addMcp = true; // non-interactive: preserve the installable-via-pipe default
367
471
  }
368
472
 
473
+ // 7b. Surface an API token for other clients? (Codex, Goose, OpenCode,
474
+ // Cursor, Zed, Cline, scripts, curl.) Same flag/TTY precedence as MCP.
475
+ // Note: a token is always minted when addMcp is true (it gets baked into
476
+ // the ~/.claude.json entry); this prompt controls whether that token is
477
+ // printed prominently at the end so the user can paste it elsewhere.
478
+ let addToken: boolean;
479
+ if (flagTokenOff) {
480
+ addToken = false;
481
+ } else if (flagTokenOn) {
482
+ addToken = true;
483
+ } else if (process.stdin.isTTY) {
484
+ addToken = await confirm(
485
+ "Generate an API token for other MCP clients (Codex, Goose, OpenCode, Cursor, Zed, Cline), scripts, or curl?",
486
+ true,
487
+ );
488
+ } else {
489
+ addToken = true; // non-interactive: default-yes matches addMcp default
490
+ }
491
+
492
+ // Mint a token if we need one (for the claude.json entry and/or for
493
+ // prominent display) and don't already have one from vault creation.
494
+ // Re-runs of init that opt in will mint a fresh token — old tokens
495
+ // continue to work; the user can `tokens revoke` the unused ones.
496
+ const defaultVault = globalConfig.default_vault || "default";
497
+ const needToken = addMcp || addToken;
498
+ if (needToken && !apiKey) {
499
+ const store = getVaultStore(defaultVault);
500
+ const { fullToken } = generateToken();
501
+ createToken(store.db, fullToken, { label: "init", permission: "full" });
502
+ apiKey = fullToken;
503
+ }
504
+
369
505
  if (addMcp) {
370
506
  installMcpConfig(apiKey);
371
507
  console.log(` MCP server added to ~/.claude.json`);
@@ -375,30 +511,31 @@ async function cmdInit(args: string[] = []) {
375
511
  }
376
512
 
377
513
  // 8. Summary
378
- console.log("\n---");
379
514
  const port = globalConfig.port || DEFAULT_PORT;
380
- if (apiKey) {
381
- console.log(`\nYour API token: ${apiKey}`);
382
- console.log(" Use this in Claude Desktop, curl, or any client.");
383
- console.log(" Pass via: Authorization: Bearer <token>");
384
- console.log(" Or via: X-API-Key: <token>");
385
- console.log("\nSave this — it will not be shown again.");
386
- }
515
+ const mcpUrl = `http://127.0.0.1:${port}/vault/${defaultVault}/mcp`;
516
+ const lines = buildInitSummaryLines({
517
+ addMcp,
518
+ addToken,
519
+ apiKey,
520
+ configDir: CONFIG_DIR,
521
+ bindHost,
522
+ port,
523
+ mcpUrl,
524
+ });
525
+ for (const line of lines) console.log(line);
526
+ }
387
527
 
388
- console.log(`\nConfig: ${CONFIG_DIR}`);
389
- console.log(`Server: http://${bindHost}:${port}`);
390
528
 
391
- console.log(`\nUsage examples:`);
392
- console.log(` curl http://localhost:${port}/health`);
393
- if (apiKey) {
394
- console.log(` curl -H "Authorization: Bearer ${apiKey}" http://localhost:${port}/api/notes`);
529
+ async function promptVaultName(): Promise<string> {
530
+ while (true) {
531
+ const answer = await ask("What would you like to call this vault?", "default");
532
+ const v = validateVaultName(answer);
533
+ if (v.ok) return v.name;
534
+ console.log(` ${v.error}`);
395
535
  }
396
-
397
- console.log(`\nNext steps:`);
398
- console.log(` parachute-vault status check everything is running`);
399
- console.log(` parachute-vault config view/edit configuration`);
400
536
  }
401
537
 
538
+
402
539
  async function promptForOwnerPassword(purpose: string): Promise<boolean> {
403
540
  console.log(`\n${purpose}`);
404
541
  console.log(" Used on the OAuth consent page to authorize third-party clients");
@@ -628,9 +765,20 @@ async function cmd2fa(args: string[]) {
628
765
  }
629
766
 
630
767
  function cmdCreate(args: string[]) {
631
- const name = args[0];
768
+ // --json: emit a single machine-readable object on stdout instead of the
769
+ // human-friendly multi-line print. Designed for orchestrators (the hub's
770
+ // POST /vaults shells out to this CLI and parses stdout). Errors still go
771
+ // to stderr as plain text and exit nonzero — callers branch on exit code.
772
+ const jsonMode = args.includes("--json");
773
+ // Greedy strip of any `--*` token to recover the positional vault name.
774
+ // Today only `--json` is recognized; any other `--foo` is silently dropped.
775
+ // If a future flag (e.g. `--force`, `--dry-run`) is added, the parsing
776
+ // here needs to whitelist it — otherwise an invalid flag becomes a silent
777
+ // no-op rather than a usage error.
778
+ const positional = args.filter((a) => !a.startsWith("--"));
779
+ const name = positional[0];
632
780
  if (!name) {
633
- console.error("Usage: parachute-vault create <name>");
781
+ console.error("Usage: parachute-vault create <name> [--json]");
634
782
  process.exit(1);
635
783
  }
636
784
 
@@ -663,14 +811,49 @@ function cmdCreate(args: string[]) {
663
811
  const needsDefault = !globalConfig.default_vault
664
812
  || !listVaults().includes(globalConfig.default_vault);
665
813
  let defaultNote: string | null = null;
814
+ let setAsDefault = false;
666
815
  if (needsDefault) {
667
816
  globalConfig.default_vault = name;
668
817
  writeGlobalConfig(globalConfig);
818
+ setAsDefault = true;
669
819
  defaultNote = wasFirst
670
820
  ? `Set as default vault (unscoped routes will target "${name}")`
671
821
  : `Set as default vault (previous default was missing)`;
672
822
  }
673
823
 
824
+ // Re-register in services.json so the hub well-known and paraclaw's
825
+ // attach picker see this vault. cmdInit registers on first run; cmdCreate
826
+ // adds the new path on every subsequent vault. Without this, vaults
827
+ // created after init were invisible to the hub (#208).
828
+ // Warnings go to stderr to keep --json stdout clean for the orchestrator.
829
+ try {
830
+ upsertService({
831
+ name: "parachute-vault",
832
+ port: globalConfig.port || DEFAULT_PORT,
833
+ paths: buildVaultServicePaths(globalConfig.default_vault, listVaults()),
834
+ health: "/health",
835
+ version: pkg.version,
836
+ });
837
+ } catch (err) {
838
+ const msg = err instanceof ServicesManifestError ? err.message : String(err);
839
+ console.error(`Warning: could not update ~/.parachute/services.json: ${msg}`);
840
+ }
841
+
842
+ if (jsonMode) {
843
+ const payload = {
844
+ name,
845
+ token: key,
846
+ paths: {
847
+ vault_dir: vaultDir(name),
848
+ vault_db: vaultDbPath(name),
849
+ vault_config: vaultConfigPath(name),
850
+ },
851
+ set_as_default: setAsDefault,
852
+ };
853
+ console.log(JSON.stringify(payload));
854
+ return;
855
+ }
856
+
674
857
  console.log(`Vault "${name}" created.`);
675
858
  console.log(` Path: ${vaultDir(name)}`);
676
859
  console.log(` API token: ${key}`);
@@ -815,20 +998,38 @@ async function cmdConfig(args: string[]) {
815
998
  function cmdTokens(args: string[]) {
816
999
  const subcmd = args[0];
817
1000
 
818
- // parachute-vault tokens list all tokens (across all vaults)
1001
+ // parachute-vault tokens [list] [--vault <name>]
1002
+ // Default: every vault's tokens, grouped by vault.
1003
+ // --vault <name>: only that vault.
819
1004
  if (!subcmd || subcmd === "list") {
820
- const vaults = listVaults();
1005
+ const vaultFlag = args.indexOf("--vault");
1006
+ const onlyVault = vaultFlag !== -1 ? args[vaultFlag + 1] : null;
1007
+ if (vaultFlag !== -1 && !onlyVault) {
1008
+ console.error("--vault requires a value.");
1009
+ process.exit(1);
1010
+ }
1011
+ const vaults = onlyVault ? [onlyVault] : listVaults();
821
1012
  let anyTokens = false;
822
1013
 
823
1014
  for (const vaultName of vaults) {
824
1015
  const vc = readVaultConfig(vaultName);
825
- if (!vc) continue;
1016
+ if (!vc) {
1017
+ if (onlyVault) {
1018
+ console.error(`Vault "${vaultName}" not found.`);
1019
+ process.exit(1);
1020
+ }
1021
+ continue;
1022
+ }
826
1023
  const store = getVaultStore(vaultName);
827
1024
  // Ensure legacy keys are migrated
828
1025
  const globalCfg = readGlobalConfig();
829
1026
  migrateVaultKeys(store.db, vc.api_keys, globalCfg.api_keys);
830
1027
 
831
- const tokens = listTokens(store.db);
1028
+ // Per-vault filter (v16): only tokens bound to this vault, plus
1029
+ // legacy NULL-bound (server-wide) rows. The `[server-wide]` annotation
1030
+ // surfaces the latter so an operator listing one vault still sees
1031
+ // tokens that authenticate cross-vault.
1032
+ const tokens = listTokens(store.db, { vaultName });
832
1033
  if (tokens.length === 0) continue;
833
1034
  anyTokens = true;
834
1035
 
@@ -836,7 +1037,8 @@ function cmdTokens(args: string[]) {
836
1037
  for (const t of tokens) {
837
1038
  const expiry = t.expires_at ? ` (expires: ${t.expires_at})` : "";
838
1039
  const lastUsed = t.last_used_at ? ` (last used: ${t.last_used_at})` : "";
839
- console.log(` ${t.id} ${t.label} [${t.permission}]${expiry}${lastUsed}`);
1040
+ const serverWide = t.vault_name === null ? " [server-wide]" : "";
1041
+ console.log(` ${t.id} ${t.label} [${t.permission}]${serverWide}${expiry}${lastUsed}`);
840
1042
  }
841
1043
  console.log();
842
1044
  }
@@ -847,12 +1049,26 @@ function cmdTokens(args: string[]) {
847
1049
  return;
848
1050
  }
849
1051
 
850
- // parachute-vault tokens create --vault <name>
1052
+ // parachute-vault tokens create [--vault <name> | --all]
851
1053
  // [--scope vault:read,vault:write | --read | --permission full|read]
852
1054
  // [--expires <duration>] [--label <label>]
1055
+ //
1056
+ // Per-vault binding (v16): the minted token is pinned to <vaultName>
1057
+ // unless --all is passed, in which case the token is server-wide
1058
+ // (vault_name = NULL) and authenticates against any vault. --all is
1059
+ // the explicit opt-out — there's no implicit fall-through to server-wide.
853
1060
  if (subcmd === "create") {
854
1061
  const vaultFlag = args.indexOf("--vault");
1062
+ const allFlag = args.includes("--all");
1063
+ if (allFlag && vaultFlag !== -1) {
1064
+ console.error("--vault and --all are mutually exclusive.");
1065
+ process.exit(1);
1066
+ }
855
1067
  const vaultName = vaultFlag !== -1 ? args[vaultFlag + 1] : (readGlobalConfig().default_vault || "default");
1068
+ if (!vaultName) {
1069
+ console.error("--vault requires a value.");
1070
+ process.exit(1);
1071
+ }
856
1072
 
857
1073
  const vc = readVaultConfig(vaultName);
858
1074
  if (!vc) {
@@ -876,6 +1092,10 @@ function cmdTokens(args: string[]) {
876
1092
  let expiresAt: string | null = null;
877
1093
  if (expiresFlag !== -1) {
878
1094
  const dur = args[expiresFlag + 1];
1095
+ if (!dur) {
1096
+ console.error("--expires requires a value (e.g. 7d, 30d, 24h, 1y).");
1097
+ process.exit(1);
1098
+ }
879
1099
  expiresAt = parseDuration(dur);
880
1100
  if (!expiresAt) {
881
1101
  console.error(`Invalid duration: ${dur}. Use format like 7d, 30d, 24h, 1y.`);
@@ -884,7 +1104,7 @@ function cmdTokens(args: string[]) {
884
1104
  }
885
1105
 
886
1106
  const labelFlag = args.indexOf("--label");
887
- const label = labelFlag !== -1 ? args[labelFlag + 1] : "default";
1107
+ const label = (labelFlag !== -1 ? args[labelFlag + 1] : undefined) ?? "default";
888
1108
 
889
1109
  const store = getVaultStore(vaultName);
890
1110
  const { fullToken } = generateToken();
@@ -893,15 +1113,22 @@ function cmdTokens(args: string[]) {
893
1113
  permission,
894
1114
  scopes,
895
1115
  expires_at: expiresAt,
1116
+ // v16 binding: pin to the vault we minted in unless --all was passed
1117
+ // (which leaves vault_name NULL = legacy server-wide).
1118
+ vault_name: allFlag ? null : vaultName,
896
1119
  });
897
1120
 
898
1121
  const displayScopes = scopes ?? [...VAULT_SCOPES];
899
- console.log(`Created token for vault "${vaultName}":`);
1122
+ const heading = allFlag
1123
+ ? `Created server-wide token (authenticates against any vault):`
1124
+ : `Created token for vault "${vaultName}":`;
1125
+ console.log(heading);
900
1126
  console.log(` Token: ${fullToken}`);
901
1127
  console.log(` Permission: ${permission}`);
902
1128
  console.log(` Scopes: ${displayScopes.join(" ")}`);
903
1129
  if (expiresAt) console.log(` Expires: ${expiresAt}`);
904
1130
  console.log(` Label: ${label}`);
1131
+ if (!allFlag) console.log(` Vault: ${vaultName}`);
905
1132
  console.log();
906
1133
  console.log("Save this token — it will not be shown again.");
907
1134
  return;
@@ -917,6 +1144,10 @@ function cmdTokens(args: string[]) {
917
1144
 
918
1145
  const vaultFlag = args.indexOf("--vault");
919
1146
  const vaultName = vaultFlag !== -1 ? args[vaultFlag + 1] : (readGlobalConfig().default_vault || "default");
1147
+ if (!vaultName) {
1148
+ console.error("--vault requires a value.");
1149
+ process.exit(1);
1150
+ }
920
1151
 
921
1152
  const vc = readVaultConfig(vaultName);
922
1153
  if (!vc) {
@@ -942,8 +1173,8 @@ function cmdTokens(args: string[]) {
942
1173
  function parseDuration(dur: string): string | null {
943
1174
  const match = dur.match(/^(\d+)(h|d|w|m|y)$/);
944
1175
  if (!match) return null;
945
- const n = parseInt(match[1], 10);
946
- const unit = match[2];
1176
+ const n = parseInt(match[1]!, 10);
1177
+ const unit = match[2]!;
947
1178
  const now = new Date();
948
1179
  switch (unit) {
949
1180
  case "h": now.setHours(now.getHours() + n); break;
@@ -1001,6 +1232,41 @@ async function cmdRestart() {
1001
1232
  process.exit(1);
1002
1233
  }
1003
1234
 
1235
+ async function cmdStop() {
1236
+ loadEnvFile();
1237
+ const port = readGlobalConfig().port || DEFAULT_PORT;
1238
+ const sentinel = stopSignalPath();
1239
+
1240
+ // Health check first: avoid leaving a stale sentinel that would kill the
1241
+ // *next* server boot. The server clears any pre-existing sentinel on
1242
+ // startup, but only after it loads — a sentinel written between launch
1243
+ // and the first poll could still win the race. Skipping the write when
1244
+ // nothing is listening is the cheap, obvious guard.
1245
+ const health = await checkHealth(port);
1246
+ if (health.status === "not-listening" || health.status === "error") {
1247
+ console.log(`Vault is not running (${health.status}${health.error ? `: ${health.error}` : ""}).`);
1248
+ return;
1249
+ }
1250
+
1251
+ ensureConfigDirSync();
1252
+ writeFileSync(sentinel, `${new Date().toISOString()}\n`);
1253
+ console.log(`Stop signal written: ${sentinel}`);
1254
+
1255
+ // Wait briefly for the server to pick up the sentinel and stop responding.
1256
+ // Polls match the server's 500ms cadence; give it ~5s before giving up.
1257
+ const start = Date.now();
1258
+ while (Date.now() - start < 5_000) {
1259
+ await new Promise((r) => setTimeout(r, 600));
1260
+ const h = await checkHealth(port);
1261
+ if (h.status === "not-listening" || h.status === "error") {
1262
+ console.log(`Vault stopped (${Math.round((Date.now() - start) / 100) / 10}s).`);
1263
+ return;
1264
+ }
1265
+ }
1266
+ console.error("Vault did not stop within 5s. Check vault logs or `parachute-vault status`.");
1267
+ process.exit(1);
1268
+ }
1269
+
1004
1270
  async function cmdStatus() {
1005
1271
  loadEnvFile();
1006
1272
  const globalConfig = readGlobalConfig();
@@ -1819,16 +2085,27 @@ async function cmdImport(args: string[]) {
1819
2085
 
1820
2086
  const positional: string[] = [];
1821
2087
  for (let i = 0; i < args.length; i++) {
1822
- if (args[i] === "--format") {
1823
- format = args[++i];
1824
- } else if (args[i] === "--vault") {
1825
- vaultName = args[++i];
1826
- } else if (args[i] === "--dry-run") {
2088
+ const arg = args[i]!;
2089
+ if (arg === "--format") {
2090
+ const v = args[++i];
2091
+ if (!v) {
2092
+ console.error("--format requires a value.");
2093
+ process.exit(1);
2094
+ }
2095
+ format = v;
2096
+ } else if (arg === "--vault") {
2097
+ const v = args[++i];
2098
+ if (!v) {
2099
+ console.error("--vault requires a value.");
2100
+ process.exit(1);
2101
+ }
2102
+ vaultName = v;
2103
+ } else if (arg === "--dry-run") {
1827
2104
  dryRun = true;
1828
- } else if (args[i] === "--obsidian") {
2105
+ } else if (arg === "--obsidian") {
1829
2106
  format = "obsidian";
1830
2107
  } else {
1831
- positional.push(args[i]);
2108
+ positional.push(arg);
1832
2109
  }
1833
2110
  }
1834
2111
  sourcePath = positional[0] ?? "";
@@ -1931,10 +2208,16 @@ async function cmdExport(args: string[]) {
1931
2208
 
1932
2209
  const positional: string[] = [];
1933
2210
  for (let i = 0; i < args.length; i++) {
1934
- if (args[i] === "--vault") {
1935
- vaultName = args[++i];
2211
+ const arg = args[i]!;
2212
+ if (arg === "--vault") {
2213
+ const v = args[++i];
2214
+ if (!v) {
2215
+ console.error("--vault requires a value.");
2216
+ process.exit(1);
2217
+ }
2218
+ vaultName = v;
1936
2219
  } else {
1937
- positional.push(args[i]);
2220
+ positional.push(arg);
1938
2221
  }
1939
2222
  }
1940
2223
  outputPath = positional[0] ?? "";
@@ -2058,7 +2341,7 @@ function usage() {
2058
2341
  console.log(`
2059
2342
  Parachute Vault — self-hosted knowledge graph
2060
2343
 
2061
- If you installed via the Parachute CLI, prefer the wrapper commands for
2344
+ If you installed via the Parachute Hub, prefer the wrapper commands for
2062
2345
  lifecycle — \`parachute start vault\`, \`parachute stop vault\`,
2063
2346
  \`parachute status\` — and use the vault-direct commands below for setup,
2064
2347
  data, and debugging.
@@ -2066,7 +2349,22 @@ data, and debugging.
2066
2349
  ── Standard use ───────────────────────────────────────────────────────
2067
2350
 
2068
2351
  Setup:
2069
- parachute-vault init [--mcp | --no-mcp] Set up everything (one command, idempotent)
2352
+ parachute-vault init [--mcp|--no-mcp] [--token|--no-token] [--vault-name <name>]
2353
+ [--autostart|--no-autostart]
2354
+ Set up everything (one command, idempotent).
2355
+ --mcp/--no-mcp controls the Claude Code MCP entry;
2356
+ --token/--no-token controls whether an API token is
2357
+ printed for pasting into other MCP clients / scripts.
2358
+ --vault-name skips the prompt and names the vault
2359
+ (lowercase alphanumeric, hyphens, underscores;
2360
+ omit to be prompted interactively, default "default").
2361
+ --autostart (default) registers vault with launchd /
2362
+ systemd so it starts on boot AND auto-restarts on
2363
+ crash. --no-autostart skips daemon registration AND
2364
+ uninstalls any prior registration — for CI, dev
2365
+ sandboxes, Docker, or environments where another
2366
+ supervisor manages the process. Persists in
2367
+ config.yaml as 'autostart: true|false'.
2070
2368
  parachute-vault doctor Diagnose install/config issues
2071
2369
  parachute-vault uninstall [--wipe] [--yes]
2072
2370
  Remove daemon + MCP entry; --wipe also removes vaults, .env,
@@ -2076,15 +2374,19 @@ Setup:
2076
2374
  parachute --version Print the installed version (alias: -v, version)
2077
2375
 
2078
2376
  Vaults:
2079
- parachute-vault create <name> Create a new vault
2377
+ parachute-vault create <name> [--json] Create a new vault (--json: emit { name, token, paths, set_as_default })
2080
2378
  parachute-vault list List all vaults
2081
2379
  parachute-vault remove <name> [--yes] Remove a vault
2082
2380
  parachute-vault mcp-install Add vault MCP to Claude
2083
2381
 
2084
2382
  Tokens:
2085
- parachute-vault tokens List all tokens
2086
- parachute-vault tokens create Create a full-access token in the default vault
2087
- parachute-vault tokens create --vault <name> Create a token in a specific vault
2383
+ parachute-vault tokens List tokens (every vault)
2384
+ parachute-vault tokens list --vault <name> List tokens for one vault only
2385
+ parachute-vault tokens create Create a vault-bound token in the default vault
2386
+ parachute-vault tokens create --vault <name> Create a token bound to a specific vault
2387
+ parachute-vault tokens create --all Create a server-wide token (vault_name=NULL).
2388
+ Authenticates against any vault — use sparingly,
2389
+ for cross-vault automation only.
2088
2390
  parachute-vault tokens create --read Read-only token (shorthand for --scope vault:read)
2089
2391
  parachute-vault tokens create --scope vault:write
2090
2392
  Narrow the token's scopes. Accepts a comma-separated
@@ -2119,9 +2421,9 @@ Import/Export:
2119
2421
 
2120
2422
  ── Advanced / standalone ──────────────────────────────────────────────
2121
2423
 
2122
- Direct daemon controls. For normal use, prefer the Parachute CLI wrappers
2424
+ Direct daemon controls. For normal use, prefer the Parachute Hub wrappers
2123
2425
  — they add PID tracking, log rotation, and cross-service \`parachute status\`
2124
- visibility. Use these when running vault without the CLI or when debugging.
2426
+ visibility. Use these when running vault without the hub or when debugging.
2125
2427
 
2126
2428
  parachute-vault serve Run server in the foreground (no PID tracking).
2127
2429
  Prefer \`parachute start vault\` for managed lifecycle.
@@ -2129,5 +2431,9 @@ visibility. Use these when running vault without the CLI or when debugging.
2129
2431
  Prefer \`parachute status\` for a cross-service view.
2130
2432
  parachute-vault logs Stream server logs
2131
2433
  parachute-vault restart Restart the daemon
2434
+ parachute-vault stop Signal a graceful shutdown of the running server
2435
+ (writes \`~/.parachute/vault/stop.signal\` — useful
2436
+ when no signal channel is available, e.g. Docker
2437
+ exec or unmanaged foreground runs).
2132
2438
  `);
2133
2439
  }