@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -54
- package/core/src/core.test.ts +4 -1
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +66 -43
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +52 -0
- package/core/src/portable-md.ts +48 -0
- package/core/src/schema.ts +87 -14
- package/core/src/store.ts +117 -0
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +191 -11
- package/src/auth-status.ts +12 -5
- package/src/auth.test.ts +135 -219
- package/src/auth.ts +158 -107
- package/src/cli.ts +306 -224
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/init-summary.test.ts +4 -4
- package/src/init-summary.ts +36 -10
- package/src/mcp-config.test.ts +4 -2
- package/src/mcp-http.ts +24 -3
- package/src/mcp-install-interactive.test.ts +33 -71
- package/src/mcp-install-interactive.ts +23 -76
- package/src/mcp-install.test.ts +156 -55
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +249 -74
- package/src/mirror-config.test.ts +107 -0
- package/src/mirror-config.ts +275 -9
- package/src/mirror-credentials.test.ts +168 -17
- package/src/mirror-credentials.ts +155 -32
- package/src/mirror-deps.ts +25 -16
- package/src/mirror-import.test.ts +122 -16
- package/src/mirror-import.ts +50 -16
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +116 -22
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +81 -21
- package/src/mirror-routes.ts +90 -16
- package/src/routes.ts +39 -2
- package/src/routing.test.ts +203 -118
- package/src/routing.ts +46 -59
- package/src/scopes.test.ts +0 -86
- package/src/scopes.ts +9 -97
- package/src/server.ts +102 -34
- package/src/storage.test.ts +132 -7
- package/src/token-store.test.ts +88 -169
- package/src/token-store.ts +123 -249
- package/src/vault-create.test.ts +12 -4
- package/src/vault.test.ts +408 -103
- package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/tokens-routes.test.ts +0 -727
- package/src/tokens-routes.ts +0 -392
- package/web/ui/dist/assets/index-Degr8snN.js +0 -60
package/src/mirror-config.ts
CHANGED
|
@@ -4,12 +4,25 @@
|
|
|
4
4
|
* Builds on the manual export primitives from vault#346 (`parachute-vault
|
|
5
5
|
* export --watch --git-commit`). This module owns the *persistent* form:
|
|
6
6
|
*
|
|
7
|
-
* - Schema for the
|
|
8
|
-
* - Parse + serialize that block
|
|
7
|
+
* - Schema for the per-vault mirror config block.
|
|
8
|
+
* - Parse + serialize that block.
|
|
9
|
+
* - Per-vault read/write of the config to `data/<vault>/mirror-config.yaml`
|
|
10
|
+
* (vault#400 — see below).
|
|
9
11
|
* - Resolve the on-disk mirror path (internal vs external).
|
|
10
12
|
* - Validate the operator-supplied shape (location enum, external_path
|
|
11
13
|
* existence + git-repo-ness).
|
|
12
14
|
*
|
|
15
|
+
* **Per-vault config (vault#400).** Before vault#400, mirror config lived in a
|
|
16
|
+
* SINGLE server-wide `mirror:` block in `~/.parachute/vault/config.yaml`.
|
|
17
|
+
* That made every vault's mirror page show the same config + the same git
|
|
18
|
+
* remote, and configuring vault B clobbered vault A. vault#399 had already
|
|
19
|
+
* moved credential STORAGE per-vault; vault#400 completes the job by moving
|
|
20
|
+
* the CONFIG block to `data/<vault>/mirror-config.yaml` — alongside the
|
|
21
|
+
* per-vault credentials file + the SQLite DB. The legacy server-wide
|
|
22
|
+
* `mirror:` block is migrated to its owning vault on boot (default/first
|
|
23
|
+
* vault, matching how the single server-wide mirror was bound); other
|
|
24
|
+
* vaults start with no mirror config. See `migrateLegacyServerWideConfig`.
|
|
25
|
+
*
|
|
13
26
|
* The lifecycle wiring (boot-time bootstrap, watch loop start/stop/reload)
|
|
14
27
|
* lives in `./mirror-manager.ts`; the HTTP surface lives in
|
|
15
28
|
* `./mirror-routes.ts`. This file is intentionally I/O-light: pure parsing,
|
|
@@ -19,11 +32,20 @@
|
|
|
19
32
|
* `parachute.computer/design/2026-05-20-vault-as-git-projection.md`.
|
|
20
33
|
*/
|
|
21
34
|
|
|
22
|
-
import {
|
|
23
|
-
|
|
35
|
+
import {
|
|
36
|
+
existsSync,
|
|
37
|
+
mkdirSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
renameSync,
|
|
40
|
+
statSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from "fs";
|
|
43
|
+
import { dirname, join } from "path";
|
|
44
|
+
import { homedir } from "os";
|
|
24
45
|
|
|
25
46
|
import { DEFAULT_COMMIT_TEMPLATE, isGitRepo } from "./export-watch.ts";
|
|
26
47
|
import { readCredentials, type MirrorCredentials } from "./mirror-credentials.ts";
|
|
48
|
+
import { ensureGitAvailable } from "./git-preflight.ts";
|
|
27
49
|
|
|
28
50
|
// ---------------------------------------------------------------------------
|
|
29
51
|
// Types
|
|
@@ -65,9 +87,9 @@ export const MIN_SAFETY_NET_SECONDS = 60;
|
|
|
65
87
|
export const MAX_SAFETY_NET_SECONDS = 86400;
|
|
66
88
|
|
|
67
89
|
/**
|
|
68
|
-
* The persistent mirror configuration block.
|
|
69
|
-
*
|
|
70
|
-
* mirroring
|
|
90
|
+
* The persistent mirror configuration block. Per-vault since vault#400: each
|
|
91
|
+
* vault stores its own block in `data/<vault>/mirror-config.yaml` (real
|
|
92
|
+
* multi-vault mirroring — every vault can mirror to its own git remote).
|
|
71
93
|
*
|
|
72
94
|
* Field semantics:
|
|
73
95
|
* - `enabled` — master switch. When false (the default for upgrading
|
|
@@ -312,6 +334,225 @@ export function serializeMirrorConfig(config: MirrorConfig): string[] {
|
|
|
312
334
|
return lines;
|
|
313
335
|
}
|
|
314
336
|
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Per-vault config storage (vault#400)
|
|
339
|
+
//
|
|
340
|
+
// Each vault's mirror config lives in `data/<vault>/mirror-config.yaml`,
|
|
341
|
+
// alongside its SQLite DB (`vault.db`), config (`vault.yaml`), and per-vault
|
|
342
|
+
// credentials file (`.mirror-credentials.yaml`, vault#399). The file holds
|
|
343
|
+
// the same `mirror:`-prefixed block `serializeMirrorConfig` already emits, so
|
|
344
|
+
// `parseMirrorConfig`/`serializeMirrorConfig` are reused verbatim.
|
|
345
|
+
//
|
|
346
|
+
// Why a separate file from credentials (not folded in): config is
|
|
347
|
+
// non-secret + hand-editable; credentials are 0o600 + token-bearing. Keeping
|
|
348
|
+
// them separate avoids accidentally widening the credentials file's perms,
|
|
349
|
+
// and keeps the credentials migration (vault#399) and config migration
|
|
350
|
+
// (vault#400) cleanly independent.
|
|
351
|
+
//
|
|
352
|
+
// Path resolution mirrors `mirror-credentials.ts` rather than importing
|
|
353
|
+
// `config.ts:vaultDir()` — config.ts imports this module, so importing it
|
|
354
|
+
// back would close a cycle. We re-derive from `PARACHUTE_HOME` (the canonical
|
|
355
|
+
// override the rest of vault honors) instead.
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
/** The vault home root — `<configDir>/vault`. Re-reads PARACHUTE_HOME per call. */
|
|
359
|
+
function vaultHomeRoot(): string {
|
|
360
|
+
const root = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
|
|
361
|
+
return join(root, "vault");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Path to a vault's per-vault mirror-config file (vault#400):
|
|
366
|
+
* `<configDir>/vault/data/<vaultName>/mirror-config.yaml`.
|
|
367
|
+
*/
|
|
368
|
+
export function mirrorConfigPath(vaultName: string): string {
|
|
369
|
+
return join(vaultHomeRoot(), "data", vaultName, "mirror-config.yaml");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Read a vault's mirror config from its per-vault file. Returns `undefined`
|
|
374
|
+
* when the file is absent (operator has never configured this vault's
|
|
375
|
+
* mirror) — distinct from "configured with enabled:false" so callers can
|
|
376
|
+
* tell "never touched" apart from "explicitly disabled" (same distinction
|
|
377
|
+
* `parseMirrorConfig` preserves). Callers that just want a usable config
|
|
378
|
+
* coalesce with `defaultMirrorConfig()`.
|
|
379
|
+
*/
|
|
380
|
+
export function readMirrorConfigForVault(vaultName: string): MirrorConfig | undefined {
|
|
381
|
+
const path = mirrorConfigPath(vaultName);
|
|
382
|
+
if (!existsSync(path)) return undefined;
|
|
383
|
+
try {
|
|
384
|
+
const raw = readFileSync(path, "utf8");
|
|
385
|
+
return parseMirrorConfig(raw);
|
|
386
|
+
} catch {
|
|
387
|
+
// Unreadable / malformed → treat as "no config" rather than crashing
|
|
388
|
+
// the boot path or a route. The operator can re-PUT to repair it.
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Persist a vault's mirror config to its per-vault file, atomically
|
|
395
|
+
* (write-temp → rename). Creates the vault data dir if missing (fresh
|
|
396
|
+
* installs / tests). The file is NOT secret (no tokens — those live in
|
|
397
|
+
* `.mirror-credentials.yaml`), so default perms are fine.
|
|
398
|
+
*/
|
|
399
|
+
export function writeMirrorConfigForVault(
|
|
400
|
+
vaultName: string,
|
|
401
|
+
config: MirrorConfig,
|
|
402
|
+
): void {
|
|
403
|
+
const path = mirrorConfigPath(vaultName);
|
|
404
|
+
const dir = dirname(path);
|
|
405
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
406
|
+
const body = serializeMirrorConfig(config).join("\n") + "\n";
|
|
407
|
+
const tmp = `${path}.tmp`;
|
|
408
|
+
writeFileSync(tmp, body);
|
|
409
|
+
renameSync(tmp, path);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Migration — legacy server-wide `mirror:` config → per-vault (vault#400)
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* One-time migration of the legacy server-wide `mirror:` config block to the
|
|
418
|
+
* per-vault layout (vault#400). Symmetric counterpart to vault#399's
|
|
419
|
+
* `migrateLegacyServerWideCredentials`.
|
|
420
|
+
*
|
|
421
|
+
* The bug: pre-vault#400, mirror config lived in a single `mirror:` block in
|
|
422
|
+
* `<configDir>/vault/config.yaml`, shared across ALL vaults. Every vault's
|
|
423
|
+
* mirror page rendered that one config (+ the owning vault's git remote);
|
|
424
|
+
* configuring vault B clobbered vault A.
|
|
425
|
+
*
|
|
426
|
+
* Attribution: attribute the legacy block to the mirror-owning vault
|
|
427
|
+
* (`resolveMirrorVaultName` = default_vault → first listed) — the same vault
|
|
428
|
+
* the single server-wide mirror was actually bound to. Other vaults start
|
|
429
|
+
* with no mirror config (they read `undefined` → default disabled).
|
|
430
|
+
*
|
|
431
|
+
* Safety:
|
|
432
|
+
* - No-op when no legacy block exists (fresh installs, already-migrated).
|
|
433
|
+
* - No-op when the target vault already has a per-vault config file (don't
|
|
434
|
+
* clobber config the operator set post-migration).
|
|
435
|
+
* - The legacy block is preserved (commented out in place) rather than
|
|
436
|
+
* silently dropped, so nothing is lost if attribution was wrong.
|
|
437
|
+
* - Logs the attribution decision clearly.
|
|
438
|
+
*
|
|
439
|
+
* This function is intentionally I/O-light on the config.yaml side: it takes
|
|
440
|
+
* the raw config.yaml text + a rewriter callback so it doesn't import
|
|
441
|
+
* `config.ts` (which imports this module). server.ts supplies the read/write.
|
|
442
|
+
*
|
|
443
|
+
* @param legacyConfig the `mirror:` block parsed from config.yaml, or
|
|
444
|
+
* undefined when there is none. server.ts passes `readGlobalConfig().mirror`.
|
|
445
|
+
* @param targetVaultName the vault to attribute the legacy config to
|
|
446
|
+
* (caller passes `resolveMirrorVaultName()`).
|
|
447
|
+
* @param commentOutLegacyBlock callback that rewrites config.yaml to comment
|
|
448
|
+
* out the `mirror:` block (so it isn't re-migrated + the operator can see
|
|
449
|
+
* the old values). Best-effort; failure is non-fatal.
|
|
450
|
+
* @returns a struct describing what happened, for logging + tests.
|
|
451
|
+
*/
|
|
452
|
+
export function migrateLegacyServerWideConfig(
|
|
453
|
+
legacyConfig: MirrorConfig | undefined,
|
|
454
|
+
targetVaultName: string | null,
|
|
455
|
+
commentOutLegacyBlock: () => void,
|
|
456
|
+
):
|
|
457
|
+
| { migrated: false; reason: "no_legacy_block" | "no_target_vault" | "target_already_configured" }
|
|
458
|
+
| { migrated: true; targetVaultName: string } {
|
|
459
|
+
if (!legacyConfig) {
|
|
460
|
+
return { migrated: false, reason: "no_legacy_block" };
|
|
461
|
+
}
|
|
462
|
+
if (!targetVaultName) {
|
|
463
|
+
// Legacy block present but no vault to attribute it to. Leave it in
|
|
464
|
+
// place — a future boot (once a vault exists) migrates it.
|
|
465
|
+
return { migrated: false, reason: "no_target_vault" };
|
|
466
|
+
}
|
|
467
|
+
const targetPath = mirrorConfigPath(targetVaultName);
|
|
468
|
+
if (existsSync(targetPath)) {
|
|
469
|
+
// Target vault already has per-vault config. Don't clobber. Still
|
|
470
|
+
// comment out the legacy block so we don't re-evaluate it every boot.
|
|
471
|
+
try {
|
|
472
|
+
commentOutLegacyBlock();
|
|
473
|
+
} catch {
|
|
474
|
+
// Non-fatal — worst case we re-check next boot and short-circuit here.
|
|
475
|
+
}
|
|
476
|
+
return { migrated: false, reason: "target_already_configured" };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
writeMirrorConfigForVault(targetVaultName, legacyConfig);
|
|
480
|
+
try {
|
|
481
|
+
commentOutLegacyBlock();
|
|
482
|
+
} catch {
|
|
483
|
+
// Non-fatal — the config is now in the per-vault file; the legacy block
|
|
484
|
+
// staying live would just re-migrate to the same place idempotently
|
|
485
|
+
// (the target_already_configured branch short-circuits next boot).
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
console.log(
|
|
489
|
+
`[mirror] migrated legacy server-wide mirror config → vault "${targetVaultName}" ` +
|
|
490
|
+
`(per-vault, vault#400). Other vaults start with no mirror config (configure each ` +
|
|
491
|
+
`separately). Legacy config.yaml \`mirror:\` block commented out (preserved for reference).`,
|
|
492
|
+
);
|
|
493
|
+
return { migrated: true, targetVaultName };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Pure string transform: comment out the server-wide `mirror:` block in a
|
|
498
|
+
* config.yaml string (vault#400 migration). Prefixes each line of the block
|
|
499
|
+
* with `# ` so the operator can still see the migrated values + nothing is
|
|
500
|
+
* silently dropped, and a subsequent boot's `parseMirrorConfig` no longer
|
|
501
|
+
* sees a LIVE block (so no re-migration — `parseMirrorConfig`'s anchor regex
|
|
502
|
+
* `^mirror:\s*$` doesn't match `# mirror:`).
|
|
503
|
+
*
|
|
504
|
+
* Block boundaries match the parser's stop rule exactly: the block starts at
|
|
505
|
+
* a 0-indent `mirror:` line and runs until the next 0-indent non-blank line
|
|
506
|
+
* (the next top-level key). Other top-level keys (port, default_vault, …) are
|
|
507
|
+
* left byte-for-byte intact; blank lines are preserved verbatim (not
|
|
508
|
+
* commented). Idempotent: an already-commented config has no live `mirror:`
|
|
509
|
+
* line, so it passes through unchanged.
|
|
510
|
+
*
|
|
511
|
+
* Extracted from server.ts (vault#408 review N3) so the YAML rewriting — which
|
|
512
|
+
* runs against the operator's real config.yaml — is directly unit-tested.
|
|
513
|
+
*/
|
|
514
|
+
export function commentOutMirrorBlock(yaml: string): string {
|
|
515
|
+
const lines = yaml.split("\n");
|
|
516
|
+
const out: string[] = [];
|
|
517
|
+
let inBlock = false;
|
|
518
|
+
for (const line of lines) {
|
|
519
|
+
if (!inBlock && /^mirror:\s*$/.test(line)) {
|
|
520
|
+
inBlock = true;
|
|
521
|
+
out.push(`# [vault#400] migrated to per-vault data/<vault>/mirror-config.yaml`);
|
|
522
|
+
out.push(`# ${line}`);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (inBlock) {
|
|
526
|
+
// The block runs until the next top-level (0-indent, non-blank) key.
|
|
527
|
+
if (/^\S/.test(line) && line.trim().length > 0) {
|
|
528
|
+
inBlock = false;
|
|
529
|
+
out.push(line);
|
|
530
|
+
} else if (line.trim().length === 0) {
|
|
531
|
+
// Preserve blank lines verbatim (don't comment them).
|
|
532
|
+
out.push(line);
|
|
533
|
+
} else {
|
|
534
|
+
out.push(`# ${line}`);
|
|
535
|
+
}
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
out.push(line);
|
|
539
|
+
}
|
|
540
|
+
return out.join("\n");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* File-I/O wrapper around `commentOutMirrorBlock`: read config.yaml at
|
|
545
|
+
* `configPath`, comment out its `mirror:` block, write it back. No-op when
|
|
546
|
+
* the file is absent. server.ts passes this (bound to GLOBAL_CONFIG_PATH) as
|
|
547
|
+
* the migration's `commentOutLegacyBlock` callback. Best-effort — callers
|
|
548
|
+
* treat a throw as non-fatal.
|
|
549
|
+
*/
|
|
550
|
+
export function commentOutLegacyMirrorBlockFile(configPath: string): void {
|
|
551
|
+
if (!existsSync(configPath)) return;
|
|
552
|
+
const yaml = readFileSync(configPath, "utf8");
|
|
553
|
+
writeFileSync(configPath, commentOutMirrorBlock(yaml));
|
|
554
|
+
}
|
|
555
|
+
|
|
315
556
|
// ---------------------------------------------------------------------------
|
|
316
557
|
// Path resolution
|
|
317
558
|
// ---------------------------------------------------------------------------
|
|
@@ -387,7 +628,17 @@ export type ShapeValidation = ShapeValidationOk | ShapeValidationError;
|
|
|
387
628
|
*/
|
|
388
629
|
export function validateMirrorConfigShape(
|
|
389
630
|
input: unknown,
|
|
390
|
-
opts: {
|
|
631
|
+
opts: {
|
|
632
|
+
/**
|
|
633
|
+
* Vault whose credentials gate `auto_push + internal`. Required for the
|
|
634
|
+
* production credential read (per-vault since vault#399). Omitting it
|
|
635
|
+
* AND `readCredentials` means the auto_push/internal credential check
|
|
636
|
+
* treats credentials as absent (fail-closed) — fine for callers that
|
|
637
|
+
* never set that combination.
|
|
638
|
+
*/
|
|
639
|
+
vaultName?: string;
|
|
640
|
+
readCredentials?: () => MirrorCredentials | null;
|
|
641
|
+
} = {},
|
|
391
642
|
): ShapeValidation {
|
|
392
643
|
if (input === null || typeof input !== "object") {
|
|
393
644
|
return {
|
|
@@ -583,7 +834,12 @@ export function validateMirrorConfigShape(
|
|
|
583
834
|
// dir, so vault IS the only thing that can wire a remote, which is
|
|
584
835
|
// why the gate below requires vault-stored credentials specifically.
|
|
585
836
|
if (out.enabled && out.auto_push && out.location === "internal") {
|
|
586
|
-
|
|
837
|
+
// Per-vault credentials (vault#399): bind the read to opts.vaultName.
|
|
838
|
+
// When neither an injected reader nor a vaultName is supplied, treat
|
|
839
|
+
// credentials as absent (fail-closed) rather than reading a wrong file.
|
|
840
|
+
const readCreds =
|
|
841
|
+
opts.readCredentials ??
|
|
842
|
+
(opts.vaultName ? () => readCredentials(opts.vaultName!) : () => null);
|
|
587
843
|
let creds: MirrorCredentials | null = null;
|
|
588
844
|
try {
|
|
589
845
|
creds = readCreds();
|
|
@@ -633,7 +889,17 @@ export type PathValidation = PathValidationOk | PathValidationError;
|
|
|
633
889
|
*/
|
|
634
890
|
export async function validateExternalPath(
|
|
635
891
|
externalPath: string,
|
|
892
|
+
// Test seam for the git-presence preflight (default `Bun.which`). Inject a
|
|
893
|
+
// fn returning `null` to exercise the git-not-installed path.
|
|
894
|
+
which?: (cmd: string) => string | null,
|
|
636
895
|
): Promise<PathValidation> {
|
|
896
|
+
// Preflight: the git-repo check below shells `git`. On a git-less server,
|
|
897
|
+
// throw the friendly, actionable GitNotInstalledError (which handleMirrorPut
|
|
898
|
+
// maps to a 503 `git_not_installed`, consistent with the import route)
|
|
899
|
+
// instead of letting `isGitRepo`'s `Bun.spawn` throw a raw
|
|
900
|
+
// "Executable not found in $PATH: \"git\"".
|
|
901
|
+
ensureGitAvailable(which);
|
|
902
|
+
|
|
637
903
|
if (!existsSync(externalPath)) {
|
|
638
904
|
return {
|
|
639
905
|
ok: false,
|
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
deleteCredentials,
|
|
26
26
|
emptyCredentials,
|
|
27
27
|
githubAuthedRemoteUrl,
|
|
28
|
+
legacyServerWideCredentialsPath,
|
|
29
|
+
migrateLegacyServerWideCredentials,
|
|
28
30
|
mirrorCredentialsPath,
|
|
29
31
|
parseCredentials,
|
|
30
32
|
previewToken,
|
|
@@ -194,7 +196,7 @@ describe("serialize + parse round-trip", () => {
|
|
|
194
196
|
describe("readCredentials / writeCredentials", () => {
|
|
195
197
|
test("returns null when no file exists", () => {
|
|
196
198
|
withSandbox(() => {
|
|
197
|
-
expect(readCredentials()).toBeNull();
|
|
199
|
+
expect(readCredentials("default")).toBeNull();
|
|
198
200
|
});
|
|
199
201
|
});
|
|
200
202
|
|
|
@@ -211,8 +213,8 @@ describe("readCredentials / writeCredentials", () => {
|
|
|
211
213
|
},
|
|
212
214
|
pat: null,
|
|
213
215
|
};
|
|
214
|
-
writeCredentials(creds);
|
|
215
|
-
const read = readCredentials();
|
|
216
|
+
writeCredentials("default", creds);
|
|
217
|
+
const read = readCredentials("default");
|
|
216
218
|
expect(read).toEqual(creds);
|
|
217
219
|
});
|
|
218
220
|
});
|
|
@@ -228,8 +230,8 @@ describe("readCredentials / writeCredentials", () => {
|
|
|
228
230
|
label: "test",
|
|
229
231
|
},
|
|
230
232
|
};
|
|
231
|
-
writeCredentials(creds);
|
|
232
|
-
const p = mirrorCredentialsPath();
|
|
233
|
+
writeCredentials("default", creds);
|
|
234
|
+
const p = mirrorCredentialsPath("default");
|
|
233
235
|
const stat = fs.statSync(p);
|
|
234
236
|
const perms = stat.mode & 0o777;
|
|
235
237
|
expect(perms).toBe(0o600);
|
|
@@ -241,15 +243,52 @@ describe("readCredentials / writeCredentials", () => {
|
|
|
241
243
|
process.env.PARACHUTE_HOME = home;
|
|
242
244
|
process.env.HOME = home;
|
|
243
245
|
try {
|
|
244
|
-
// Note: don't pre-create the vault subdir.
|
|
246
|
+
// Note: don't pre-create the vault data subdir.
|
|
245
247
|
const creds: MirrorCredentials = { ...emptyCredentials() };
|
|
246
|
-
writeCredentials(creds);
|
|
247
|
-
expect(
|
|
248
|
+
writeCredentials("default", creds);
|
|
249
|
+
expect(
|
|
250
|
+
fs.existsSync(
|
|
251
|
+
path.join(home, "vault", "data", "default", ".mirror-credentials.yaml"),
|
|
252
|
+
),
|
|
253
|
+
).toBe(true);
|
|
248
254
|
} finally {
|
|
249
255
|
fs.rmSync(home, { recursive: true, force: true });
|
|
250
256
|
}
|
|
251
257
|
});
|
|
252
258
|
|
|
259
|
+
test("two vaults' credentials are isolated — no bleed (vault#399)", () => {
|
|
260
|
+
withSandbox(() => {
|
|
261
|
+
const aCreds: MirrorCredentials = {
|
|
262
|
+
...emptyCredentials(),
|
|
263
|
+
active_method: "pat",
|
|
264
|
+
pat: {
|
|
265
|
+
token: "ghp_vaultA1234567890abc",
|
|
266
|
+
remote_url: "https://x-access-token:ghp_vaultA1234567890abc@github.com/aaron/vault-a.git",
|
|
267
|
+
label: "vault A",
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
const bCreds: MirrorCredentials = {
|
|
271
|
+
...emptyCredentials(),
|
|
272
|
+
active_method: "pat",
|
|
273
|
+
pat: {
|
|
274
|
+
token: "ghp_vaultB9876543210xyz",
|
|
275
|
+
remote_url: "https://x-access-token:ghp_vaultB9876543210xyz@github.com/aaron/vault-b.git",
|
|
276
|
+
label: "vault B",
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
writeCredentials("alpha", aCreds);
|
|
280
|
+
writeCredentials("beta", bCreds);
|
|
281
|
+
// Each vault reads back ITS OWN remote + token — never the other's.
|
|
282
|
+
expect(readCredentials("alpha")).toEqual(aCreds);
|
|
283
|
+
expect(readCredentials("beta")).toEqual(bCreds);
|
|
284
|
+
expect(readCredentials("alpha")?.pat?.remote_url).toContain("vault-a.git");
|
|
285
|
+
expect(readCredentials("beta")?.pat?.remote_url).toContain("vault-b.git");
|
|
286
|
+
expect(readCredentials("alpha")?.pat?.token).not.toBe(readCredentials("beta")?.pat?.token);
|
|
287
|
+
// Files live in separate per-vault dirs.
|
|
288
|
+
expect(mirrorCredentialsPath("alpha")).not.toBe(mirrorCredentialsPath("beta"));
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
253
292
|
test("write is atomic — partial file not left on failure path", () => {
|
|
254
293
|
withSandbox(() => {
|
|
255
294
|
const creds: MirrorCredentials = {
|
|
@@ -263,17 +302,17 @@ describe("readCredentials / writeCredentials", () => {
|
|
|
263
302
|
},
|
|
264
303
|
pat: null,
|
|
265
304
|
};
|
|
266
|
-
writeCredentials(creds);
|
|
305
|
+
writeCredentials("default", creds);
|
|
267
306
|
// Subsequent write replaces atomically (.tmp rename onto final).
|
|
268
307
|
const updated: MirrorCredentials = {
|
|
269
308
|
...creds,
|
|
270
309
|
github_oauth: { ...creds.github_oauth!, access_token: "gho_updated123456789" },
|
|
271
310
|
};
|
|
272
|
-
writeCredentials(updated);
|
|
273
|
-
const read = readCredentials();
|
|
311
|
+
writeCredentials("default", updated);
|
|
312
|
+
const read = readCredentials("default");
|
|
274
313
|
expect(read?.github_oauth?.access_token).toBe("gho_updated123456789");
|
|
275
314
|
// No leftover .tmp file.
|
|
276
|
-
expect(fs.existsSync(`${mirrorCredentialsPath()}.tmp`)).toBe(false);
|
|
315
|
+
expect(fs.existsSync(`${mirrorCredentialsPath("default")}.tmp`)).toBe(false);
|
|
277
316
|
});
|
|
278
317
|
});
|
|
279
318
|
});
|
|
@@ -281,20 +320,132 @@ describe("readCredentials / writeCredentials", () => {
|
|
|
281
320
|
describe("deleteCredentials", () => {
|
|
282
321
|
test("idempotent — missing file is a no-op (doesn't throw)", () => {
|
|
283
322
|
withSandbox(() => {
|
|
284
|
-
expect(() => deleteCredentials()).not.toThrow();
|
|
323
|
+
expect(() => deleteCredentials("default")).not.toThrow();
|
|
285
324
|
});
|
|
286
325
|
});
|
|
287
326
|
|
|
288
327
|
test("removes the file when present", () => {
|
|
289
328
|
withSandbox(() => {
|
|
290
|
-
writeCredentials({ ...emptyCredentials(), active_method: null });
|
|
291
|
-
expect(fs.existsSync(mirrorCredentialsPath())).toBe(true);
|
|
292
|
-
deleteCredentials();
|
|
293
|
-
expect(fs.existsSync(mirrorCredentialsPath())).toBe(false);
|
|
329
|
+
writeCredentials("default", { ...emptyCredentials(), active_method: null });
|
|
330
|
+
expect(fs.existsSync(mirrorCredentialsPath("default"))).toBe(true);
|
|
331
|
+
deleteCredentials("default");
|
|
332
|
+
expect(fs.existsSync(mirrorCredentialsPath("default"))).toBe(false);
|
|
294
333
|
});
|
|
295
334
|
});
|
|
296
335
|
});
|
|
297
336
|
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Migration — legacy server-wide → per-vault (vault#399)
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
describe("migrateLegacyServerWideCredentials", () => {
|
|
342
|
+
function writeLegacyFile(_home: string, creds: MirrorCredentials): string {
|
|
343
|
+
const legacyPath = legacyServerWideCredentialsPath();
|
|
344
|
+
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
|
|
345
|
+
fs.writeFileSync(legacyPath, serializeCredentials(creds), { mode: 0o600 });
|
|
346
|
+
return legacyPath;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const sampleCreds: MirrorCredentials = {
|
|
350
|
+
...emptyCredentials(),
|
|
351
|
+
active_method: "pat",
|
|
352
|
+
pat: {
|
|
353
|
+
token: "ghp_legacy1234567890abc",
|
|
354
|
+
remote_url: "https://x-access-token:ghp_legacy1234567890abc@github.com/aaron/first-vault.git",
|
|
355
|
+
label: "legacy",
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
test("no legacy file → no-op", () => {
|
|
360
|
+
withSandbox(() => {
|
|
361
|
+
const r = migrateLegacyServerWideCredentials("default");
|
|
362
|
+
expect(r.migrated).toBe(false);
|
|
363
|
+
expect((r as { reason: string }).reason).toBe("no_legacy_file");
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("legacy file → attributed to the FIRST vault, others stay empty", () => {
|
|
368
|
+
const home = tmp("mirror-migrate-");
|
|
369
|
+
process.env.PARACHUTE_HOME = home;
|
|
370
|
+
process.env.HOME = home;
|
|
371
|
+
try {
|
|
372
|
+
const legacyPath = writeLegacyFile(home, sampleCreds);
|
|
373
|
+
const r = migrateLegacyServerWideCredentials("first");
|
|
374
|
+
expect(r.migrated).toBe(true);
|
|
375
|
+
// First vault now owns the legacy creds.
|
|
376
|
+
expect(readCredentials("first")).toEqual(sampleCreds);
|
|
377
|
+
// A second vault that never had its own file starts EMPTY — the bug
|
|
378
|
+
// would have made it read the same remote. It must not.
|
|
379
|
+
expect(readCredentials("second")).toBeNull();
|
|
380
|
+
// Legacy file preserved as .bak — nothing silently lost.
|
|
381
|
+
expect(fs.existsSync(legacyPath)).toBe(false);
|
|
382
|
+
expect(fs.existsSync(`${legacyPath}.bak`)).toBe(true);
|
|
383
|
+
} finally {
|
|
384
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("idempotent — second run is a no-op once target has creds", () => {
|
|
389
|
+
const home = tmp("mirror-migrate-idem-");
|
|
390
|
+
process.env.PARACHUTE_HOME = home;
|
|
391
|
+
process.env.HOME = home;
|
|
392
|
+
try {
|
|
393
|
+
writeLegacyFile(home, sampleCreds);
|
|
394
|
+
expect(migrateLegacyServerWideCredentials("first").migrated).toBe(true);
|
|
395
|
+
// Run again: no legacy file remains, so no-op.
|
|
396
|
+
const r2 = migrateLegacyServerWideCredentials("first");
|
|
397
|
+
expect(r2.migrated).toBe(false);
|
|
398
|
+
// First vault's creds unchanged.
|
|
399
|
+
expect(readCredentials("first")).toEqual(sampleCreds);
|
|
400
|
+
} finally {
|
|
401
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("does not clobber an existing per-vault file", () => {
|
|
406
|
+
const home = tmp("mirror-migrate-noclobber-");
|
|
407
|
+
process.env.PARACHUTE_HOME = home;
|
|
408
|
+
process.env.HOME = home;
|
|
409
|
+
try {
|
|
410
|
+
writeLegacyFile(home, sampleCreds);
|
|
411
|
+
// Target vault already has its own (different) creds.
|
|
412
|
+
const existing: MirrorCredentials = {
|
|
413
|
+
...emptyCredentials(),
|
|
414
|
+
active_method: "pat",
|
|
415
|
+
pat: {
|
|
416
|
+
token: "ghp_already1234567890ab",
|
|
417
|
+
remote_url: "https://x-access-token:ghp_already1234567890ab@github.com/aaron/already.git",
|
|
418
|
+
label: "already",
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
writeCredentials("first", existing);
|
|
422
|
+
const r = migrateLegacyServerWideCredentials("first");
|
|
423
|
+
expect(r.migrated).toBe(false);
|
|
424
|
+
expect((r as { reason: string }).reason).toBe("target_already_has_creds");
|
|
425
|
+
// Existing per-vault creds preserved, NOT overwritten by legacy.
|
|
426
|
+
expect(readCredentials("first")).toEqual(existing);
|
|
427
|
+
} finally {
|
|
428
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("no target vault → leaves legacy file in place for a later boot", () => {
|
|
433
|
+
const home = tmp("mirror-migrate-notarget-");
|
|
434
|
+
process.env.PARACHUTE_HOME = home;
|
|
435
|
+
process.env.HOME = home;
|
|
436
|
+
try {
|
|
437
|
+
const legacyPath = writeLegacyFile(home, sampleCreds);
|
|
438
|
+
const r = migrateLegacyServerWideCredentials(null);
|
|
439
|
+
expect(r.migrated).toBe(false);
|
|
440
|
+
expect((r as { reason: string }).reason).toBe("no_target_vault");
|
|
441
|
+
// Legacy file untouched — a future boot (after a vault exists) migrates.
|
|
442
|
+
expect(fs.existsSync(legacyPath)).toBe(true);
|
|
443
|
+
} finally {
|
|
444
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
298
449
|
// ---------------------------------------------------------------------------
|
|
299
450
|
// Redaction
|
|
300
451
|
// ---------------------------------------------------------------------------
|