@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.
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/cli.ts +90 -25
- package/src/init-summary.test.ts +125 -125
- package/src/init-summary.ts +89 -54
- package/src/init.test.ts +128 -0
- package/src/mirror-remote-guard.test.ts +269 -0
- package/src/mirror-remote-guard.ts +273 -0
- package/src/mirror-routes.test.ts +313 -0
- package/src/mirror-routes.ts +92 -6
- package/src/vault.test.ts +56 -0
package/src/init-summary.ts
CHANGED
|
@@ -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 (`
|
|
26
|
-
*
|
|
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
|
|
42
|
-
*
|
|
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
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
*
|
|
50
|
-
*
|
|
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
|
-
|
|
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(
|
|
65
|
-
lines.push(`
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
|
|
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(` -
|
|
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
|
+
});
|