@openparachute/vault 0.2.4 → 0.3.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 +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -19
- package/core/src/hooks.ts +111 -3
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +153 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +95 -1
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +28 -1
- package/docs/HTTP_API.md +105 -1
- package/docs/auth-model.md +340 -0
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/bind.test.ts +28 -0
- package/src/bind.ts +19 -0
- package/src/cli.ts +228 -133
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +294 -0
- package/src/scopes.ts +253 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +73 -9
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +864 -0
- package/src/transcription-worker.ts +501 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -16
package/src/config.ts
CHANGED
|
@@ -2,25 +2,41 @@
|
|
|
2
2
|
* Configuration management for Parachute Vault.
|
|
3
3
|
*
|
|
4
4
|
* Directory layout:
|
|
5
|
-
* ~/.parachute/
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* ~/.parachute/ — ecosystem root (shared with sibling services)
|
|
6
|
+
* services.json — CLI-owned manifest (services-manifest.ts)
|
|
7
|
+
* well-known/ — CLI-owned (.well-known serving)
|
|
8
|
+
* vault/ — everything vault owns
|
|
9
|
+
* .env
|
|
10
|
+
* config.yaml — global server config
|
|
11
|
+
* start.sh / server-path — daemon wrapper + pointer (daemon.ts)
|
|
12
|
+
* logs/
|
|
13
|
+
* vault.log / vault.err — daemon stdout/stderr (matches
|
|
14
|
+
* `~/.parachute/<svc>/logs/<svc>.log` — the
|
|
15
|
+
* CLI lifecycle convention from PR #83)
|
|
16
|
+
* data/ — per-vault SQLite data (Postgres-style:
|
|
17
|
+
* named `data/` rather than `vaults/` so it
|
|
18
|
+
* doesn't read as doubled)
|
|
19
|
+
* {name}/
|
|
20
|
+
* vault.db — SQLite database
|
|
21
|
+
* vault.yaml — per-vault config (description, api_keys, …)
|
|
22
|
+
* assets/ — per-vault attachments
|
|
23
|
+
*
|
|
24
|
+
* Pre-0.3 installs put vault state directly under `~/.parachute/`; on startup
|
|
25
|
+
* we auto-migrate those paths into `vault/` (see `migrateFromLegacyLayout`).
|
|
26
|
+
* Pre-filesystem-hygiene 0.3 installs put per-vault state under
|
|
27
|
+
* `vault/vaults/` and daemon logs flat in `vault/`; those are moved into
|
|
28
|
+
* `data/` and `logs/` by `migrateVaultInternalLayout` on startup.
|
|
12
29
|
*/
|
|
13
30
|
|
|
14
31
|
import { homedir } from "os";
|
|
15
32
|
import { join } from "path";
|
|
16
|
-
import {
|
|
17
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
|
|
33
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from "fs";
|
|
18
34
|
import crypto from "node:crypto";
|
|
19
35
|
|
|
20
36
|
// ---------------------------------------------------------------------------
|
|
21
37
|
// Paths
|
|
22
38
|
//
|
|
23
|
-
// Historical note: the exported `CONFIG_DIR`, `
|
|
39
|
+
// Historical note: the exported `CONFIG_DIR`, `DATA_DIR`, etc. used to be
|
|
24
40
|
// `const` captured at module load. That made tests flaky: anything setting
|
|
25
41
|
// `process.env.PARACHUTE_HOME` after import would be ignored, and when `bun
|
|
26
42
|
// test` shares one process across files, whichever test loaded first froze
|
|
@@ -28,35 +44,50 @@ import crypto from "node:crypto";
|
|
|
28
44
|
// getters so `PARACHUTE_HOME` is re-read per call. The top-level constants
|
|
29
45
|
// are kept for backward-compat (other modules import them) and reflect the
|
|
30
46
|
// value at load time.
|
|
47
|
+
//
|
|
48
|
+
// `configDirPath()` is the ecosystem root — shared with sibling services
|
|
49
|
+
// (channel, scribe, …) and with the CLI's `services.json` + `well-known/`.
|
|
50
|
+
// `vaultHomePath()` is the vault-scoped subdir; all vault-owned files live
|
|
51
|
+
// under it.
|
|
31
52
|
// ---------------------------------------------------------------------------
|
|
32
53
|
|
|
33
54
|
function configDirPath(): string {
|
|
34
55
|
return process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
|
|
35
56
|
}
|
|
36
57
|
|
|
37
|
-
function
|
|
38
|
-
return join(configDirPath(), "
|
|
58
|
+
function vaultHomePath(): string {
|
|
59
|
+
return join(configDirPath(), "vault");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function dataDirPath(): string {
|
|
63
|
+
return join(vaultHomePath(), "data");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function logsDirPath(): string {
|
|
67
|
+
return join(vaultHomePath(), "logs");
|
|
39
68
|
}
|
|
40
69
|
|
|
41
70
|
function globalConfigPath(): string {
|
|
42
|
-
return join(
|
|
71
|
+
return join(vaultHomePath(), "config.yaml");
|
|
43
72
|
}
|
|
44
73
|
|
|
45
74
|
function envFilePath(): string {
|
|
46
|
-
return join(
|
|
75
|
+
return join(vaultHomePath(), ".env");
|
|
47
76
|
}
|
|
48
77
|
|
|
49
78
|
export const CONFIG_DIR = configDirPath();
|
|
50
|
-
export const
|
|
51
|
-
export const
|
|
52
|
-
export const
|
|
53
|
-
export const
|
|
54
|
-
export const
|
|
79
|
+
export const VAULT_HOME = join(CONFIG_DIR, "vault");
|
|
80
|
+
export const DATA_DIR = join(VAULT_HOME, "data");
|
|
81
|
+
export const LOGS_DIR = join(VAULT_HOME, "logs");
|
|
82
|
+
export const GLOBAL_CONFIG_PATH = join(VAULT_HOME, "config.yaml");
|
|
83
|
+
export const ENV_PATH = join(VAULT_HOME, ".env");
|
|
84
|
+
export const LOG_PATH = join(LOGS_DIR, "vault.log");
|
|
85
|
+
export const ERR_PATH = join(LOGS_DIR, "vault.err");
|
|
55
86
|
export const DEFAULT_PORT = 1940;
|
|
56
|
-
export const ASSETS_DIR = join(
|
|
87
|
+
export const ASSETS_DIR = join(VAULT_HOME, "assets");
|
|
57
88
|
|
|
58
89
|
export function vaultDir(name: string): string {
|
|
59
|
-
return join(
|
|
90
|
+
return join(dataDirPath(), name);
|
|
60
91
|
}
|
|
61
92
|
|
|
62
93
|
export function vaultDbPath(name: string): string {
|
|
@@ -101,6 +132,29 @@ export interface VaultConfig {
|
|
|
101
132
|
tag_schemas?: Record<string, TagSchema>;
|
|
102
133
|
/** Tag name that marks a note as publicly viewable. Default: "published". */
|
|
103
134
|
published_tag?: string;
|
|
135
|
+
/**
|
|
136
|
+
* What to do with the audio file on disk once the worker is done with it.
|
|
137
|
+
* - `"keep"` (default): leave the file on disk.
|
|
138
|
+
* - `"until_transcribed"`: unlink once the transcript lands successfully;
|
|
139
|
+
* on failure the file is kept so the user can retry or re-upload.
|
|
140
|
+
* - `"never"`: unlink whenever the worker reaches a terminal state
|
|
141
|
+
* (`done` OR `failed`). Audio is discarded even if transcription
|
|
142
|
+
* failed — users who opt in accept that losing a bad transcription
|
|
143
|
+
* also loses the source audio.
|
|
144
|
+
*
|
|
145
|
+
* In every mode the attachment row (including any stored transcript) is
|
|
146
|
+
* preserved; only the file on disk is affected.
|
|
147
|
+
*/
|
|
148
|
+
audio_retention?: "keep" | "until_transcribed" | "never";
|
|
149
|
+
/**
|
|
150
|
+
* Transcription worker settings for this vault. Today only `context` is
|
|
151
|
+
* honored — a list of context predicates the worker attaches to each
|
|
152
|
+
* transcription POST so scribe sees person/project context alongside the
|
|
153
|
+
* audio. Same shape as triggers' `action.include_context`.
|
|
154
|
+
*/
|
|
155
|
+
transcription?: {
|
|
156
|
+
context?: TriggerIncludeContext[];
|
|
157
|
+
};
|
|
104
158
|
}
|
|
105
159
|
|
|
106
160
|
// ---------------------------------------------------------------------------
|
|
@@ -131,6 +185,20 @@ export interface TriggerWhen {
|
|
|
131
185
|
*/
|
|
132
186
|
export type TriggerSendMode = "json" | "attachment" | "content";
|
|
133
187
|
|
|
188
|
+
/**
|
|
189
|
+
* A single `include_context` entry — a query over the vault whose matching
|
|
190
|
+
* notes are serialized as context entries and included alongside the primary
|
|
191
|
+
* webhook payload. See `src/context.ts` for the fetch + serialization rules.
|
|
192
|
+
*/
|
|
193
|
+
export interface TriggerIncludeContext {
|
|
194
|
+
/** Tag the note must carry. Required. */
|
|
195
|
+
tag: string;
|
|
196
|
+
/** If set, notes also carrying this tag are excluded. */
|
|
197
|
+
exclude_tag?: string;
|
|
198
|
+
/** Metadata keys to surface on each resulting entry. */
|
|
199
|
+
include_metadata?: string[];
|
|
200
|
+
}
|
|
201
|
+
|
|
134
202
|
export interface TriggerAction {
|
|
135
203
|
/** URL to POST the webhook payload to. */
|
|
136
204
|
webhook: string;
|
|
@@ -138,6 +206,12 @@ export interface TriggerAction {
|
|
|
138
206
|
timeout?: number;
|
|
139
207
|
/** How to send data to the webhook. Default "json". */
|
|
140
208
|
send?: TriggerSendMode;
|
|
209
|
+
/**
|
|
210
|
+
* If present, the trigger pre-fetches the matching vault notes at fire
|
|
211
|
+
* time and attaches them as a `context` JSON part (send=attachment) or a
|
|
212
|
+
* top-level `context` field (send=json). send=content ignores this.
|
|
213
|
+
*/
|
|
214
|
+
include_context?: TriggerIncludeContext[];
|
|
141
215
|
}
|
|
142
216
|
|
|
143
217
|
export interface TriggerConfig {
|
|
@@ -265,6 +339,23 @@ function serializeVaultConfig(config: VaultConfig): string {
|
|
|
265
339
|
if (config.published_tag) {
|
|
266
340
|
lines.push(`published_tag: ${config.published_tag}`);
|
|
267
341
|
}
|
|
342
|
+
if (config.audio_retention) {
|
|
343
|
+
lines.push(`audio_retention: ${config.audio_retention}`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (config.transcription?.context?.length) {
|
|
347
|
+
lines.push("transcription:");
|
|
348
|
+
lines.push(" context:");
|
|
349
|
+
for (const entry of config.transcription.context) {
|
|
350
|
+
lines.push(` - tag: ${entry.tag}`);
|
|
351
|
+
if (entry.exclude_tag) {
|
|
352
|
+
lines.push(` exclude_tag: ${entry.exclude_tag}`);
|
|
353
|
+
}
|
|
354
|
+
if (entry.include_metadata?.length) {
|
|
355
|
+
lines.push(` include_metadata: [${entry.include_metadata.map((v) => `"${v}"`).join(", ")}]`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
268
359
|
|
|
269
360
|
lines.push("api_keys:");
|
|
270
361
|
for (const key of config.api_keys) {
|
|
@@ -320,6 +411,12 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
|
|
|
320
411
|
const pubTagMatch = yaml.match(/^published_tag:\s*(\S+)/m);
|
|
321
412
|
if (pubTagMatch) config.published_tag = pubTagMatch[1];
|
|
322
413
|
|
|
414
|
+
const retentionMatch = yaml.match(/^audio_retention:\s*(\S+)/m);
|
|
415
|
+
if (retentionMatch) {
|
|
416
|
+
const v = retentionMatch[1];
|
|
417
|
+
if (v === "keep" || v === "until_transcribed" || v === "never") config.audio_retention = v;
|
|
418
|
+
}
|
|
419
|
+
|
|
323
420
|
// Parse description (block scalar)
|
|
324
421
|
const descMatch = yaml.match(/^description:\s*\|\s*\n((?:\s{2}.+\n?)+)/m);
|
|
325
422
|
if (descMatch) {
|
|
@@ -358,9 +455,64 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
|
|
|
358
455
|
// Parse tag_schemas
|
|
359
456
|
config.tag_schemas = parseTagSchemas(yaml);
|
|
360
457
|
|
|
458
|
+
// Parse transcription.context (same shape as triggers' include_context)
|
|
459
|
+
const transcriptionContext = parseTranscriptionContext(yaml);
|
|
460
|
+
if (transcriptionContext) {
|
|
461
|
+
config.transcription = { context: transcriptionContext };
|
|
462
|
+
}
|
|
463
|
+
|
|
361
464
|
return config;
|
|
362
465
|
}
|
|
363
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Parse the `transcription: { context: [...] }` section from vault.yaml.
|
|
469
|
+
* Shape matches triggers' `action.include_context` so callers can reuse the
|
|
470
|
+
* same `ContextPredicate` helpers from src/context.ts.
|
|
471
|
+
*/
|
|
472
|
+
function parseTranscriptionContext(yaml: string): TriggerIncludeContext[] | undefined {
|
|
473
|
+
const startMatch = yaml.match(/^transcription:\s*$/m);
|
|
474
|
+
if (!startMatch) return undefined;
|
|
475
|
+
|
|
476
|
+
const startIdx = (startMatch.index ?? 0) + startMatch[0].length;
|
|
477
|
+
const lines = yaml.slice(startIdx).split("\n");
|
|
478
|
+
|
|
479
|
+
const entries: TriggerIncludeContext[] = [];
|
|
480
|
+
let inContext = false;
|
|
481
|
+
let current: TriggerIncludeContext | null = null;
|
|
482
|
+
|
|
483
|
+
const pushCurrent = () => {
|
|
484
|
+
if (current && current.tag) entries.push(current);
|
|
485
|
+
current = null;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
for (const line of lines) {
|
|
489
|
+
// Stop at next top-level key.
|
|
490
|
+
if (line.match(/^\S/) && line.trim().length > 0) break;
|
|
491
|
+
if (line.trim().length === 0) continue;
|
|
492
|
+
|
|
493
|
+
const trimmed = line.trim();
|
|
494
|
+
|
|
495
|
+
if (trimmed === "context:") { inContext = true; continue; }
|
|
496
|
+
if (!inContext) continue;
|
|
497
|
+
|
|
498
|
+
const itemStart = trimmed.match(/^-\s+(\w+):\s*(.*)$/);
|
|
499
|
+
if (itemStart) {
|
|
500
|
+
pushCurrent();
|
|
501
|
+
current = { tag: "" };
|
|
502
|
+
applyContextField(current, itemStart[1], itemStart[2]);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const fieldMatch = trimmed.match(/^(\w+):\s*(.*)$/);
|
|
506
|
+
if (fieldMatch && current) {
|
|
507
|
+
applyContextField(current, fieldMatch[1], fieldMatch[2]);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
pushCurrent();
|
|
513
|
+
return entries.length > 0 ? entries : undefined;
|
|
514
|
+
}
|
|
515
|
+
|
|
364
516
|
/**
|
|
365
517
|
* Parse the tag_schemas section from vault.yaml.
|
|
366
518
|
* Uses line-by-line indent tracking since the main parser is hand-rolled.
|
|
@@ -444,6 +596,25 @@ function parseYamlList(val: string): string[] {
|
|
|
444
596
|
return inner.split(",").map((s) => s.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean);
|
|
445
597
|
}
|
|
446
598
|
|
|
599
|
+
/**
|
|
600
|
+
* Apply a single "key: value" line to the include_context item being built.
|
|
601
|
+
* Shared between triggers and vault-config transcription.context (same shape).
|
|
602
|
+
*/
|
|
603
|
+
function applyContextField(
|
|
604
|
+
item: TriggerIncludeContext,
|
|
605
|
+
key: string,
|
|
606
|
+
raw: string,
|
|
607
|
+
): void {
|
|
608
|
+
const value = raw.trim();
|
|
609
|
+
if (key === "tag") { item.tag = value.replace(/^"(.*)"$/, "$1"); return; }
|
|
610
|
+
if (key === "exclude_tag") { item.exclude_tag = value.replace(/^"(.*)"$/, "$1"); return; }
|
|
611
|
+
if (key === "include_metadata") {
|
|
612
|
+
const listMatch = value.match(/^\[([^\]]*)\]/);
|
|
613
|
+
if (listMatch) item.include_metadata = parseYamlList(listMatch[1]);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
447
618
|
function parseTriggers(yaml: string): TriggerConfig[] | undefined {
|
|
448
619
|
const startMatch = yaml.match(/^triggers:\s*$/m);
|
|
449
620
|
if (!startMatch) return undefined;
|
|
@@ -454,7 +625,17 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
|
|
|
454
625
|
const triggers: TriggerConfig[] = [];
|
|
455
626
|
let current: Partial<TriggerConfig> | null = null;
|
|
456
627
|
// Track which section we're in by the last seen section header
|
|
457
|
-
let section: "top" | "when" | "action" = "top";
|
|
628
|
+
let section: "top" | "when" | "action" | "include_context" = "top";
|
|
629
|
+
// When inside include_context, track the item currently being parsed.
|
|
630
|
+
let currentContext: TriggerIncludeContext | null = null;
|
|
631
|
+
|
|
632
|
+
const pushContextItem = () => {
|
|
633
|
+
if (currentContext && current?.action) {
|
|
634
|
+
current.action.include_context = current.action.include_context ?? [];
|
|
635
|
+
current.action.include_context.push(currentContext);
|
|
636
|
+
}
|
|
637
|
+
currentContext = null;
|
|
638
|
+
};
|
|
458
639
|
|
|
459
640
|
for (const line of lines) {
|
|
460
641
|
// Stop at next top-level key
|
|
@@ -466,6 +647,7 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
|
|
|
466
647
|
// New trigger item: "- name: ..."
|
|
467
648
|
const nameMatch = trimmed.match(/^-\s+name:\s*(.+)/);
|
|
468
649
|
if (nameMatch) {
|
|
650
|
+
pushContextItem();
|
|
469
651
|
if (current?.name) {
|
|
470
652
|
if (current.action?.webhook) {
|
|
471
653
|
triggers.push(current as TriggerConfig);
|
|
@@ -481,8 +663,16 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
|
|
|
481
663
|
if (!current) continue;
|
|
482
664
|
|
|
483
665
|
// Section headers — detect by key name regardless of indent
|
|
484
|
-
if (trimmed === "when:") { section = "when"; continue; }
|
|
485
|
-
if (trimmed === "action:") { section = "action"; continue; }
|
|
666
|
+
if (trimmed === "when:") { pushContextItem(); section = "when"; continue; }
|
|
667
|
+
if (trimmed === "action:") { pushContextItem(); section = "action"; continue; }
|
|
668
|
+
if (trimmed === "include_context:") {
|
|
669
|
+
// Entering the nested list under action:.
|
|
670
|
+
if (!current.action) current.action = { webhook: "" } as TriggerAction;
|
|
671
|
+
current.action.include_context = current.action.include_context ?? [];
|
|
672
|
+
section = "include_context";
|
|
673
|
+
currentContext = null;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
486
676
|
|
|
487
677
|
// Top-level trigger field
|
|
488
678
|
const eventsMatch = trimmed.match(/^events:\s*\[([^\]]*)\]/);
|
|
@@ -521,8 +711,27 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
|
|
|
521
711
|
continue;
|
|
522
712
|
}
|
|
523
713
|
}
|
|
714
|
+
|
|
715
|
+
// include_context list items: "- tag: X" starts a new item; subsequent
|
|
716
|
+
// indented lines set fields on it.
|
|
717
|
+
if (section === "include_context") {
|
|
718
|
+
const itemStart = trimmed.match(/^-\s+(\w+):\s*(.*)$/);
|
|
719
|
+
if (itemStart) {
|
|
720
|
+
pushContextItem();
|
|
721
|
+
currentContext = { tag: "" };
|
|
722
|
+
applyContextField(currentContext, itemStart[1], itemStart[2]);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const fieldMatch = trimmed.match(/^(\w+):\s*(.*)$/);
|
|
726
|
+
if (fieldMatch && currentContext) {
|
|
727
|
+
applyContextField(currentContext, fieldMatch[1], fieldMatch[2]);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
524
731
|
}
|
|
525
732
|
|
|
733
|
+
pushContextItem();
|
|
734
|
+
|
|
526
735
|
// Push the last trigger
|
|
527
736
|
if (current?.name) {
|
|
528
737
|
if (current.action?.webhook) {
|
|
@@ -713,14 +922,174 @@ function serializeBackup(backup: BackupConfig): string[] {
|
|
|
713
922
|
// Directory management
|
|
714
923
|
// ---------------------------------------------------------------------------
|
|
715
924
|
|
|
716
|
-
export async function ensureConfigDir(): Promise<void> {
|
|
717
|
-
await mkdir(configDirPath(), { recursive: true });
|
|
718
|
-
await mkdir(vaultsDirPath(), { recursive: true });
|
|
719
|
-
}
|
|
720
|
-
|
|
721
925
|
export function ensureConfigDirSync(): void {
|
|
722
926
|
mkdirSync(configDirPath(), { recursive: true });
|
|
723
|
-
|
|
927
|
+
migrateFromLegacyLayout();
|
|
928
|
+
mkdirSync(vaultHomePath(), { recursive: true });
|
|
929
|
+
migrateVaultInternalLayout();
|
|
930
|
+
mkdirSync(dataDirPath(), { recursive: true });
|
|
931
|
+
mkdirSync(logsDirPath(), { recursive: true });
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Move vault-owned state from the legacy root layout (`~/.parachute/.env`,
|
|
936
|
+
* `~/.parachute/vaults/`, …) into `~/.parachute/vault/`. Fresh installs see
|
|
937
|
+
* nothing to migrate and exit quickly; double-calls are a no-op.
|
|
938
|
+
*
|
|
939
|
+
* Per-path move policy: if a legacy path exists AND the target under `vault/`
|
|
940
|
+
* does not, rename the legacy path into place. If both exist, the target
|
|
941
|
+
* wins — we don't overwrite a user's manually-migrated state — and the
|
|
942
|
+
* legacy path is left alone with a warning logged. Each moved path is
|
|
943
|
+
* announced on stdout so users notice when vault relocates their files.
|
|
944
|
+
*/
|
|
945
|
+
export function migrateFromLegacyLayout(): void {
|
|
946
|
+
const root = configDirPath();
|
|
947
|
+
const dest = vaultHomePath();
|
|
948
|
+
|
|
949
|
+
// Pre-0.3 installs targeted flat names at root (`vaults`, `vault.log`);
|
|
950
|
+
// we now land those directly under their current canonical subdirs
|
|
951
|
+
// (`data/`, `logs/`) so upgrading users skip the intermediate shape that
|
|
952
|
+
// `migrateVaultInternalLayout` would otherwise correct on a second pass.
|
|
953
|
+
const candidates: Array<[string, string]> = [
|
|
954
|
+
[".env", ".env"],
|
|
955
|
+
["config.yaml", "config.yaml"],
|
|
956
|
+
["vault.log", "logs/vault.log"],
|
|
957
|
+
["vault.err", "logs/vault.err"],
|
|
958
|
+
["start.sh", "start.sh"],
|
|
959
|
+
["server-path", "server-path"],
|
|
960
|
+
["vaults", "data"],
|
|
961
|
+
["assets", "assets"],
|
|
962
|
+
];
|
|
963
|
+
|
|
964
|
+
const present: Array<[string, string]> = [];
|
|
965
|
+
for (const [from, to] of candidates) {
|
|
966
|
+
const src = join(root, from);
|
|
967
|
+
if (existsSync(src)) present.push([from, to]);
|
|
968
|
+
}
|
|
969
|
+
if (present.length === 0) return;
|
|
970
|
+
|
|
971
|
+
mkdirSync(dest, { recursive: true });
|
|
972
|
+
|
|
973
|
+
const moved: string[] = [];
|
|
974
|
+
const skipped: string[] = [];
|
|
975
|
+
for (const [from, to] of present) {
|
|
976
|
+
const src = join(root, from);
|
|
977
|
+
const dst = join(dest, to);
|
|
978
|
+
if (existsSync(dst)) {
|
|
979
|
+
skipped.push(from);
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
// Target may live in a subdir (logs/, data/); ensure parent exists
|
|
983
|
+
// before renameSync, which is strict about target parent existence.
|
|
984
|
+
const parent = join(dst, "..");
|
|
985
|
+
mkdirSync(parent, { recursive: true });
|
|
986
|
+
try {
|
|
987
|
+
renameSync(src, dst);
|
|
988
|
+
moved.push(from);
|
|
989
|
+
} catch (err) {
|
|
990
|
+
console.warn(formatMigrationFailure(src, dst, err));
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Log to stderr — migration is operational/audit output, and keeping
|
|
995
|
+
// stdout clean lets callers that pipe stdout (the CLI, spawned child
|
|
996
|
+
// processes, JSON-consuming shells) run without interference.
|
|
997
|
+
if (moved.length > 0) {
|
|
998
|
+
console.error(
|
|
999
|
+
`[parachute-vault] migrated to new layout: moved ${moved.map((p) => join(root, p)).join(", ")} → ${dest}/`,
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
if (skipped.length > 0) {
|
|
1003
|
+
console.error(
|
|
1004
|
+
`[parachute-vault] left legacy paths in place (target already exists under vault/): ${skipped.map((p) => join(root, p)).join(", ")}. Remove the legacy copies once you've confirmed the vault/ copies are current.`,
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Format a migration-rename failure warning. If the underlying error is
|
|
1011
|
+
* EXDEV (cross-device rename — `PARACHUTE_HOME` straddles a mount, a common
|
|
1012
|
+
* shape in Docker with bind-mounts or multi-disk dev setups), the raw error
|
|
1013
|
+
* message is opaque. Surface the likely cause and note that vault continues
|
|
1014
|
+
* on the legacy layout rather than exiting.
|
|
1015
|
+
*/
|
|
1016
|
+
export function formatMigrationFailure(src: string, dst: string, err: unknown): string {
|
|
1017
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1018
|
+
const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined;
|
|
1019
|
+
if (code === "EXDEV") {
|
|
1020
|
+
return `[parachute-vault] migration failed for ${src} → ${dst}: likely because PARACHUTE_HOME crosses a mount boundary (EXDEV). Vault will continue on the legacy layout; move the file manually to complete the upgrade.`;
|
|
1021
|
+
}
|
|
1022
|
+
return `[parachute-vault] failed to migrate ${src} → ${dst}: ${msg}`;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Tidies the layout *inside* `vault/` for installs upgrading across the
|
|
1027
|
+
* filesystem-hygiene refactor:
|
|
1028
|
+
*
|
|
1029
|
+
* vault/vaults/ → vault/data/ (matches Postgres/Redis convention;
|
|
1030
|
+
* avoids the doubled "vault/vaults")
|
|
1031
|
+
* vault/vault.log → vault/logs/vault.log
|
|
1032
|
+
* vault/vault.err → vault/logs/vault.err
|
|
1033
|
+
*
|
|
1034
|
+
* Same target-wins, idempotent, rename-only policy as
|
|
1035
|
+
* `migrateFromLegacyLayout`. Runs every boot — once the moves have
|
|
1036
|
+
* happened, subsequent calls are pure existence checks that exit fast.
|
|
1037
|
+
*
|
|
1038
|
+
* If `vault/` doesn't exist yet (never booted before), this returns
|
|
1039
|
+
* immediately — `ensureConfigDirSync` creates the fresh layout right after.
|
|
1040
|
+
*/
|
|
1041
|
+
export function migrateVaultInternalLayout(): void {
|
|
1042
|
+
const vaultHome = vaultHomePath();
|
|
1043
|
+
if (!existsSync(vaultHome)) return;
|
|
1044
|
+
|
|
1045
|
+
// vault/vaults/ → vault/data/
|
|
1046
|
+
const legacyData = join(vaultHome, "vaults");
|
|
1047
|
+
const newData = dataDirPath();
|
|
1048
|
+
if (existsSync(legacyData)) {
|
|
1049
|
+
if (existsSync(newData)) {
|
|
1050
|
+
console.error(
|
|
1051
|
+
`[parachute-vault] both ${legacyData}/ and ${newData}/ exist — using data/, leaving vaults/ in place. Remove the legacy copy once you've confirmed data/ is current.`,
|
|
1052
|
+
);
|
|
1053
|
+
} else {
|
|
1054
|
+
try {
|
|
1055
|
+
renameSync(legacyData, newData);
|
|
1056
|
+
console.error(`[parachute-vault] migrated ${legacyData}/ → ${newData}/`);
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
console.warn(formatMigrationFailure(`${legacyData}/`, `${newData}/`, err));
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// vault/{vault.log,vault.err} → vault/logs/{vault.log,vault.err}
|
|
1064
|
+
const logsDir = logsDirPath();
|
|
1065
|
+
const logsMoved: string[] = [];
|
|
1066
|
+
const logsSkipped: string[] = [];
|
|
1067
|
+
for (const name of ["vault.log", "vault.err"]) {
|
|
1068
|
+
const src = join(vaultHome, name);
|
|
1069
|
+
if (!existsSync(src)) continue;
|
|
1070
|
+
const dst = join(logsDir, name);
|
|
1071
|
+
if (existsSync(dst)) {
|
|
1072
|
+
logsSkipped.push(name);
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
mkdirSync(logsDir, { recursive: true });
|
|
1076
|
+
try {
|
|
1077
|
+
renameSync(src, dst);
|
|
1078
|
+
logsMoved.push(name);
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
console.warn(formatMigrationFailure(src, dst, err));
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (logsMoved.length > 0) {
|
|
1084
|
+
console.error(
|
|
1085
|
+
`[parachute-vault] migrated ${logsMoved.map((n) => join(vaultHome, n)).join(", ")} → ${logsDir}/`,
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
if (logsSkipped.length > 0) {
|
|
1089
|
+
console.error(
|
|
1090
|
+
`[parachute-vault] left legacy log files in place (target already exists under logs/): ${logsSkipped.map((n) => join(vaultHome, n)).join(", ")}.`,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
724
1093
|
}
|
|
725
1094
|
|
|
726
1095
|
// ---------------------------------------------------------------------------
|
|
@@ -857,6 +1226,18 @@ export function writeGlobalConfig(config: GlobalConfig): void {
|
|
|
857
1226
|
if (trigger.action.timeout) {
|
|
858
1227
|
lines.push(` timeout: ${trigger.action.timeout}`);
|
|
859
1228
|
}
|
|
1229
|
+
if (trigger.action.include_context?.length) {
|
|
1230
|
+
lines.push(" include_context:");
|
|
1231
|
+
for (const entry of trigger.action.include_context) {
|
|
1232
|
+
lines.push(` - tag: ${entry.tag}`);
|
|
1233
|
+
if (entry.exclude_tag) {
|
|
1234
|
+
lines.push(` exclude_tag: ${entry.exclude_tag}`);
|
|
1235
|
+
}
|
|
1236
|
+
if (entry.include_metadata?.length) {
|
|
1237
|
+
lines.push(` include_metadata: [${entry.include_metadata.map((v) => `"${v}"`).join(", ")}]`);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
860
1241
|
}
|
|
861
1242
|
}
|
|
862
1243
|
|
|
@@ -916,7 +1297,7 @@ export function generateApiKey(): { fullKey: string; keyId: string } {
|
|
|
916
1297
|
}
|
|
917
1298
|
|
|
918
1299
|
// ---------------------------------------------------------------------------
|
|
919
|
-
// Environment file (~/.parachute/.env)
|
|
1300
|
+
// Environment file (~/.parachute/vault/.env)
|
|
920
1301
|
// ---------------------------------------------------------------------------
|
|
921
1302
|
|
|
922
1303
|
/**
|
|
@@ -952,7 +1333,7 @@ export function writeEnvFile(env: Record<string, string>): void {
|
|
|
952
1333
|
ensureConfigDirSync();
|
|
953
1334
|
const lines: string[] = [
|
|
954
1335
|
"# Parachute Vault configuration",
|
|
955
|
-
"# Managed by: parachute
|
|
1336
|
+
"# Managed by: parachute-vault config",
|
|
956
1337
|
"",
|
|
957
1338
|
];
|
|
958
1339
|
for (const [key, val] of Object.entries(env)) {
|
|
@@ -1001,7 +1382,7 @@ export function loadEnvFile(): void {
|
|
|
1001
1382
|
|
|
1002
1383
|
export function listVaults(): string[] {
|
|
1003
1384
|
try {
|
|
1004
|
-
const dir =
|
|
1385
|
+
const dir = dataDirPath();
|
|
1005
1386
|
if (!existsSync(dir)) return [];
|
|
1006
1387
|
const entries = Bun.spawnSync(["ls", dir]).stdout.toString().trim();
|
|
1007
1388
|
if (!entries) return [];
|
|
@@ -1014,15 +1395,15 @@ export function listVaults(): string[] {
|
|
|
1014
1395
|
}
|
|
1015
1396
|
|
|
1016
1397
|
/**
|
|
1017
|
-
* Resolve the vault that
|
|
1018
|
-
*
|
|
1398
|
+
* Resolve the vault that tooling-level defaults (e.g. the `parachute-vault`
|
|
1399
|
+
* MCP entry the CLI writes into `~/.claude.json`) should target. HTTP routing
|
|
1400
|
+
* is vault-scoped — `/vault/<name>/...` is the only URL shape — so this helper
|
|
1401
|
+
* is no longer on the request path; it just picks the one vault the CLI wires
|
|
1402
|
+
* up by default.
|
|
1019
1403
|
*
|
|
1020
1404
|
* Resolution order:
|
|
1021
1405
|
* 1. If `default_vault` is set in config.yaml AND that vault exists → use it.
|
|
1022
1406
|
* 2. Else if exactly one vault exists → use that vault regardless of its name.
|
|
1023
|
-
* This is the "single-vault auto-default": if you only have `journal`,
|
|
1024
|
-
* `/mcp` transparently targets `journal` without requiring you to visit
|
|
1025
|
-
* `/vaults/journal/mcp`.
|
|
1026
1407
|
* 3. Otherwise → return `null` (multi-vault deployment with no/bad default;
|
|
1027
1408
|
* the caller should surface an explicit error rather than guess).
|
|
1028
1409
|
*
|
|
@@ -1030,8 +1411,7 @@ export function listVaults(): string[] {
|
|
|
1030
1411
|
* - If `default_vault` points to a deleted vault, step 2 still kicks in so
|
|
1031
1412
|
* operators aren't stranded after `vault remove`.
|
|
1032
1413
|
* - The name "default" has no special meaning here; it's just whatever
|
|
1033
|
-
* `vault init` happens to create on first run.
|
|
1034
|
-
* "journal" behaves identically.
|
|
1414
|
+
* `vault init` happens to create on first run.
|
|
1035
1415
|
*/
|
|
1036
1416
|
export function resolveDefaultVault(): string | null {
|
|
1037
1417
|
const globalConfig = readGlobalConfig();
|