@openparachute/vault 0.6.0-rc.1 → 0.6.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 (91) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +7 -7
  3. package/core/src/core.test.ts +279 -26
  4. package/core/src/expand-visibility.test.ts +102 -0
  5. package/core/src/expand.ts +31 -3
  6. package/core/src/indexed-fields.ts +1 -1
  7. package/core/src/link-count.test.ts +301 -0
  8. package/core/src/links.ts +97 -2
  9. package/core/src/mcp.ts +201 -33
  10. package/core/src/notes.ts +44 -8
  11. package/core/src/obsidian-alignment.test.ts +375 -0
  12. package/core/src/obsidian.ts +234 -14
  13. package/core/src/portable-md.test.ts +40 -0
  14. package/core/src/portable-md.ts +142 -16
  15. package/core/src/schema.ts +58 -11
  16. package/core/src/store.ts +69 -22
  17. package/core/src/tag-expand-axis.test.ts +301 -0
  18. package/core/src/tag-hierarchy.ts +80 -0
  19. package/core/src/tag-schemas.ts +61 -46
  20. package/core/src/triggers-store.test.ts +100 -0
  21. package/core/src/triggers-store.ts +165 -0
  22. package/core/src/types.ts +68 -4
  23. package/core/src/vault-projection.ts +20 -0
  24. package/core/src/wikilinks.ts +2 -2
  25. package/package.json +2 -3
  26. package/src/admin-spa.test.ts +100 -10
  27. package/src/admin-spa.ts +48 -3
  28. package/src/auth-hub-jwt.test.ts +8 -1
  29. package/src/auth-status.ts +2 -2
  30. package/src/auth.test.ts +39 -3
  31. package/src/auth.ts +31 -2
  32. package/src/auto-transcribe.test.ts +51 -0
  33. package/src/auto-transcribe.ts +24 -6
  34. package/src/autostart.test.ts +75 -0
  35. package/src/autostart.ts +84 -0
  36. package/src/cli.ts +434 -140
  37. package/src/config.test.ts +109 -0
  38. package/src/config.ts +157 -10
  39. package/src/export-watch.test.ts +23 -0
  40. package/src/export-watch.ts +14 -0
  41. package/src/git-preflight.test.ts +70 -0
  42. package/src/git-preflight.ts +68 -0
  43. package/src/hub-jwt.test.ts +75 -2
  44. package/src/hub-jwt.ts +43 -6
  45. package/src/init-summary.test.ts +120 -5
  46. package/src/init-summary.ts +67 -25
  47. package/src/live-match.test.ts +198 -0
  48. package/src/live-match.ts +310 -0
  49. package/src/mcp-install.test.ts +93 -0
  50. package/src/mcp-install.ts +106 -0
  51. package/src/mcp-tools.ts +80 -7
  52. package/src/mirror-config.test.ts +14 -0
  53. package/src/mirror-config.ts +11 -0
  54. package/src/mirror-import.test.ts +110 -0
  55. package/src/mirror-import.ts +71 -13
  56. package/src/mirror-manager.test.ts +51 -0
  57. package/src/mirror-manager.ts +73 -11
  58. package/src/mirror-routes.test.ts +463 -1
  59. package/src/mirror-routes.ts +474 -4
  60. package/src/oauth-discovery.test.ts +55 -0
  61. package/src/oauth-discovery.ts +24 -5
  62. package/src/routes.ts +696 -121
  63. package/src/routing.test.ts +451 -5
  64. package/src/routing.ts +113 -5
  65. package/src/scopes.ts +1 -1
  66. package/src/server.ts +66 -4
  67. package/src/storage.test.ts +162 -0
  68. package/src/subscribe.test.ts +588 -0
  69. package/src/subscribe.ts +248 -0
  70. package/src/subscriptions.ts +295 -0
  71. package/src/tag-expand-routes.test.ts +45 -0
  72. package/src/tag-scope.ts +68 -1
  73. package/src/token-store.ts +7 -7
  74. package/src/transcription-worker.test.ts +471 -5
  75. package/src/transcription-worker.ts +212 -44
  76. package/src/triggers-api.test.ts +533 -0
  77. package/src/triggers-api.ts +295 -0
  78. package/src/triggers.ts +93 -7
  79. package/src/usage.test.ts +362 -0
  80. package/src/usage.ts +318 -0
  81. package/src/vault-create.test.ts +340 -12
  82. package/src/vault-name.test.ts +61 -3
  83. package/src/vault-name.ts +62 -14
  84. package/src/vault-remove.test.ts +187 -0
  85. package/src/vault-store.ts +10 -3
  86. package/src/vault.test.ts +1353 -62
  87. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  88. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  89. package/web/ui/dist/index.html +2 -2
  90. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  91. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -169,6 +169,33 @@ describe("config", () => {
169
169
  expect(loaded!.transcription).toBeUndefined();
170
170
  });
171
171
 
172
+ test("round-trips per-vault auto_transcribe.enabled (true + false)", () => {
173
+ // The PATCH /api/vault handler persists the per-vault toggle via
174
+ // writeVaultConfig; confirm it survives a read-back — this is the exact
175
+ // field shouldAutoTranscribe reads per-vault (per-vault → global → true).
176
+ const base: VaultConfig = {
177
+ name: "testvault",
178
+ api_keys: [],
179
+ created_at: "2026-01-01T00:00:00.000Z",
180
+ };
181
+
182
+ writeVaultConfig({ ...base, auto_transcribe: { enabled: true } });
183
+ expect(readVaultConfig("testvault")!.auto_transcribe?.enabled).toBe(true);
184
+
185
+ writeVaultConfig({ ...base, auto_transcribe: { enabled: false } });
186
+ expect(readVaultConfig("testvault")!.auto_transcribe?.enabled).toBe(false);
187
+ });
188
+
189
+ test("vault config without auto_transcribe loads as undefined (falls back to global)", () => {
190
+ const config: VaultConfig = {
191
+ name: "testvault",
192
+ api_keys: [],
193
+ created_at: "2026-01-01T00:00:00.000Z",
194
+ };
195
+ writeVaultConfig(config);
196
+ expect(readVaultConfig("testvault")!.auto_transcribe).toBeUndefined();
197
+ });
198
+
172
199
  test("round-trips discovery: enabled|disabled", () => {
173
200
  // Default: absent means enabled (endpoint serves names).
174
201
  writeGlobalConfig({ port: 1940 });
@@ -213,6 +240,72 @@ describe("config", () => {
213
240
  expect(reloaded.api_keys?.find((k) => k.id === "k_legacy")?.scope).toBe("write");
214
241
  });
215
242
 
243
+ // ----- vault#234: anchored api_keys field regexes ----------------------
244
+ // The api_keys field regexes (label/scope/key_hash/created_at/last_used_at)
245
+ // used to be unanchored, so a COMMENTED `# scope: read` line matched, and a
246
+ // value-less `scope: ` (trailing space) captured the NEXT field's token
247
+ // (`key_hash`). The writer never emits either shape — only hand-editing
248
+ // reaches these branches — but a malformed scope could silently mis-scope a
249
+ // key. The regexes are now line-anchored + horizontal-whitespace-bounded.
250
+
251
+ test("vault#234: commented `# scope:` line is ignored, scope falls back to default", () => {
252
+ const fs = require("fs");
253
+ const path = join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234", "vault.yaml");
254
+ fs.mkdirSync(join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234"), { recursive: true });
255
+ // The only `scope:` line is commented out; the parser must NOT pick it up.
256
+ fs.writeFileSync(
257
+ path,
258
+ `name: mv234\ncreated_at: "2026-01-01T00:00:00.000Z"\napi_keys:\n - id: k_cmt\n label: commented\n # scope: read\n key_hash: sha256:cmt\n created_at: "2026-01-01T00:00:00.000Z"\n`,
259
+ );
260
+ const loaded = readVaultConfig("mv234");
261
+ const key = loaded!.api_keys.find((k) => k.id === "k_cmt");
262
+ expect(key).toBeDefined();
263
+ // Commented scope ignored → default "write", NOT "read".
264
+ expect(key!.scope).toBe("write");
265
+ expect(key!.key_hash).toBe("sha256:cmt");
266
+ });
267
+
268
+ test("vault#234: value-less `scope: ` (trailing space) does NOT capture the next field", () => {
269
+ const fs = require("fs");
270
+ const path = join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234b", "vault.yaml");
271
+ fs.mkdirSync(join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234b"), { recursive: true });
272
+ // `scope: ` has a trailing space and no value; the OLD regex skipped the
273
+ // newline and captured `sha256:trailing` (the key_hash) as the scope.
274
+ fs.writeFileSync(
275
+ path,
276
+ `name: mv234b\ncreated_at: "2026-01-01T00:00:00.000Z"\napi_keys:\n - id: k_trail\n label: trailing\n scope: \n key_hash: sha256:trailing\n created_at: "2026-01-01T00:00:00.000Z"\n`,
277
+ );
278
+ const loaded = readVaultConfig("mv234b");
279
+ const key = loaded!.api_keys.find((k) => k.id === "k_trail");
280
+ expect(key).toBeDefined();
281
+ // The hash must NOT have been borrowed as the scope.
282
+ expect(key!.scope).not.toBe("sha256:trailing");
283
+ expect(key!.scope).toBe("write"); // default
284
+ // And the real key_hash is still parsed correctly.
285
+ expect(key!.key_hash).toBe("sha256:trailing");
286
+ });
287
+
288
+ test("vault#234: a valid `scope: read` still parses (positive control, both parsers)", () => {
289
+ const fs = require("fs");
290
+ // Vault-level parser.
291
+ const vpath = join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234c", "vault.yaml");
292
+ fs.mkdirSync(join(process.env.PARACHUTE_HOME!, "vault", "data", "mv234c"), { recursive: true });
293
+ fs.writeFileSync(
294
+ vpath,
295
+ `name: mv234c\ncreated_at: "2026-01-01T00:00:00.000Z"\napi_keys:\n - id: k_v\n label: reader\n scope: read\n key_hash: sha256:v\n created_at: "2026-01-01T00:00:00.000Z"\n`,
296
+ );
297
+ expect(readVaultConfig("mv234c")!.api_keys.find((k) => k.id === "k_v")?.scope).toBe("read");
298
+
299
+ // Global parser, same grammar.
300
+ const gpath = join(process.env.PARACHUTE_HOME!, "vault", "config.yaml");
301
+ fs.writeFileSync(
302
+ gpath,
303
+ `port: 1940\napi_keys:\n - id: k_g\n label: reader\n # scope: write\n scope: read\n key_hash: sha256:g\n created_at: "2026-01-01T00:00:00.000Z"\n`,
304
+ );
305
+ // The commented `# scope: write` is skipped; the real `scope: read` wins.
306
+ expect(readGlobalConfig().api_keys?.find((k) => k.id === "k_g")?.scope).toBe("read");
307
+ });
308
+
216
309
  test("writeEnvFile writes .env at 0600 (SCRIBE_AUTH_TOKEN secrecy)", () => {
217
310
  // Regression for vault#354 reviewer finding: the .env holds
218
311
  // SCRIBE_AUTH_TOKEN (the vault↔scribe loopback bearer). On a
@@ -250,6 +343,22 @@ describe("config", () => {
250
343
  writeGlobalConfig({ port: 1940, autostart: false });
251
344
  expect(readGlobalConfig().autostart).toBe(false);
252
345
  });
346
+
347
+ test("round-trips default_mirror: internal|off", () => {
348
+ // Absent: createVault falls back to the in-code default ("internal" —
349
+ // backup-on-by-default). The knob is only persisted when explicitly set.
350
+ writeGlobalConfig({ port: 1940 });
351
+ expect(readGlobalConfig().default_mirror).toBeUndefined();
352
+
353
+ // Explicit internal — new vaults get the History-preset local git mirror.
354
+ writeGlobalConfig({ port: 1940, default_mirror: "internal" });
355
+ expect(readGlobalConfig().default_mirror).toBe("internal");
356
+
357
+ // Explicit off — the opt-out operators set on git-less / disk-constrained
358
+ // / cloud boxes so new vaults are created with no mirror config.
359
+ writeGlobalConfig({ port: 1940, default_mirror: "off" });
360
+ expect(readGlobalConfig().default_mirror).toBe("off");
361
+ });
253
362
  });
254
363
 
255
364
  // ---------------------------------------------------------------------------
package/src/config.ts CHANGED
@@ -115,6 +115,17 @@ export function vaultConfigPath(name: string): string {
115
115
  return join(vaultDir(name), "vault.yaml");
116
116
  }
117
117
 
118
+ /**
119
+ * Per-vault attachments directory: `<vaultDir>/assets`, or the `ASSETS_DIR`
120
+ * env override when set (single-assets-root deployments). Lives here next to
121
+ * the other path helpers — neutral ground that both `routes.ts` (upload/serve)
122
+ * and `usage.ts` (footprint dir-walk) import without a cycle. `routes.ts`
123
+ * re-exports it for the existing callers (mirror-deps, server, triggers, …).
124
+ */
125
+ export function assetsDir(name: string): string {
126
+ return process.env.ASSETS_DIR ?? join(vaultDir(name), "assets");
127
+ }
128
+
118
129
  // ---------------------------------------------------------------------------
119
130
  // Types
120
131
  // ---------------------------------------------------------------------------
@@ -172,6 +183,23 @@ export interface VaultConfig {
172
183
  transcription?: {
173
184
  context?: TriggerIncludeContext[];
174
185
  };
186
+ /**
187
+ * Per-vault auto-transcribe override (vault#353 follow-up). When set, this
188
+ * vault's value takes precedence over the server-wide
189
+ * `GlobalConfig.auto_transcribe.enabled`. Resolution at the decision point
190
+ * (`shouldAutoTranscribe`) is **per-vault → global → true**: a vault that
191
+ * sets `enabled` here uses it; a vault that leaves it unset falls back to
192
+ * the global toggle, which itself defaults ON.
193
+ *
194
+ * This is what makes scribe's "link to vault X" genuinely per-vault —
195
+ * `PATCH /vault/X/api/vault {auto_transcribe:{enabled:true}}` flips only
196
+ * vault X, never the whole server. URL + bearer are still resolved per-
197
+ * process (services.json / SCRIBE_AUTH_TOKEN); only the on/off toggle is
198
+ * per-vault.
199
+ */
200
+ auto_transcribe?: {
201
+ enabled?: boolean;
202
+ };
175
203
  }
176
204
 
177
205
  // ---------------------------------------------------------------------------
@@ -229,6 +257,20 @@ export interface TriggerAction {
229
257
  * top-level `context` field (send=json). send=content ignores this.
230
258
  */
231
259
  include_context?: TriggerIncludeContext[];
260
+ /**
261
+ * Optional webhook auth. When `auth.bearer` is set, the trigger sends
262
+ * `Authorization: Bearer <bearer>` on the webhook POST — the JWT path that
263
+ * retires the shared `?secret=` query param. Back-compat: a webhook URL
264
+ * carrying its own `?secret=` still works; `auth` is purely additive.
265
+ * Runtime triggers (registered via the /api/triggers REST surface) are the
266
+ * primary users; config.yaml triggers may also carry it.
267
+ */
268
+ auth?: TriggerAuth;
269
+ }
270
+
271
+ export interface TriggerAuth {
272
+ /** Bearer token (typically a hub-issued JWT) for the webhook Authorization header. */
273
+ bearer?: string;
232
274
  }
233
275
 
234
276
  export interface TriggerConfig {
@@ -270,6 +312,19 @@ export interface GlobalConfig {
270
312
  * point their own supervisor at it.
271
313
  */
272
314
  autostart?: boolean;
315
+ /**
316
+ * Boot auto-create marker (2026-06-09 hub-module-boundary migration, the
317
+ * vault wave's `cmdRemove` improvement). Server boot auto-creates a
318
+ * `default` vault when `listVaults()` is empty — the Docker / hub-install
319
+ * first-run path. When the operator EXPLICITLY deletes their last vault
320
+ * via `parachute-vault remove`, that auto-create would silently resurrect
321
+ * a fresh `default` (with fresh credentials) on the next boot. `cmdRemove`
322
+ * writes `auto_create: false` when it removes the last vault; boot skips
323
+ * the auto-create while the marker is present. Fresh installs (no
324
+ * config.yaml at all) never carry the marker, so the Docker first-run
325
+ * behavior is preserved. See `bootAutoCreateAllowed`.
326
+ */
327
+ auto_create?: boolean;
273
328
  /** Backup configuration: schedule, retention, destinations. */
274
329
  backup?: BackupConfig;
275
330
  /**
@@ -280,6 +335,29 @@ export interface GlobalConfig {
280
335
  * resolved path. See `./mirror-config.ts`.
281
336
  */
282
337
  mirror?: MirrorConfigType;
338
+ /**
339
+ * Server-wide DEFAULT for newly created vaults' backup posture. Decides
340
+ * whether `createVault` writes the History-preset internal mirror
341
+ * (local git backup of the markdown projection) at create time.
342
+ *
343
+ * - `"internal"` (default) — new vaults get a local git mirror enabled
344
+ * out of the box (backup-on-by-default). The History preset:
345
+ * `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
346
+ * auto_push:false}`. GitHub off-site backup remains an opt-in upgrade.
347
+ * - `"off"` — new vaults are created with no mirror config (the historical
348
+ * pre-default behavior). The escape hatch for git-less / disk-constrained
349
+ * boxes and cloud deploys, where doubling disk per vault is unwanted.
350
+ * Cloud / container deploys SHOULD set this to `off`.
351
+ *
352
+ * Create-time ONLY — this knob does NOT retroactively enable mirrors on
353
+ * already-created vaults (that would ~double disk across every existing
354
+ * vault). Existing-vault opt-in is a separate, deliberate follow-up.
355
+ *
356
+ * The container/cloud first-boot auto-create path in `server.ts` does NOT
357
+ * funnel through `createVault`, so it is unaffected by this knob and stays
358
+ * mirror-off regardless — matching the recommended cloud posture.
359
+ */
360
+ default_mirror?: "internal" | "off";
283
361
  /**
284
362
  * Auto-transcribe configuration for the vault↔scribe handoff (vault#353,
285
363
  * design 2026-05-21 Part 2). When `enabled: true` AND scribe is discoverable
@@ -396,6 +474,14 @@ function serializeVaultConfig(config: VaultConfig): string {
396
474
  lines.push(`audio_retention: ${config.audio_retention}`);
397
475
  }
398
476
 
477
+ // Per-vault auto-transcribe override. Serialized as a nested block so future
478
+ // fields can grow under it (mirrors the GlobalConfig shape). Only emitted
479
+ // when `enabled` is explicitly set — an unset vault falls back to global.
480
+ if (config.auto_transcribe?.enabled !== undefined) {
481
+ lines.push("auto_transcribe:");
482
+ lines.push(` enabled: ${config.auto_transcribe.enabled}`);
483
+ }
484
+
399
485
  if (config.transcription?.context?.length) {
400
486
  lines.push("transcription:");
401
487
  lines.push(" context:");
@@ -484,14 +570,32 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
484
570
  }
485
571
 
486
572
  // Parse api_keys
573
+ //
574
+ // Accepted grammar (vault#234): each `id:` block is the writer's output —
575
+ // one field per line, indented two spaces under the `- id:` list item:
576
+ //
577
+ // - id: <id>
578
+ // label: <label> # free text to end of line
579
+ // scope: <scope> # single token (read|write|admin|…)
580
+ // key_hash: <hash> # single token
581
+ // created_at: "<iso>" # quoted or bare, no embedded newline
582
+ // last_used_at: "<iso>"
583
+ //
584
+ // Each field regex is line-anchored (`^...`, `m` flag) so a COMMENTED line
585
+ // (`# scope: read`) never matches — the line must begin with optional
586
+ // leading whitespace then the bare key. The value matcher uses horizontal
587
+ // whitespace only (`[^\S\r\n]*`, never `\s*`) after the colon so a
588
+ // value-less field (`scope: ` with a trailing space) can't skip the newline
589
+ // and capture the NEXT field's value. A missing optional field falls back to
590
+ // its default rather than borrowing a neighbor's token.
487
591
  const keyBlocks = yaml.split(/\n\s+-\s+id:\s+/).slice(1);
488
592
  for (const block of keyBlocks) {
489
593
  const idMatch = block.match(/^(\S+)/);
490
- const labelMatch = block.match(/label:\s*(.+)/);
491
- const scopeMatch = block.match(/scope:\s*(\S+)/);
492
- const hashMatch = block.match(/key_hash:\s*(\S+)/);
493
- const createdAtMatch = block.match(/created_at:\s*"?([^"\n]+)"?/);
494
- const lastUsedMatch = block.match(/last_used_at:\s*"?([^"\n]+)"?/);
594
+ const labelMatch = block.match(/^[^\S\r\n]*label:[^\S\r\n]*(.+)/m);
595
+ const scopeMatch = block.match(/^[^\S\r\n]*scope:[^\S\r\n]*(\S+)/m);
596
+ const hashMatch = block.match(/^[^\S\r\n]*key_hash:[^\S\r\n]*(\S+)/m);
597
+ const createdAtMatch = block.match(/^[^\S\r\n]*created_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
598
+ const lastUsedMatch = block.match(/^[^\S\r\n]*last_used_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
495
599
 
496
600
  if (idMatch && hashMatch) {
497
601
  config.api_keys.push({
@@ -514,6 +618,22 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
514
618
  config.transcription = { context: transcriptionContext };
515
619
  }
516
620
 
621
+ // Parse the per-vault auto_transcribe block — currently single boolean
622
+ // `enabled`. Nested 2-space-indent block (mirrors the GlobalConfig parser)
623
+ // so future fields can grow under it without breaking the regex.
624
+ const autoTranscribeStart = yaml.match(/^auto_transcribe:\s*$/m);
625
+ if (autoTranscribeStart) {
626
+ const after = yaml.slice((autoTranscribeStart.index ?? 0) + autoTranscribeStart[0].length);
627
+ for (const line of after.split("\n")) {
628
+ if (line.match(/^\S/) && line.trim().length > 0) break; // next top-level key
629
+ const m = line.match(/^\s+enabled:\s*(true|false)/);
630
+ if (m) {
631
+ config.auto_transcribe = { enabled: m[1]! === "true" };
632
+ break;
633
+ }
634
+ }
635
+ }
636
+
517
637
  return config;
518
638
  }
519
639
 
@@ -1151,6 +1271,20 @@ export function migrateVaultInternalLayout(): void {
1151
1271
  // Global config
1152
1272
  // ---------------------------------------------------------------------------
1153
1273
 
1274
+ /**
1275
+ * Whether server boot may auto-create the first vault when none exist.
1276
+ *
1277
+ * Only the explicit `auto_create: false` marker (written by `cmdRemove`
1278
+ * when it deletes the LAST vault) blocks the auto-create. A fresh install
1279
+ * has no config.yaml — `readGlobalConfig()` returns defaults with
1280
+ * `auto_create` unset — so Docker / hub-install first-run still
1281
+ * auto-creates `default`. Pure + exported so the boot gate is testable
1282
+ * without booting a server.
1283
+ */
1284
+ export function bootAutoCreateAllowed(config: Pick<GlobalConfig, "auto_create">): boolean {
1285
+ return config.auto_create !== false;
1286
+ }
1287
+
1154
1288
  export function readGlobalConfig(): GlobalConfig {
1155
1289
  try {
1156
1290
  const gcPath = globalConfigPath();
@@ -1162,6 +1296,8 @@ export function readGlobalConfig(): GlobalConfig {
1162
1296
  const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
1163
1297
  const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
1164
1298
  const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
1299
+ const autoCreateMatch = yaml.match(/^auto_create:\s*(true|false)/m);
1300
+ const defaultMirrorMatch = yaml.match(/^default_mirror:\s*(internal|off)/m);
1165
1301
  // auto_transcribe block — currently single boolean `enabled` (vault#353).
1166
1302
  // Parsed as a nested 2-space-indent block so future fields can grow under
1167
1303
  // it without breaking the regex; only `enabled` is read for v0.6.
@@ -1190,6 +1326,12 @@ export function readGlobalConfig(): GlobalConfig {
1190
1326
  if (autostartMatch) {
1191
1327
  config.autostart = autostartMatch[1]! === "true";
1192
1328
  }
1329
+ if (autoCreateMatch) {
1330
+ config.auto_create = autoCreateMatch[1]! === "true";
1331
+ }
1332
+ if (defaultMirrorMatch) {
1333
+ config.default_mirror = defaultMirrorMatch[1]! as "internal" | "off";
1334
+ }
1193
1335
  if (autoTranscribeEnabled !== undefined) {
1194
1336
  config.auto_transcribe = { enabled: autoTranscribeEnabled };
1195
1337
  }
@@ -1212,16 +1354,19 @@ export function readGlobalConfig(): GlobalConfig {
1212
1354
  }
1213
1355
 
1214
1356
  // Parse global api_keys
1357
+ // Same line-anchored grammar as the vault-level parser above (vault#234)
1358
+ // — commented lines don't match; a value-less field can't capture the
1359
+ // next field's token across the newline.
1215
1360
  const keyBlocks = yaml.split(/\n\s+-\s+id:\s+/).slice(1);
1216
1361
  if (keyBlocks.length > 0) {
1217
1362
  config.api_keys = [];
1218
1363
  for (const block of keyBlocks) {
1219
1364
  const idMatch = block.match(/^(\S+)/);
1220
- const labelMatch = block.match(/label:\s*(.+)/);
1221
- const scopeMatch = block.match(/scope:\s*(\S+)/);
1222
- const hashMatch = block.match(/key_hash:\s*(\S+)/);
1223
- const createdAtMatch = block.match(/created_at:\s*"?([^"\n]+)"?/);
1224
- const lastUsedMatch = block.match(/last_used_at:\s*"?([^"\n]+)"?/);
1365
+ const labelMatch = block.match(/^[^\S\r\n]*label:[^\S\r\n]*(.+)/m);
1366
+ const scopeMatch = block.match(/^[^\S\r\n]*scope:[^\S\r\n]*(\S+)/m);
1367
+ const hashMatch = block.match(/^[^\S\r\n]*key_hash:[^\S\r\n]*(\S+)/m);
1368
+ const createdAtMatch = block.match(/^[^\S\r\n]*created_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
1369
+ const lastUsedMatch = block.match(/^[^\S\r\n]*last_used_at:[^\S\r\n]*"?([^"\n]+?)"?\s*$/m);
1225
1370
  if (idMatch && hashMatch) {
1226
1371
  config.api_keys.push({
1227
1372
  id: idMatch[1]!,
@@ -1259,6 +1404,8 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1259
1404
  if (config.default_vault) lines.push(`default_vault: ${config.default_vault}`);
1260
1405
  if (config.discovery) lines.push(`discovery: ${config.discovery}`);
1261
1406
  if (config.autostart !== undefined) lines.push(`autostart: ${config.autostart}`);
1407
+ if (config.auto_create !== undefined) lines.push(`auto_create: ${config.auto_create}`);
1408
+ if (config.default_mirror) lines.push(`default_mirror: ${config.default_mirror}`);
1262
1409
  if (config.owner_password_hash) {
1263
1410
  lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
1264
1411
  }
@@ -35,6 +35,7 @@ import {
35
35
  runGitCommitCycle,
36
36
  shouldCommit,
37
37
  } from "./export-watch.ts";
38
+ import { GitNotInstalledError } from "./git-preflight.ts";
38
39
 
39
40
  const CLI = path.resolve(import.meta.dir, "cli.ts");
40
41
 
@@ -504,6 +505,28 @@ describe("runGitCommitCycle", () => {
504
505
  });
505
506
  expect(result.message).toBe("note: Inbox/DonorMeeting");
506
507
  });
508
+
509
+ test("git missing → throws GitNotInstalledError (sync surfaces friendly error, not raw spawn crash)", async () => {
510
+ // vault#415 — the sync/commit path must surface the actionable
511
+ // git-not-installed message (which the manager threads into
512
+ // status.last_error) instead of crashing with a raw "Executable not
513
+ // found in $PATH". Force the preflight to see no git via the `which`
514
+ // seam; no real spawn should be reached.
515
+ fs.writeFileSync(path.join(dir, "Note.md"), "# n\n");
516
+ await expect(
517
+ runGitCommitCycle({
518
+ repoDir: dir,
519
+ template: DEFAULT_COMMIT_TEMPLATE,
520
+ notesChanged: 1,
521
+ vaultName: "default",
522
+ firstNoteTitle: "Note",
523
+ push: false,
524
+ which: () => null,
525
+ }),
526
+ ).rejects.toBeInstanceOf(GitNotInstalledError);
527
+ // The commit cycle bailed at the preflight — no commit landed.
528
+ expect(gitLogOneline(dir)).toHaveLength(1); // only the seed
529
+ });
507
530
  });
508
531
 
509
532
  // ---------------------------------------------------------------------------
@@ -13,6 +13,8 @@
13
13
  * detection. See `parachute-patterns/cookbook/vault-portable-export.md`.
14
14
  */
15
15
 
16
+ import { ensureGitAvailable } from "./git-preflight.ts";
17
+
16
18
  // ---------------------------------------------------------------------------
17
19
  // Commit message templating
18
20
  // ---------------------------------------------------------------------------
@@ -269,11 +271,23 @@ export async function runGitCommitCycle(opts: {
269
271
  push: boolean;
270
272
  /** Override for tests — defaults to `new Date().toISOString()`. */
271
273
  now?: () => string;
274
+ /**
275
+ * Override the git-presence probe (test seam — defaults to `Bun.which`).
276
+ * Inject a fn returning `null` to exercise the git-not-installed path.
277
+ */
278
+ which?: (cmd: string) => string | null;
272
279
  }): Promise<{
273
280
  committed: boolean;
274
281
  message?: string;
275
282
  push?: { attempted: true; ok: boolean; error?: string };
276
283
  }> {
284
+ // Preflight: every step below shells `git`. On a git-less server the first
285
+ // `Bun.spawn(["git", ...])` would throw a raw "Executable not found" error;
286
+ // surface the friendly, actionable GitNotInstalledError so callers can
287
+ // thread it into mirror status (`last_error`) instead of crashing the
288
+ // watch loop with an opaque message.
289
+ ensureGitAvailable(opts.which);
290
+
277
291
  const now = opts.now ?? (() => new Date().toISOString());
278
292
 
279
293
  const add = await gitAddAll(opts.repoDir);
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tests for the shared git-availability preflight (vault#415).
3
+ *
4
+ * Found live: importing a repo on a git-less Amazon Linux EC2 box failed
5
+ * with a raw `Executable not found in $PATH: "git"` 500. The preflight gives
6
+ * every git entry point a fast, friendly, actionable failure instead.
7
+ */
8
+
9
+ import { describe, test, expect } from "bun:test";
10
+ import {
11
+ GitNotInstalledError,
12
+ ensureGitAvailable,
13
+ isGitNotFoundSpawnError,
14
+ } from "./git-preflight.ts";
15
+
16
+ describe("ensureGitAvailable", () => {
17
+ test("throws GitNotInstalledError when which returns null", () => {
18
+ expect(() => ensureGitAvailable(() => null)).toThrow(GitNotInstalledError);
19
+ });
20
+
21
+ test("does not throw when which resolves git", () => {
22
+ expect(() => ensureGitAvailable(() => "/usr/bin/git")).not.toThrow();
23
+ });
24
+
25
+ test("defaults to Bun.which (git is present in this test env)", () => {
26
+ // The test host has git; the default-arg path resolves it cleanly.
27
+ expect(() => ensureGitAvailable()).not.toThrow();
28
+ });
29
+ });
30
+
31
+ describe("GitNotInstalledError message", () => {
32
+ test("is OS-agnostic-but-helpful — names dnf, apt-get, and brew", () => {
33
+ const msg = new GitNotInstalledError().message;
34
+ expect(msg).toContain("git is required for this operation");
35
+ expect(msg).toContain("sudo dnf install git");
36
+ expect(msg).toContain("sudo apt-get install -y git");
37
+ expect(msg).toContain("brew install git");
38
+ });
39
+
40
+ test("carries the GitNotInstalledError name (instanceof + name both work)", () => {
41
+ const err = new GitNotInstalledError();
42
+ expect(err).toBeInstanceOf(GitNotInstalledError);
43
+ expect(err.name).toBe("GitNotInstalledError");
44
+ });
45
+ });
46
+
47
+ describe("isGitNotFoundSpawnError", () => {
48
+ test("matches Bun's executable-not-found message for git", () => {
49
+ expect(
50
+ isGitNotFoundSpawnError(
51
+ new Error('Executable not found in $PATH: "git"'),
52
+ ),
53
+ ).toBe(true);
54
+ });
55
+
56
+ test("matches an ENOENT spawn error mentioning git", () => {
57
+ const err = new Error("spawn git ENOENT") as Error & { code?: string };
58
+ err.code = "ENOENT";
59
+ expect(isGitNotFoundSpawnError(err)).toBe(true);
60
+ });
61
+
62
+ test("does not match an unrelated error", () => {
63
+ expect(isGitNotFoundSpawnError(new Error("network unreachable"))).toBe(false);
64
+ });
65
+
66
+ test("does not match a non-Error value", () => {
67
+ expect(isGitNotFoundSpawnError("git missing")).toBe(false);
68
+ expect(isGitNotFoundSpawnError(null)).toBe(false);
69
+ });
70
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared git-availability preflight.
3
+ *
4
+ * Every git-using entry point in vault (mirror import, mirror sync/commit/
5
+ * push, internal-mirror bootstrap) shells out to the `git` binary via
6
+ * `Bun.spawn(["git", ...])`. On a server where `git` isn't installed (a
7
+ * fresh Amazon Linux / minimal Docker image, etc.) Bun throws a raw
8
+ * `Executable not found in $PATH: "git"` error, which the import route only
9
+ * caught in its generic `internal` 500 branch — surfacing an unhelpful,
10
+ * un-actionable error to the operator.
11
+ *
12
+ * This module centralizes the preflight so every git entry point fails
13
+ * fast with a clear, actionable message that tells the operator HOW to
14
+ * fix it (install git via their distro's package manager).
15
+ *
16
+ * Found live on the gitcoin-parachute EC2 deploy (Amazon Linux, no git).
17
+ * See vault#415-era fix.
18
+ */
19
+
20
+ /**
21
+ * Thrown when `git` is required for an operation but isn't on PATH. Carries
22
+ * an OS-agnostic-but-helpful message with the common install commands so the
23
+ * operator can act without leaving the error surface.
24
+ */
25
+ export class GitNotInstalledError extends Error {
26
+ constructor() {
27
+ super(
28
+ "git is required for this operation but was not found on the server. " +
29
+ "Install git and retry — e.g. `sudo dnf install git` (Amazon Linux / Fedora), " +
30
+ "`sudo apt-get install -y git` (Debian / Ubuntu), or `brew install git` (macOS).",
31
+ );
32
+ this.name = "GitNotInstalledError";
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Throw `GitNotInstalledError` if `git` isn't resolvable on PATH.
38
+ *
39
+ * `which` is a TEST SEAM (default `Bun.which`) so tests can force the
40
+ * git-missing branch without uninstalling git from the test host. Production
41
+ * callers pass nothing and get the real `Bun.which`.
42
+ */
43
+ export function ensureGitAvailable(
44
+ which: (cmd: string) => string | null = Bun.which,
45
+ ): void {
46
+ if (which("git") === null) {
47
+ throw new GitNotInstalledError();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Heuristic: does this error look like the "git executable not found" failure
53
+ * Bun throws when it can't resolve the binary? Used as a belt-and-suspenders
54
+ * catch around `Bun.spawn(["git", ...])` so a spawn that slips past the
55
+ * preflight (race where git is removed between check and spawn, or a code path
56
+ * that didn't preflight) still surfaces the friendly error instead of the raw
57
+ * `Executable not found in $PATH: "git"` string.
58
+ */
59
+ export function isGitNotFoundSpawnError(err: unknown): boolean {
60
+ if (!(err instanceof Error)) return false;
61
+ const msg = err.message ?? "";
62
+ // Bun: `Executable not found in $PATH: "git"`.
63
+ // Node/posix: ENOENT spawn errors mention the missing file.
64
+ return (
65
+ (msg.includes("Executable not found") && msg.includes("git")) ||
66
+ ((err as { code?: string }).code === "ENOENT" && msg.includes("git"))
67
+ );
68
+ }