@openparachute/vault 0.6.1 → 0.6.2-rc.1

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.
@@ -5,13 +5,32 @@
5
5
  */
6
6
 
7
7
  export type InitSummaryInput = {
8
+ /**
9
+ * Whether init WROTE the Claude Code MCP config (~/.claude.json) this run.
10
+ * As of 2026-06-23 this is opt-in (default false) — init's primary job is to
11
+ * point the operator at the web setup wizard and surface the self-serve
12
+ * connect info, not to write a config file as a side effect.
13
+ */
8
14
  addMcp: boolean;
9
15
  addToken: boolean;
10
16
  apiKey: string | undefined;
11
17
  configDir: string;
12
18
  bindHost: string;
13
19
  port: number;
20
+ /**
21
+ * The vault's MCP connector URL — `<hub-origin>/vault/<name>/mcp` (hub-origin
22
+ * / expose-state aware). Surfaced in the summary for self-serve copy-paste:
23
+ * a ready-to-run `claude mcp add ...` command is built from it so a Claude
24
+ * Code user can opt in by pasting one line, AND it's printed plain so any
25
+ * other MCP client can be pointed at it.
26
+ */
14
27
  mcpUrl: string;
28
+ /**
29
+ * The web setup wizard URL — `<hub-origin>/admin/setup`. init's primary job
30
+ * is to get the operator into this wizard, so it's printed prominently at the
31
+ * top of the summary.
32
+ */
33
+ wizardUrl?: string | undefined;
15
34
  /**
16
35
  * The default vault's name — used to emit the three-segment
17
36
  * `vault:<vaultName>:read` scope in the OAuth-first mint-token suggestion
@@ -22,8 +41,8 @@ export type InitSummaryInput = {
22
41
  /**
23
42
  * Guidance from the bootstrap-credential step when no token could be issued
24
43
  * (standalone install, no hub reachable — vault#282 Stage 2). Surfaced when
25
- * the operator wanted a token (`addMcp || addToken`) but `apiKey` is
26
- * undefined, so they know why and how to make the vault reachable.
44
+ * the operator wanted a token (`addToken`) but `apiKey` is undefined, so they
45
+ * know why and how to make the vault reachable.
27
46
  */
28
47
  noTokenGuidance?: string | undefined;
29
48
  /**
@@ -38,54 +57,66 @@ export type InitSummaryInput = {
38
57
  };
39
58
 
40
59
  /**
41
- * Build the post-install summary lines for `vault init`, branched on the
42
- * (addMcp, addToken, apiKey) decision matrix.
60
+ * Build the post-install summary lines for `vault init`.
61
+ *
62
+ * 2026-06-23 messaging realignment: the site no longer claims "Claude Code is
63
+ * auto-configured," and init no longer writes `~/.claude.json` by default.
64
+ * init's job is to (1) point the operator at the web setup wizard, and
65
+ * (2) SURFACE the self-serve connect info — the connector URL + a ready-to-paste
66
+ * `claude mcp add ...` line — so a Claude Code user opts in by copy-paste rather
67
+ * than a silent side effect.
43
68
  *
44
- * vault#442: the DEFAULT is per-user OAuth no token is minted, and the
45
- * Claude Code MCP entry is written without a baked bearer (browser sign-in on
46
- * first connect). A token is minted only on explicit opt-in (`addToken`), and
47
- * then scope-narrow. Branches:
69
+ * The summary is built in three parts:
70
+ * 1. Wizard hand-off (always) "finish setup in your browser: <wizardUrl>".
71
+ * 2. Connect-your-AI block the connector URL + copy-paste `claude mcp add`,
72
+ * branched on whether init wrote the MCP entry / minted a token.
73
+ * 3. Config / server / next-steps footer.
48
74
  *
49
- * addMcp, !apiKey → OAuth-first: connect, sign in on first use
50
- * addMcp, addToken, apiKey → token baked into claude.json + printed
51
- * addMcp, !addToken, apiKey → token baked into claude.json, hint
52
- * !addMcp, addToken, apiKey → token printed prominently
53
- * !addMcp, addToken, !apiKey → opted into a token but no hub reachable
54
- * !addMcp, !addToken → OAuth-first: add Claude Code later
75
+ * vault#442: per-user OAuth is the default — no token is minted unless the
76
+ * operator opts in (`addToken`), and then it's scope-narrow.
55
77
  */
56
78
  export function buildInitSummaryLines(input: InitSummaryInput): string[] {
57
- const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, vaultName, noTokenGuidance, hubPresent } = input;
79
+ const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, wizardUrl, vaultName, noTokenGuidance, hubPresent } = input;
58
80
  const lines: string[] = [];
59
81
  lines.push("");
60
82
  lines.push("---");
61
83
 
62
- if (addMcp && apiKey && addToken) {
84
+ // 1. Wizard hand-off the primary purpose of init is to get the operator
85
+ // into the web setup wizard. Lead with it.
86
+ if (wizardUrl) {
63
87
  lines.push("");
64
- lines.push(`Your API token: ${apiKey}`);
65
- lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
66
- lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
67
- lines.push(` - Won't be shown again — save it now.`);
68
- } else if (addMcp && apiKey && !addToken) {
69
- lines.push("");
70
- lines.push(
71
- "Token in ~/.claude.json; run `parachute-vault mcp-install` later if you need one for other clients.",
72
- );
73
- } else if (addMcp && !apiKey) {
74
- // vault#442 default: OAuth-first. The MCP entry is wired without a bearer —
75
- // Claude Code signs in via browser OAuth on first connect. No token needed.
76
- lines.push("");
77
- lines.push("Connect your AI — no token needed, you'll sign in on first use:");
78
- lines.push(` Claude Code is already wired in (~/.claude.json) — just start a session.`);
79
- lines.push(` Other clients: claude mcp add --transport http parachute-vault ${mcpUrl}`);
80
- lines.push(` Need a header-auth token for a script? parachute auth mint-token --scope vault:${vaultName}:read`);
81
- } else if (!addMcp && addToken && apiKey) {
88
+ lines.push("Finish setup in your browser:");
89
+ lines.push(` ${wizardUrl}`);
90
+ }
91
+
92
+ // The copy-paste opt-in line for Claude Code (and any client that speaks the
93
+ // `claude mcp add` form). Built from the connector URL so it's the real
94
+ // endpoint, hub-origin aware.
95
+ const claudeAddCmd = `claude mcp add --transport http parachute-vault ${mcpUrl}`;
96
+
97
+ // 2. Connect-your-AI surface the self-serve connect info every time.
98
+ if (addToken && apiKey) {
99
+ // Operator opted into a header-auth token AND it was minted. Surface it
100
+ // prominently (won't be shown again), plus the connector URL.
82
101
  lines.push("");
83
102
  lines.push(`Your API token: ${apiKey}`);
84
- lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
103
+ if (addMcp) {
104
+ lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
105
+ }
106
+ lines.push(` - Paste into another MCP client's config, or use as Authorization: Bearer <token>`);
85
107
  lines.push(` - Won't be shown again — save it now.`);
86
- } else if (!addMcp && addToken && !apiKey) {
108
+ lines.push("");
109
+ lines.push("Connector URL (point any MCP client here):");
110
+ lines.push(` ${mcpUrl}`);
111
+ if (!addMcp) {
112
+ lines.push("");
113
+ lines.push("Add Claude Code by copy-paste:");
114
+ lines.push(` ${claudeAddCmd}`);
115
+ }
116
+ } else if (addToken && !apiKey) {
87
117
  // Explicitly opted into a token but none was minted (vault#282 Stage 2 —
88
- // vault no longer mints local pvt_* tokens). Surface why + recovery.
118
+ // vault no longer mints local pvt_* tokens). Surface why + recovery, then
119
+ // still print the self-serve connect info.
89
120
  lines.push("");
90
121
  lines.push(
91
122
  noTokenGuidance ??
@@ -113,15 +144,23 @@ export function buildInitSummaryLines(input: InitSummaryInput): string[] {
113
144
  " or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
114
145
  );
115
146
  }
116
- } else if (!addMcp && !addToken) {
117
- // OAuth-first, but the operator skipped wiring Claude Code too.
118
147
  lines.push("");
119
- lines.push(
120
- "Skipped the Claude Code MCP entry. Add it anytime — it uses per-user OAuth, no token needed:",
121
- );
122
- lines.push(
123
- " parachute-vault mcp-install",
124
- );
148
+ lines.push("Connect your AI — no token needed, you'll sign in on first use:");
149
+ lines.push(` Connector URL: ${mcpUrl}`);
150
+ lines.push(` Claude Code: ${claudeAddCmd}`);
151
+ } else {
152
+ // Default path (no token). Per-user OAuth — sign in on first connect.
153
+ lines.push("");
154
+ lines.push("Connect your AI — no token needed, you'll sign in on first use:");
155
+ lines.push(` Connector URL: ${mcpUrl}`);
156
+ if (addMcp) {
157
+ lines.push(` Claude Code is already wired in (~/.claude.json) — just start a session.`);
158
+ lines.push(` Other clients: ${claudeAddCmd}`);
159
+ } else {
160
+ lines.push(` Claude Code: ${claudeAddCmd}`);
161
+ lines.push(` Other clients (Codex, Goose, OpenCode, Cursor, Zed, Cline): point them at the connector URL above.`);
162
+ }
163
+ lines.push(` Need a header-auth token for a script? parachute auth mint-token --scope vault:${vaultName}:read`);
125
164
  }
126
165
 
127
166
  lines.push("");
@@ -137,19 +176,15 @@ export function buildInitSummaryLines(input: InitSummaryInput): string[] {
137
176
 
138
177
  lines.push("");
139
178
  lines.push(`Next steps:`);
179
+ if (wizardUrl) {
180
+ lines.push(` - Finish setup in the web wizard: ${wizardUrl}`);
181
+ }
140
182
  if (addMcp) {
141
183
  lines.push(` - Start a new Claude Code session — your Vault is already wired in. Try:`);
142
184
  lines.push(` claude "Help me set up my parachute vault"`);
143
- lines.push(` - Or point any other local MCP client (Codex, Goose, OpenCode, Cursor,`);
144
- lines.push(` Zed, Cline, your own agent) at:`);
145
- lines.push(` ${mcpUrl}`);
146
- } else if (addToken) {
147
- lines.push(` - Point any local MCP client (Codex, Goose, OpenCode, Cursor, Zed,`);
148
- lines.push(` Cline, your own agent) at:`);
149
- lines.push(` ${mcpUrl}`);
150
- lines.push(` - Or add Claude Code back anytime: parachute-vault mcp-install`);
151
185
  } else {
152
- lines.push(` - Add Claude Code: parachute-vault mcp-install`);
186
+ lines.push(` - Wire Claude Code (copy-paste): ${claudeAddCmd}`);
187
+ lines.push(` or run the guided installer: parachute-vault mcp-install`);
153
188
  }
154
189
  lines.push(` - Check status: parachute-vault status`);
155
190
  lines.push(` - Edit config: parachute-vault config`);
package/src/init.test.ts CHANGED
@@ -72,6 +72,134 @@ describe("vault init — --help mentions --vault-name", () => {
72
72
  expect(exitCode).toBe(0);
73
73
  expect(stdout).toContain("--no-autostart");
74
74
  });
75
+
76
+ test("usage text documents the opt-in --configure-claude-code flag (2026-06-23)", () => {
77
+ const { exitCode, stdout } = runCli(["--help"]);
78
+ expect(exitCode).toBe(0);
79
+ expect(stdout).toContain("--configure-claude-code");
80
+ });
81
+ });
82
+
83
+ /**
84
+ * 2026-06-23 messaging realignment: writing the Claude Code MCP config
85
+ * (~/.claude.json) is now OPT-IN. init's default no longer writes it — it
86
+ * surfaces the connector URL + a copy-paste `claude mcp add` command and points
87
+ * at the web wizard instead. These run init end-to-end under an isolated HOME /
88
+ * PARACHUTE_HOME (with --no-autostart --no-token to keep daemon + token side
89
+ * effects out) and assert the ~/.claude.json side effect on the default vs the
90
+ * opt-in path. Non-interactive (piped) is the mode these spawned subprocesses
91
+ * run in, which is exactly the back-compat-sensitive path.
92
+ */
93
+ describe("vault init — Claude Code MCP config is opt-in (2026-06-23)", () => {
94
+ test("default (no --mcp flag) does NOT write ~/.claude.json, and surfaces copy-paste connect info", () => {
95
+ const sandbox = mkdtempSync(join(tmpdir(), "vault-init-mcp-optin-"));
96
+ try {
97
+ const parachuteHome = join(sandbox, ".parachute");
98
+ const claudeJson = join(sandbox, ".claude.json");
99
+ const { exitCode, stdout } = runCli(
100
+ [
101
+ "init",
102
+ "--no-autostart",
103
+ "--no-token",
104
+ "--vault-name",
105
+ "mcpoptin",
106
+ ],
107
+ { HOME: sandbox, PARACHUTE_HOME: parachuteHome },
108
+ );
109
+
110
+ expect(exitCode).toBe(0);
111
+ // The headline behavior: no silent ~/.claude.json write.
112
+ expect(existsSync(claudeJson)).toBe(false);
113
+ expect(stdout).not.toContain("MCP server added to ~/.claude.json");
114
+ // But the self-serve connect info IS surfaced for copy-paste.
115
+ expect(stdout).toContain("claude mcp add --transport http");
116
+ expect(stdout).toContain("/vault/mcpoptin/mcp");
117
+ // And the web wizard hand-off is present.
118
+ expect(stdout).toContain("Finish setup in your browser:");
119
+ } finally {
120
+ rmSync(sandbox, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test("--configure-claude-code opts in and writes ~/.claude.json", () => {
125
+ const sandbox = mkdtempSync(join(tmpdir(), "vault-init-mcp-optin-"));
126
+ try {
127
+ const parachuteHome = join(sandbox, ".parachute");
128
+ const claudeJson = join(sandbox, ".claude.json");
129
+ const { exitCode, stdout } = runCli(
130
+ [
131
+ "init",
132
+ "--no-autostart",
133
+ "--no-token",
134
+ "--configure-claude-code",
135
+ "--vault-name",
136
+ "mcpoptin",
137
+ ],
138
+ { HOME: sandbox, PARACHUTE_HOME: parachuteHome },
139
+ );
140
+
141
+ expect(exitCode).toBe(0);
142
+ expect(existsSync(claudeJson)).toBe(true);
143
+ const claudeConfig = JSON.parse(readFileSync(claudeJson, "utf-8"));
144
+ // The user-scope entry is keyed `parachute-vault` under top-level mcpServers.
145
+ expect(claudeConfig.mcpServers?.["parachute-vault"]?.url).toContain(
146
+ "/vault/mcpoptin/mcp",
147
+ );
148
+ expect(stdout).toContain("MCP server added to ~/.claude.json");
149
+ } finally {
150
+ rmSync(sandbox, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ for (const alias of ["--mcp", "--mcp-install"]) {
155
+ test(`the ${alias} alias still opts in`, () => {
156
+ const sandbox = mkdtempSync(join(tmpdir(), "vault-init-mcp-optin-"));
157
+ try {
158
+ const parachuteHome = join(sandbox, ".parachute");
159
+ const claudeJson = join(sandbox, ".claude.json");
160
+ const { exitCode } = runCli(
161
+ [
162
+ "init",
163
+ "--no-autostart",
164
+ "--no-token",
165
+ alias,
166
+ "--vault-name",
167
+ "mcpoptin",
168
+ ],
169
+ { HOME: sandbox, PARACHUTE_HOME: parachuteHome },
170
+ );
171
+ expect(exitCode).toBe(0);
172
+ expect(existsSync(claudeJson)).toBe(true);
173
+ } finally {
174
+ rmSync(sandbox, { recursive: true, force: true });
175
+ }
176
+ });
177
+ }
178
+
179
+ test("--no-mcp wins over an opt-in alias on the same command line", () => {
180
+ const sandbox = mkdtempSync(join(tmpdir(), "vault-init-mcp-optin-"));
181
+ try {
182
+ const parachuteHome = join(sandbox, ".parachute");
183
+ const claudeJson = join(sandbox, ".claude.json");
184
+ const { exitCode } = runCli(
185
+ [
186
+ "init",
187
+ "--no-autostart",
188
+ "--no-token",
189
+ "--configure-claude-code",
190
+ "--no-mcp",
191
+ "--vault-name",
192
+ "mcpoptin",
193
+ ],
194
+ { HOME: sandbox, PARACHUTE_HOME: parachuteHome },
195
+ );
196
+ expect(exitCode).toBe(0);
197
+ // --no-mcp is the safer default and must win — no file written.
198
+ expect(existsSync(claudeJson)).toBe(false);
199
+ } finally {
200
+ rmSync(sandbox, { recursive: true, force: true });
201
+ }
202
+ });
75
203
  });
76
204
 
77
205
  /**
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Tests for the cross-vault remote-clobber guard (vault#482).
3
+ *
4
+ * Two layers:
5
+ * - Pure normalization / equivalence (`normalizeRemoteIdentity`,
6
+ * `sameRemoteIdentity`) — https vs ssh vs scp-shorthand vs .git-suffix.
7
+ * - `findConflictingVault` against real on-disk vault state under a temp
8
+ * PARACHUTE_HOME (no network, no live manager).
9
+ */
10
+
11
+ import { describe, test, expect, afterEach } from "bun:test";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+
16
+ import {
17
+ normalizeRemoteIdentity,
18
+ sameRemoteIdentity,
19
+ claimedRemoteOf,
20
+ findConflictingVault,
21
+ } from "./mirror-remote-guard.ts";
22
+ import { writeMirrorConfigForVault, defaultMirrorConfig } from "./mirror-config.ts";
23
+ import { writeCredentials } from "./mirror-credentials.ts";
24
+
25
+ const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
26
+ const ORIG_HOME = process.env.HOME;
27
+ afterEach(() => {
28
+ if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
29
+ else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
30
+ if (ORIG_HOME === undefined) delete process.env.HOME;
31
+ else process.env.HOME = ORIG_HOME;
32
+ });
33
+
34
+ function tmp(prefix: string): string {
35
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
36
+ }
37
+
38
+ /**
39
+ * Stand up a vault on disk under the active PARACHUTE_HOME so `listVaults()`
40
+ * sees it: create `data/<name>/vault.yaml`. Optionally seed an enabled
41
+ * internal mirror config + a mirror-dir `origin` and/or a PAT credential.
42
+ */
43
+ function seedVault(
44
+ name: string,
45
+ opts: {
46
+ origin?: string;
47
+ pat?: string;
48
+ enabledMirror?: boolean;
49
+ } = {},
50
+ ): void {
51
+ const home = process.env.PARACHUTE_HOME!;
52
+ const vaultDataDir = path.join(home, "vault", "data", name);
53
+ fs.mkdirSync(vaultDataDir, { recursive: true });
54
+ // Minimal vault.yaml so listVaults() recognizes it.
55
+ fs.writeFileSync(path.join(vaultDataDir, "vault.yaml"), `name: ${name}\n`);
56
+
57
+ if (opts.enabledMirror !== false && (opts.origin || opts.pat)) {
58
+ writeMirrorConfigForVault(name, {
59
+ ...defaultMirrorConfig(),
60
+ enabled: true,
61
+ location: "internal",
62
+ });
63
+ }
64
+
65
+ if (opts.origin) {
66
+ // Write the mirror dir's git config origin straight to disk — no git
67
+ // spawn needed; the guard reads `.git/config` directly.
68
+ const mirrorGitDir = path.join(vaultDataDir, "mirror", ".git");
69
+ fs.mkdirSync(mirrorGitDir, { recursive: true });
70
+ fs.writeFileSync(
71
+ path.join(mirrorGitDir, "config"),
72
+ `[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = ${opts.origin}\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n`,
73
+ );
74
+ }
75
+
76
+ if (opts.pat) {
77
+ writeCredentials(name, {
78
+ active_method: "pat",
79
+ github_oauth: null,
80
+ pat: { token: "ghp_seedtoken1234567890", remote_url: opts.pat, label: "seed" },
81
+ });
82
+ }
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Normalization / equivalence
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe("normalizeRemoteIdentity", () => {
90
+ test("https with and without .git normalize equal", () => {
91
+ expect(normalizeRemoteIdentity("https://github.com/x/y")).toBe("github.com/x/y");
92
+ expect(normalizeRemoteIdentity("https://github.com/x/y.git")).toBe("github.com/x/y");
93
+ });
94
+
95
+ test("trailing slash is stripped", () => {
96
+ expect(normalizeRemoteIdentity("https://github.com/x/y/")).toBe("github.com/x/y");
97
+ expect(normalizeRemoteIdentity("https://github.com/x/y.git/")).toBe("github.com/x/y");
98
+ });
99
+
100
+ test("host is lower-cased", () => {
101
+ expect(normalizeRemoteIdentity("https://GitHub.com/x/y")).toBe("github.com/x/y");
102
+ });
103
+
104
+ test("owner/repo is lower-cased too (GitHub is case-insensitive) — the clobber guard", () => {
105
+ // Aaron/Vault and aaron/vault are the SAME GitHub repo; both must normalize
106
+ // equal so two vaults with different-case configs are caught, not clobbered.
107
+ expect(normalizeRemoteIdentity("https://github.com/Aaron/Vault.git")).toBe("github.com/aaron/vault");
108
+ expect(normalizeRemoteIdentity("git@github.com:Aaron/Vault.git")).toBe("github.com/aaron/vault");
109
+ });
110
+
111
+ test("embedded userinfo (token) is stripped", () => {
112
+ expect(
113
+ normalizeRemoteIdentity("https://x-access-token:ghp_secret@github.com/x/y.git"),
114
+ ).toBe("github.com/x/y");
115
+ });
116
+
117
+ test("scp-style SSH shorthand normalizes to host/path", () => {
118
+ expect(normalizeRemoteIdentity("git@github.com:x/y.git")).toBe("github.com/x/y");
119
+ });
120
+
121
+ test("ssh:// URL normalizes to host/path", () => {
122
+ expect(normalizeRemoteIdentity("ssh://git@github.com/x/y.git")).toBe("github.com/x/y");
123
+ });
124
+
125
+ test("empty / whitespace → null", () => {
126
+ expect(normalizeRemoteIdentity("")).toBeNull();
127
+ expect(normalizeRemoteIdentity(" ")).toBeNull();
128
+ });
129
+ });
130
+
131
+ describe("sameRemoteIdentity — the equivalence the guard keys off", () => {
132
+ test("https ≡ ssh ≡ scp-shorthand ≡ .git-suffix for the same repo", () => {
133
+ const variants = [
134
+ "https://github.com/aaron/my-vault.git",
135
+ "https://github.com/aaron/my-vault",
136
+ "git@github.com:aaron/my-vault.git",
137
+ "ssh://git@github.com/aaron/my-vault",
138
+ "https://x-access-token:ghp_tok@github.com/aaron/my-vault.git",
139
+ ];
140
+ for (const a of variants) {
141
+ for (const b of variants) {
142
+ expect(sameRemoteIdentity(a, b)).toBe(true);
143
+ }
144
+ }
145
+ });
146
+
147
+ test("case-insensitive owner/repo: Aaron/Vault ≡ aaron/vault (the data-loss gap)", () => {
148
+ expect(
149
+ sameRemoteIdentity("https://github.com/Aaron/Vault.git", "https://github.com/aaron/vault"),
150
+ ).toBe(true);
151
+ });
152
+
153
+ test("different repos are NOT equal", () => {
154
+ expect(
155
+ sameRemoteIdentity(
156
+ "https://github.com/aaron/my-vault.git",
157
+ "https://github.com/aaron/other-vault.git",
158
+ ),
159
+ ).toBe(false);
160
+ });
161
+
162
+ test("different owners are NOT equal (the family-box case)", () => {
163
+ expect(
164
+ sameRemoteIdentity(
165
+ "https://github.com/alice/backup.git",
166
+ "https://github.com/bob/backup.git",
167
+ ),
168
+ ).toBe(false);
169
+ });
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // claimedRemoteOf — reads both origin + PAT sources
174
+ // ---------------------------------------------------------------------------
175
+
176
+ describe("claimedRemoteOf", () => {
177
+ let home: string;
178
+ afterEach(() => {
179
+ if (home) fs.rmSync(home, { recursive: true, force: true });
180
+ });
181
+
182
+ test("reads the mirror dir origin (covers OAuth-selected repos)", () => {
183
+ home = tmp("guard-claimed-origin-");
184
+ process.env.PARACHUTE_HOME = home;
185
+ seedVault("a", { origin: "https://github.com/aaron/my-vault.git" });
186
+ expect(claimedRemoteOf("a")).toBe("github.com/aaron/my-vault");
187
+ });
188
+
189
+ test("reads the stored PAT remote_url when no origin on disk", () => {
190
+ home = tmp("guard-claimed-pat-");
191
+ process.env.PARACHUTE_HOME = home;
192
+ seedVault("a", {
193
+ pat: "https://x-access-token:ghp_x@github.com/aaron/pat-vault.git",
194
+ });
195
+ expect(claimedRemoteOf("a")).toBe("github.com/aaron/pat-vault");
196
+ });
197
+
198
+ test("returns null for a vault with no mirror remote", () => {
199
+ home = tmp("guard-claimed-none-");
200
+ process.env.PARACHUTE_HOME = home;
201
+ seedVault("a", {});
202
+ expect(claimedRemoteOf("a")).toBeNull();
203
+ });
204
+ });
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // findConflictingVault — the cross-vault scan
208
+ // ---------------------------------------------------------------------------
209
+
210
+ describe("findConflictingVault", () => {
211
+ let home: string;
212
+ afterEach(() => {
213
+ if (home) fs.rmSync(home, { recursive: true, force: true });
214
+ });
215
+
216
+ test("detects a sibling vault targeting the same repo (origin source)", () => {
217
+ home = tmp("guard-conflict-origin-");
218
+ process.env.PARACHUTE_HOME = home;
219
+ // Vault "a" already backs up to aaron/shared via its mirror origin.
220
+ seedVault("a", { origin: "https://github.com/aaron/shared.git" });
221
+ seedVault("b", {});
222
+ // Vault "b" tries to bind the SAME repo via a different URL shape.
223
+ const conflict = findConflictingVault("b", "git@github.com:aaron/shared.git");
224
+ expect(conflict).not.toBeNull();
225
+ expect(conflict!.conflictingVault).toBe("a");
226
+ expect(conflict!.remoteIdentity).toBe("github.com/aaron/shared");
227
+ });
228
+
229
+ test("detects a sibling vault targeting the same repo (PAT source)", () => {
230
+ home = tmp("guard-conflict-pat-");
231
+ process.env.PARACHUTE_HOME = home;
232
+ seedVault("a", {
233
+ pat: "https://x-access-token:ghp_a@github.com/aaron/shared.git",
234
+ });
235
+ seedVault("b", {});
236
+ const conflict = findConflictingVault(
237
+ "b",
238
+ "https://github.com/aaron/shared",
239
+ );
240
+ expect(conflict).not.toBeNull();
241
+ expect(conflict!.conflictingVault).toBe("a");
242
+ });
243
+
244
+ test("excludes the current vault — re-pointing to its OWN remote is allowed", () => {
245
+ home = tmp("guard-conflict-self-");
246
+ process.env.PARACHUTE_HOME = home;
247
+ seedVault("a", { origin: "https://github.com/aaron/shared.git" });
248
+ // Vault "a" re-binds its OWN repo (token rotation / re-pick) → no conflict.
249
+ const conflict = findConflictingVault("a", "https://github.com/aaron/shared.git");
250
+ expect(conflict).toBeNull();
251
+ });
252
+
253
+ test("no false positive when no sibling targets the repo", () => {
254
+ home = tmp("guard-conflict-clear-");
255
+ process.env.PARACHUTE_HOME = home;
256
+ seedVault("a", { origin: "https://github.com/aaron/vault-a.git" });
257
+ seedVault("b", {});
258
+ const conflict = findConflictingVault("b", "https://github.com/aaron/vault-b.git");
259
+ expect(conflict).toBeNull();
260
+ });
261
+
262
+ test("an empty / unparseable candidate never conflicts", () => {
263
+ home = tmp("guard-conflict-empty-");
264
+ process.env.PARACHUTE_HOME = home;
265
+ seedVault("a", { origin: "https://github.com/aaron/shared.git" });
266
+ expect(findConflictingVault("b", "")).toBeNull();
267
+ expect(findConflictingVault("b", " ")).toBeNull();
268
+ });
269
+ });