@openparachute/vault 0.6.0 → 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.
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
  /**
@@ -104,6 +104,26 @@ describe("serialize + parse round-trip", () => {
104
104
  expect(out).toEqual(creds);
105
105
  });
106
106
 
107
+ test("github_oauth with empty scope round-trips (GitHub App tokens)", () => {
108
+ // Tokens from the shared Parachute GitHub App carry scope: "" (GitHub
109
+ // Apps use fine-grained permissions, not scopes). The empty string must
110
+ // survive serialize → parse and still pass the section-validity guard.
111
+ const creds: MirrorCredentials = {
112
+ active_method: "github_oauth",
113
+ github_oauth: {
114
+ access_token: "ghu_abc123def456ghi789",
115
+ scope: "",
116
+ authorized_at: "2026-06-10T18:00:00.000Z",
117
+ user_login: "aaron",
118
+ user_id: 12345,
119
+ },
120
+ pat: null,
121
+ };
122
+ const out = parseCredentials(serializeCredentials(creds));
123
+ expect(out).toEqual(creds);
124
+ expect(out.github_oauth?.scope).toBe("");
125
+ });
126
+
107
127
  test("pat credentials round-trip", () => {
108
128
  const creds: MirrorCredentials = {
109
129
  active_method: "pat",
@@ -209,8 +209,8 @@ export function emptyCredentials(): MirrorCredentials {
209
209
  *
210
210
  * active_method: github_oauth
211
211
  * github_oauth:
212
- * access_token: gho_...
213
- * scope: repo
212
+ * access_token: ghu_...
213
+ * scope: ""
214
214
  * authorized_at: 2026-05-28T03:14:15.000Z
215
215
  * user_login: aaron
216
216
  * user_id: 12345
@@ -218,6 +218,10 @@ export function emptyCredentials(): MirrorCredentials {
218
218
  * token: ghp_...
219
219
  * remote_url: https://github.com/aaron/my-vault.git
220
220
  * label: "GitHub PAT"
221
+ *
222
+ * `scope` is always `""` for tokens from the shared Parachute GitHub App
223
+ * (GitHub Apps use fine-grained permissions, not scopes). Credentials from
224
+ * the classic-OAuth era may still carry `scope: repo` — both parse fine.
221
225
  */
222
226
  export function serializeCredentials(creds: MirrorCredentials): string {
223
227
  const lines: string[] = [];
@@ -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
+ });