@openparachute/vault 0.4.6-rc.3 → 0.4.6
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 +41 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +161 -1
- package/src/auth.ts +57 -3
- package/src/cli.ts +420 -22
- package/src/export-watch.test.ts +811 -0
- package/src/export-watch.ts +255 -0
- package/src/mcp-config.test.ts +260 -0
- package/src/mcp-install.test.ts +60 -0
- package/src/mcp-install.ts +61 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for `parachute-vault export --watch` and `--git-commit`.
|
|
3
|
+
*
|
|
4
|
+
* Split out from `cli.ts` so the template renderer and git-shell helpers are
|
|
5
|
+
* unit-testable without spawning the full CLI. The CLI imports and wires
|
|
6
|
+
* these into `cmdExport`'s watch loop; tests import them directly.
|
|
7
|
+
*
|
|
8
|
+
* No new deps — `git` is shelled out via `Bun.spawn`. Watch detection is
|
|
9
|
+
* polling: every `intervalSeconds`, run an incremental export with the
|
|
10
|
+
* cursor captured at the *start* of the previous cycle. Vault writes are
|
|
11
|
+
* HTTP-mediated and don't surface to filesystem watchers (the bun:sqlite DB
|
|
12
|
+
* is opaque), so polling on `updated_at >= cursor` is the simplest robust
|
|
13
|
+
* detection. See `parachute-patterns/cookbook/vault-portable-export.md`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Commit message templating
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** Variables exposed to `--git-message-template`. */
|
|
21
|
+
export interface CommitTemplateVars {
|
|
22
|
+
/** ISO-formatted UTC timestamp captured at commit time. */
|
|
23
|
+
date: string;
|
|
24
|
+
/** Count of notes changed since the previous export cursor. */
|
|
25
|
+
notes_changed: number;
|
|
26
|
+
/**
|
|
27
|
+
* First changed note's title (or path / id fallback) since the previous
|
|
28
|
+
* cursor — useful for single-note commits. Empty when nothing matched or
|
|
29
|
+
* on the initial export (where "first changed" is ambiguous).
|
|
30
|
+
*/
|
|
31
|
+
first_note_title: string;
|
|
32
|
+
/** Source vault name. */
|
|
33
|
+
vault_name: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Substitute `{{var}}` tokens in `template`. Recognized vars:
|
|
38
|
+
*
|
|
39
|
+
* `{{date}}` `{{notes_changed}}` `{{plural}}` `{{first_note_title}}` `{{vault_name}}`
|
|
40
|
+
*
|
|
41
|
+
* `{{plural}}` is `""` when `notes_changed === 1`, else `"s"` — so
|
|
42
|
+
* `"{{notes_changed}} note{{plural}}"` reads naturally for both 1 and N
|
|
43
|
+
* without the operator writing their own conditional.
|
|
44
|
+
*
|
|
45
|
+
* Unknown tokens pass through untouched (so a typo in the template is
|
|
46
|
+
* visible in the commit message rather than silently dropped).
|
|
47
|
+
*/
|
|
48
|
+
export function renderCommitMessage(template: string, vars: CommitTemplateVars): string {
|
|
49
|
+
return template
|
|
50
|
+
.replace(/\{\{date\}\}/g, vars.date)
|
|
51
|
+
.replace(/\{\{notes_changed\}\}/g, String(vars.notes_changed))
|
|
52
|
+
.replace(/\{\{plural\}\}/g, vars.notes_changed === 1 ? "" : "s")
|
|
53
|
+
.replace(/\{\{first_note_title\}\}/g, vars.first_note_title)
|
|
54
|
+
.replace(/\{\{vault_name\}\}/g, vars.vault_name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Default commit message template. Reads as "export: <iso> (N note[s])". */
|
|
58
|
+
export const DEFAULT_COMMIT_TEMPLATE =
|
|
59
|
+
"export: {{date}} ({{notes_changed}} note{{plural}})";
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Git shell helpers (Bun.spawn — no new dep)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Return true if `dir` is inside a git working tree.
|
|
67
|
+
*
|
|
68
|
+
* Implementation: `git rev-parse --is-inside-work-tree`, exit code 0 = yes.
|
|
69
|
+
* No reliance on `.git/` existing as a directory (handles worktrees + bare
|
|
70
|
+
* submodule layouts uniformly).
|
|
71
|
+
*/
|
|
72
|
+
export async function isGitRepo(dir: string): Promise<boolean> {
|
|
73
|
+
const proc = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], {
|
|
74
|
+
cwd: dir,
|
|
75
|
+
stdout: "pipe",
|
|
76
|
+
stderr: "pipe",
|
|
77
|
+
});
|
|
78
|
+
const exitCode = await proc.exited;
|
|
79
|
+
return exitCode === 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** List the paths git currently has staged (cached). One per line. */
|
|
83
|
+
export async function listStagedFiles(repoDir: string): Promise<string[]> {
|
|
84
|
+
const proc = Bun.spawn(["git", "diff", "--cached", "--name-only"], {
|
|
85
|
+
cwd: repoDir,
|
|
86
|
+
stdout: "pipe",
|
|
87
|
+
stderr: "pipe",
|
|
88
|
+
});
|
|
89
|
+
await proc.exited;
|
|
90
|
+
const out = new TextDecoder().decode(await new Response(proc.stdout).arrayBuffer());
|
|
91
|
+
return out
|
|
92
|
+
.split("\n")
|
|
93
|
+
.map((l) => l.trim())
|
|
94
|
+
.filter((l) => l.length > 0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Run `git add -A` in `repoDir`. Returns true on success. */
|
|
98
|
+
export async function gitAddAll(repoDir: string): Promise<{ ok: boolean; stderr: string }> {
|
|
99
|
+
const proc = Bun.spawn(["git", "add", "-A"], {
|
|
100
|
+
cwd: repoDir,
|
|
101
|
+
stdout: "pipe",
|
|
102
|
+
stderr: "pipe",
|
|
103
|
+
});
|
|
104
|
+
const exitCode = await proc.exited;
|
|
105
|
+
const stderr = new TextDecoder().decode(await new Response(proc.stderr).arrayBuffer());
|
|
106
|
+
return { ok: exitCode === 0, stderr: stderr.trim() };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Run `git commit -m <message>` in `repoDir`. Returns true on success. */
|
|
110
|
+
export async function gitCommit(
|
|
111
|
+
repoDir: string,
|
|
112
|
+
message: string,
|
|
113
|
+
): Promise<{ ok: boolean; stderr: string }> {
|
|
114
|
+
const proc = Bun.spawn(["git", "commit", "-m", message], {
|
|
115
|
+
cwd: repoDir,
|
|
116
|
+
stdout: "pipe",
|
|
117
|
+
stderr: "pipe",
|
|
118
|
+
});
|
|
119
|
+
const exitCode = await proc.exited;
|
|
120
|
+
const stderr = new TextDecoder().decode(await new Response(proc.stderr).arrayBuffer());
|
|
121
|
+
return { ok: exitCode === 0, stderr: stderr.trim() };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Run `git push` in `repoDir`. Returns true on success. */
|
|
125
|
+
export async function gitPush(
|
|
126
|
+
repoDir: string,
|
|
127
|
+
): Promise<{ ok: boolean; stderr: string }> {
|
|
128
|
+
const proc = Bun.spawn(["git", "push"], {
|
|
129
|
+
cwd: repoDir,
|
|
130
|
+
stdout: "pipe",
|
|
131
|
+
stderr: "pipe",
|
|
132
|
+
});
|
|
133
|
+
const exitCode = await proc.exited;
|
|
134
|
+
const stderr = new TextDecoder().decode(await new Response(proc.stderr).arrayBuffer());
|
|
135
|
+
return { ok: exitCode === 0, stderr: stderr.trim() };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Unstage everything in `repoDir` (no-op if nothing staged).
|
|
140
|
+
*
|
|
141
|
+
* Uses bare `git reset` (no `HEAD`, no path args) so the call works on a
|
|
142
|
+
* fresh repo with no commits yet — an operator who runs `--git-commit`
|
|
143
|
+
* against a `git init`'d empty repo and lands in the `.parachute/`-only
|
|
144
|
+
* skip path on the first cycle would otherwise leave staging dirty.
|
|
145
|
+
* Both `git reset HEAD -- .` and `git restore --staged .` require a
|
|
146
|
+
* resolvable HEAD; bare `git reset` falls back cleanly when none exists.
|
|
147
|
+
* See vault#346 reviewer note.
|
|
148
|
+
*/
|
|
149
|
+
export async function gitUnstageAll(repoDir: string): Promise<void> {
|
|
150
|
+
const proc = Bun.spawn(["git", "reset"], {
|
|
151
|
+
cwd: repoDir,
|
|
152
|
+
stdout: "pipe",
|
|
153
|
+
stderr: "pipe",
|
|
154
|
+
});
|
|
155
|
+
await proc.exited;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Cycle-level helpers: commit-or-skip decision + execution
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Decide whether the currently-staged set warrants a commit, given the
|
|
164
|
+
* `notes_changed` reported by the export cycle.
|
|
165
|
+
*
|
|
166
|
+
* Skip rules:
|
|
167
|
+
* - Empty staging → skip (nothing to commit).
|
|
168
|
+
* - `notes_changed === 0` AND every staged path lives under `.parachute/`
|
|
169
|
+
* → skip. This filters the pure `vault.yaml` `exported_at` churn that
|
|
170
|
+
* would otherwise produce a commit per watch interval forever.
|
|
171
|
+
*
|
|
172
|
+
* Otherwise → commit.
|
|
173
|
+
*
|
|
174
|
+
* Note: a vault rename (vault.yaml `name` field changes) also follows the
|
|
175
|
+
* skip path. Metadata-only changes don't commit by design — the operator
|
|
176
|
+
* who renames a vault and wants that in the git mirror history can either
|
|
177
|
+
* touch a note or run a one-shot `git commit --allow-empty -m "rename vault"`
|
|
178
|
+
* by hand.
|
|
179
|
+
*/
|
|
180
|
+
export function shouldCommit(stagedFiles: string[], notesChanged: number): {
|
|
181
|
+
commit: boolean;
|
|
182
|
+
reason: "ok" | "empty" | "parachute_meta_only";
|
|
183
|
+
} {
|
|
184
|
+
if (stagedFiles.length === 0) return { commit: false, reason: "empty" };
|
|
185
|
+
if (notesChanged === 0 && stagedFiles.every((f) => f.startsWith(".parachute/"))) {
|
|
186
|
+
return { commit: false, reason: "parachute_meta_only" };
|
|
187
|
+
}
|
|
188
|
+
return { commit: true, reason: "ok" };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Stage → decide → commit → optionally push. Logs status to stdout/stderr.
|
|
193
|
+
* Returns whether a commit landed.
|
|
194
|
+
*/
|
|
195
|
+
export async function runGitCommitCycle(opts: {
|
|
196
|
+
repoDir: string;
|
|
197
|
+
template: string;
|
|
198
|
+
notesChanged: number;
|
|
199
|
+
vaultName: string;
|
|
200
|
+
firstNoteTitle: string;
|
|
201
|
+
push: boolean;
|
|
202
|
+
/** Override for tests — defaults to `new Date().toISOString()`. */
|
|
203
|
+
now?: () => string;
|
|
204
|
+
}): Promise<{ committed: boolean; message?: string }> {
|
|
205
|
+
const now = opts.now ?? (() => new Date().toISOString());
|
|
206
|
+
|
|
207
|
+
const add = await gitAddAll(opts.repoDir);
|
|
208
|
+
if (!add.ok) {
|
|
209
|
+
console.error(`[git-commit] git add failed: ${add.stderr}`);
|
|
210
|
+
return { committed: false };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const staged = await listStagedFiles(opts.repoDir);
|
|
214
|
+
const decision = shouldCommit(staged, opts.notesChanged);
|
|
215
|
+
if (!decision.commit) {
|
|
216
|
+
if (decision.reason === "empty") {
|
|
217
|
+
console.log(`[git-commit] no changes; skipping commit`);
|
|
218
|
+
} else if (decision.reason === "parachute_meta_only") {
|
|
219
|
+
// Unstage so the next cycle starts clean and the operator's
|
|
220
|
+
// `git status` reflects the skip-decision.
|
|
221
|
+
await gitUnstageAll(opts.repoDir);
|
|
222
|
+
console.log(
|
|
223
|
+
`[git-commit] no note changes (only .parachute/ metadata); skipping commit`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return { committed: false };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const message = renderCommitMessage(opts.template, {
|
|
230
|
+
date: now(),
|
|
231
|
+
notes_changed: opts.notesChanged,
|
|
232
|
+
first_note_title: opts.firstNoteTitle,
|
|
233
|
+
vault_name: opts.vaultName,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const commit = await gitCommit(opts.repoDir, message);
|
|
237
|
+
if (!commit.ok) {
|
|
238
|
+
console.error(`[git-commit] git commit failed: ${commit.stderr}`);
|
|
239
|
+
return { committed: false };
|
|
240
|
+
}
|
|
241
|
+
console.log(`[git-commit] ${message}`);
|
|
242
|
+
|
|
243
|
+
if (opts.push) {
|
|
244
|
+
const pushResult = await gitPush(opts.repoDir);
|
|
245
|
+
if (!pushResult.ok) {
|
|
246
|
+
// Non-fatal — a network blip shouldn't kill a watch loop. Warn and
|
|
247
|
+
// move on; the next successful commit's push will catch up history.
|
|
248
|
+
console.warn(`[git-commit] git push failed (non-fatal): ${pushResult.stderr}`);
|
|
249
|
+
} else {
|
|
250
|
+
console.log(`[git-commit] pushed`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { committed: true, message };
|
|
255
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `parachute-vault mcp-config <vault-name>` — the JSON synthesizer
|
|
3
|
+
* for `claude -p --mcp-config '<json>'` runners. Three concerns:
|
|
4
|
+
*
|
|
5
|
+
* 1. Pure helper: `buildMcpConfigJson` — shape correctness for both literal
|
|
6
|
+
* and `--env-vars` template modes.
|
|
7
|
+
* 2. CLI flag parsing: required vault arg, --token / PARACHUTE_VAULT_TOKEN
|
|
8
|
+
* precedence, --base-url override, --env-vars short-circuit (no vault
|
|
9
|
+
* lookup, no bearer required).
|
|
10
|
+
* 3. End-to-end stdout — verify pipe-to-jq usability (valid JSON, exact
|
|
11
|
+
* shape claude consumes).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { buildMcpConfigJson } from "./mcp-install.ts";
|
|
19
|
+
|
|
20
|
+
const CLI = path.resolve(import.meta.dir, "cli.ts");
|
|
21
|
+
|
|
22
|
+
function runCli(
|
|
23
|
+
args: string[],
|
|
24
|
+
parachuteHome: string,
|
|
25
|
+
extraEnv: Record<string, string | undefined> = {},
|
|
26
|
+
cwd?: string,
|
|
27
|
+
): { exitCode: number; stdout: string; stderr: string } {
|
|
28
|
+
const proc = Bun.spawnSync({
|
|
29
|
+
cmd: ["bun", CLI, ...args],
|
|
30
|
+
cwd: cwd ?? parachuteHome,
|
|
31
|
+
env: {
|
|
32
|
+
...process.env,
|
|
33
|
+
PARACHUTE_HOME: parachuteHome,
|
|
34
|
+
HOME: parachuteHome,
|
|
35
|
+
...extraEnv,
|
|
36
|
+
},
|
|
37
|
+
stdout: "pipe",
|
|
38
|
+
stderr: "pipe",
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
exitCode: proc.exitCode ?? -1,
|
|
42
|
+
stdout: new TextDecoder().decode(proc.stdout),
|
|
43
|
+
stderr: new TextDecoder().decode(proc.stderr),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setupBareVault(parachuteHome: string, name: string): void {
|
|
48
|
+
const vaultsDir = path.join(parachuteHome, "vault", "data");
|
|
49
|
+
fs.mkdirSync(path.join(vaultsDir, name), { recursive: true });
|
|
50
|
+
fs.writeFileSync(
|
|
51
|
+
path.join(vaultsDir, name, "vault.yaml"),
|
|
52
|
+
`name: ${name}\napi_keys: []\n`,
|
|
53
|
+
);
|
|
54
|
+
const globalPath = path.join(parachuteHome, "vault", "config.yaml");
|
|
55
|
+
if (!fs.existsSync(globalPath)) {
|
|
56
|
+
fs.mkdirSync(path.dirname(globalPath), { recursive: true });
|
|
57
|
+
fs.writeFileSync(globalPath, `default_vault: ${name}\nport: 1940\n`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Unit: buildMcpConfigJson
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe("buildMcpConfigJson", () => {
|
|
66
|
+
test("literal mode embeds the bearer and URL verbatim", () => {
|
|
67
|
+
const json = buildMcpConfigJson({
|
|
68
|
+
vaultName: "gitcoin",
|
|
69
|
+
baseUrl: "http://127.0.0.1:1940",
|
|
70
|
+
bearer: "pvt_abc123",
|
|
71
|
+
});
|
|
72
|
+
const parsed = JSON.parse(json);
|
|
73
|
+
expect(parsed.mcpServers["parachute-vault-gitcoin"]).toEqual({
|
|
74
|
+
type: "http",
|
|
75
|
+
url: "http://127.0.0.1:1940/vault/gitcoin/mcp",
|
|
76
|
+
headers: { Authorization: "Bearer pvt_abc123" },
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("env-var mode emits placeholders that survive JSON parsing", () => {
|
|
81
|
+
const json = buildMcpConfigJson({
|
|
82
|
+
vaultName: "gitcoin",
|
|
83
|
+
baseUrl: "",
|
|
84
|
+
bearer: "",
|
|
85
|
+
useEnvVars: true,
|
|
86
|
+
});
|
|
87
|
+
// The placeholders must come through verbatim — `${...}` is a normal
|
|
88
|
+
// string value to JSON, and that's what shell expansion later acts on.
|
|
89
|
+
const parsed = JSON.parse(json);
|
|
90
|
+
expect(parsed.mcpServers["parachute-vault-gitcoin"].url).toBe(
|
|
91
|
+
"${PARACHUTE_HUB_URL}/vault/gitcoin/mcp",
|
|
92
|
+
);
|
|
93
|
+
expect(parsed.mcpServers["parachute-vault-gitcoin"].headers.Authorization).toBe(
|
|
94
|
+
"Bearer ${PARACHUTE_VAULT_TOKEN}",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("strips trailing slash from baseUrl (no double slashes in the path)", () => {
|
|
99
|
+
const json = buildMcpConfigJson({
|
|
100
|
+
vaultName: "default",
|
|
101
|
+
baseUrl: "https://hub.example.com/",
|
|
102
|
+
bearer: "t",
|
|
103
|
+
});
|
|
104
|
+
const parsed = JSON.parse(json);
|
|
105
|
+
expect(parsed.mcpServers["parachute-vault-default"].url).toBe(
|
|
106
|
+
"https://hub.example.com/vault/default/mcp",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("entry key always uses parachute-vault-<name> (multi-vault-ready)", () => {
|
|
111
|
+
const json = buildMcpConfigJson({
|
|
112
|
+
vaultName: "work",
|
|
113
|
+
baseUrl: "http://127.0.0.1:1940",
|
|
114
|
+
bearer: "t",
|
|
115
|
+
});
|
|
116
|
+
const parsed = JSON.parse(json);
|
|
117
|
+
expect(Object.keys(parsed.mcpServers)).toEqual(["parachute-vault-work"]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// CLI end-to-end
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
describe("mcp-config CLI", () => {
|
|
126
|
+
let tmp: string;
|
|
127
|
+
beforeEach(() => {
|
|
128
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vault-mcp-config-"));
|
|
129
|
+
});
|
|
130
|
+
afterEach(() => {
|
|
131
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("emits well-formed JSON to stdout with --token", () => {
|
|
135
|
+
setupBareVault(tmp, "gitcoin");
|
|
136
|
+
const res = runCli(
|
|
137
|
+
["mcp-config", "gitcoin", "--token", "pvt_test123"],
|
|
138
|
+
tmp,
|
|
139
|
+
);
|
|
140
|
+
expect(res.exitCode).toBe(0);
|
|
141
|
+
const parsed = JSON.parse(res.stdout);
|
|
142
|
+
expect(parsed.mcpServers["parachute-vault-gitcoin"].type).toBe("http");
|
|
143
|
+
expect(parsed.mcpServers["parachute-vault-gitcoin"].headers.Authorization).toBe(
|
|
144
|
+
"Bearer pvt_test123",
|
|
145
|
+
);
|
|
146
|
+
expect(parsed.mcpServers["parachute-vault-gitcoin"].url).toContain(
|
|
147
|
+
"/vault/gitcoin/mcp",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("reads PARACHUTE_VAULT_TOKEN env var when --token absent", () => {
|
|
152
|
+
setupBareVault(tmp, "gitcoin");
|
|
153
|
+
const res = runCli(
|
|
154
|
+
["mcp-config", "gitcoin"],
|
|
155
|
+
tmp,
|
|
156
|
+
{ PARACHUTE_VAULT_TOKEN: "pvt_envvar" },
|
|
157
|
+
);
|
|
158
|
+
expect(res.exitCode).toBe(0);
|
|
159
|
+
const parsed = JSON.parse(res.stdout);
|
|
160
|
+
expect(parsed.mcpServers["parachute-vault-gitcoin"].headers.Authorization).toBe(
|
|
161
|
+
"Bearer pvt_envvar",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("--token wins over PARACHUTE_VAULT_TOKEN when both are present", () => {
|
|
166
|
+
setupBareVault(tmp, "gitcoin");
|
|
167
|
+
const res = runCli(
|
|
168
|
+
["mcp-config", "gitcoin", "--token", "from-flag"],
|
|
169
|
+
tmp,
|
|
170
|
+
{ PARACHUTE_VAULT_TOKEN: "from-env" },
|
|
171
|
+
);
|
|
172
|
+
expect(res.exitCode).toBe(0);
|
|
173
|
+
const parsed = JSON.parse(res.stdout);
|
|
174
|
+
expect(parsed.mcpServers["parachute-vault-gitcoin"].headers.Authorization).toBe(
|
|
175
|
+
"Bearer from-flag",
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("exits 1 with a clear error when no token is supplied", () => {
|
|
180
|
+
setupBareVault(tmp, "gitcoin");
|
|
181
|
+
// Explicitly unset PARACHUTE_VAULT_TOKEN (test env may have it).
|
|
182
|
+
const res = runCli(
|
|
183
|
+
["mcp-config", "gitcoin"],
|
|
184
|
+
tmp,
|
|
185
|
+
{ PARACHUTE_VAULT_TOKEN: "" },
|
|
186
|
+
);
|
|
187
|
+
expect(res.exitCode).toBe(1);
|
|
188
|
+
expect(res.stderr).toMatch(/No bearer token/);
|
|
189
|
+
expect(res.stderr).toMatch(/--token/);
|
|
190
|
+
expect(res.stderr).toMatch(/PARACHUTE_VAULT_TOKEN/);
|
|
191
|
+
// The error message points operators at the workaround paths so they
|
|
192
|
+
// can recover without re-reading the docs.
|
|
193
|
+
expect(res.stderr).toMatch(/parachute-vault tokens create/);
|
|
194
|
+
expect(res.stderr).toMatch(/--env-vars/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("exits 1 when the vault doesn't exist (literal mode)", () => {
|
|
198
|
+
setupBareVault(tmp, "default");
|
|
199
|
+
const res = runCli(
|
|
200
|
+
["mcp-config", "ghost", "--token", "t"],
|
|
201
|
+
tmp,
|
|
202
|
+
);
|
|
203
|
+
expect(res.exitCode).toBe(1);
|
|
204
|
+
expect(res.stderr).toMatch(/Vault "ghost" not found/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("--env-vars short-circuits — no vault lookup, no token required", () => {
|
|
208
|
+
// Deliberately no setupBareVault: --env-vars mode emits the template
|
|
209
|
+
// shape without resolving anything. Operators commit the template from
|
|
210
|
+
// machines that may not have the target vault.
|
|
211
|
+
const res = runCli(
|
|
212
|
+
["mcp-config", "gitcoin", "--env-vars"],
|
|
213
|
+
tmp,
|
|
214
|
+
{ PARACHUTE_VAULT_TOKEN: "" },
|
|
215
|
+
);
|
|
216
|
+
expect(res.exitCode).toBe(0);
|
|
217
|
+
const parsed = JSON.parse(res.stdout);
|
|
218
|
+
expect(parsed.mcpServers["parachute-vault-gitcoin"].url).toBe(
|
|
219
|
+
"${PARACHUTE_HUB_URL}/vault/gitcoin/mcp",
|
|
220
|
+
);
|
|
221
|
+
expect(parsed.mcpServers["parachute-vault-gitcoin"].headers.Authorization).toBe(
|
|
222
|
+
"Bearer ${PARACHUTE_VAULT_TOKEN}",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("--base-url overrides the auto-detected origin", () => {
|
|
227
|
+
setupBareVault(tmp, "gitcoin");
|
|
228
|
+
const res = runCli(
|
|
229
|
+
[
|
|
230
|
+
"mcp-config",
|
|
231
|
+
"gitcoin",
|
|
232
|
+
"--token",
|
|
233
|
+
"t",
|
|
234
|
+
"--base-url",
|
|
235
|
+
"https://hub.taildf9ce2.ts.net",
|
|
236
|
+
],
|
|
237
|
+
tmp,
|
|
238
|
+
);
|
|
239
|
+
expect(res.exitCode).toBe(0);
|
|
240
|
+
const parsed = JSON.parse(res.stdout);
|
|
241
|
+
expect(parsed.mcpServers["parachute-vault-gitcoin"].url).toBe(
|
|
242
|
+
"https://hub.taildf9ce2.ts.net/vault/gitcoin/mcp",
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("missing vault-name arg prints usage and exits 1", () => {
|
|
247
|
+
const res = runCli(["mcp-config"], tmp);
|
|
248
|
+
expect(res.exitCode).toBe(1);
|
|
249
|
+
expect(res.stderr).toMatch(/Usage:/);
|
|
250
|
+
expect(res.stderr).toMatch(/mcp-config <vault-name>/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("flag passed in place of vault-name (e.g. --help) is rejected", () => {
|
|
254
|
+
// Guards against `parachute-vault mcp-config --token foo` being
|
|
255
|
+
// silently misparsed as vault-name="--token".
|
|
256
|
+
const res = runCli(["mcp-config", "--help"], tmp);
|
|
257
|
+
expect(res.exitCode).toBe(1);
|
|
258
|
+
expect(res.stderr).toMatch(/Usage:/);
|
|
259
|
+
});
|
|
260
|
+
});
|
package/src/mcp-install.test.ts
CHANGED
|
@@ -580,6 +580,10 @@ describe("mcp-install end-to-end", () => {
|
|
|
580
580
|
// scoped to this directory (and how to widen if they wanted global).
|
|
581
581
|
expect(res.stdout).toMatch(/this directory only/);
|
|
582
582
|
expect(res.stdout).toMatch(/--install-scope user/);
|
|
583
|
+
// Headless-flow heads-up: local-scope entries don't propagate to
|
|
584
|
+
// claude -p subprocesses; operators wiring up runners need to know.
|
|
585
|
+
expect(res.stdout).toMatch(/Headless flows/);
|
|
586
|
+
expect(res.stdout).toMatch(/claude -p/);
|
|
583
587
|
});
|
|
584
588
|
|
|
585
589
|
test("--install-scope project writes <cwd>/.mcp.json instead of ~/.claude.json", () => {
|
|
@@ -798,6 +802,62 @@ describe("mcp-install end-to-end", () => {
|
|
|
798
802
|
expect(bearer).toMatch(/^Bearer pvt_/);
|
|
799
803
|
});
|
|
800
804
|
|
|
805
|
+
test("--dry-run describes the write without touching disk or hitting the hub", () => {
|
|
806
|
+
// Aaron hit this when probing `mcp-install --help`: the bare CLI
|
|
807
|
+
// dispatch was creating an empty `projects[<cwd>]` slot in
|
|
808
|
+
// ~/.claude.json as a side effect of writing the install. --dry-run
|
|
809
|
+
// is the deliberate "tell me what you'd do" path.
|
|
810
|
+
setupBareVault(tmp, "default");
|
|
811
|
+
const res = runCli(
|
|
812
|
+
["mcp-install", "--install-scope", "user", "--token", "should-not-be-used", "--dry-run"],
|
|
813
|
+
tmp,
|
|
814
|
+
);
|
|
815
|
+
expect(res.exitCode).toBe(0);
|
|
816
|
+
// No claude.json should exist: the install was inhibited.
|
|
817
|
+
expect(fs.existsSync(path.join(tmp, ".claude.json"))).toBe(false);
|
|
818
|
+
// The dry-run output names target file, entry key, URL, and how to apply.
|
|
819
|
+
expect(res.stdout).toMatch(/\[dry-run\]/);
|
|
820
|
+
expect(res.stdout).toMatch(/Target file:/);
|
|
821
|
+
expect(res.stdout).toMatch(/Entry key:\s+parachute-vault/);
|
|
822
|
+
expect(res.stdout).toMatch(/MCP URL:/);
|
|
823
|
+
expect(res.stdout).toMatch(/Re-run without --dry-run to apply\./);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("--dry-run works for a vault that doesn't exist yet (probe shape)", () => {
|
|
827
|
+
// The dry-run contract is "no side effects, including failures on
|
|
828
|
+
// state the caller is asking about." A future-vault probe — would
|
|
829
|
+
// installing for this not-yet-created vault land where I expect? —
|
|
830
|
+
// is a legitimate pre-create check, not an error. The
|
|
831
|
+
// vault-existence guard runs only on the real-install path.
|
|
832
|
+
setupBareVault(tmp, "default");
|
|
833
|
+
const res = runCli(
|
|
834
|
+
["mcp-install", "--vault", "future-vault", "--token", "t", "--dry-run"],
|
|
835
|
+
tmp,
|
|
836
|
+
);
|
|
837
|
+
expect(res.exitCode).toBe(0);
|
|
838
|
+
expect(res.stdout).toMatch(/\[dry-run\]/);
|
|
839
|
+
// Output names the absent vault explicitly so the operator can tell
|
|
840
|
+
// the dry-run worked against the right target.
|
|
841
|
+
expect(res.stdout).toMatch(/Vault:\s+future-vault/);
|
|
842
|
+
expect(res.stdout).toMatch(/does not exist yet/);
|
|
843
|
+
expect(res.stdout).toMatch(/Entry key:\s+parachute-vault-future-vault/);
|
|
844
|
+
// No claude.json written — dry-run still didn't touch disk.
|
|
845
|
+
expect(fs.existsSync(path.join(tmp, ".claude.json"))).toBe(false);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
test("--dry-run with --mint skips the hub round-trip and operator-token check", () => {
|
|
849
|
+
// The whole point of --dry-run is "no side effects" — that includes
|
|
850
|
+
// the hub-mint network call. A naive implementation might still try
|
|
851
|
+
// to read operator.token and fail before the dry-run print fires.
|
|
852
|
+
setupBareVault(tmp, "default");
|
|
853
|
+
// Deliberately no operator.token file present, no PARACHUTE_HUB_ORIGIN.
|
|
854
|
+
const res = runCli(["mcp-install", "--dry-run"], tmp, { PARACHUTE_HUB_ORIGIN: "" });
|
|
855
|
+
expect(res.exitCode).toBe(0);
|
|
856
|
+
expect(res.stdout).toMatch(/\[dry-run\]/);
|
|
857
|
+
expect(res.stderr).not.toMatch(/No operator token found/);
|
|
858
|
+
expect(res.stderr).not.toMatch(/No hub origin configured/);
|
|
859
|
+
});
|
|
860
|
+
|
|
801
861
|
test("subsequent --token install on top of an existing entry overwrites the bearer (user scope)", () => {
|
|
802
862
|
setupBareVault(tmp, "default");
|
|
803
863
|
runCli(["mcp-install", "--install-scope", "user", "--token", "first"], tmp);
|
package/src/mcp-install.ts
CHANGED
|
@@ -334,6 +334,67 @@ export async function mintHubJwt(opts: MintHubJwtOpts): Promise<MintedHubJwt | M
|
|
|
334
334
|
};
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// `mcp-config` — emit the JSON shape `claude -p --mcp-config '<json>'` expects
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Build the JSON config shape consumed by `claude -p --mcp-config '<json>'`.
|
|
343
|
+
*
|
|
344
|
+
* Two emission modes:
|
|
345
|
+
*
|
|
346
|
+
* - **literal** (`useEnvVars: false`): the URL and bearer are inlined. The
|
|
347
|
+
* emitted JSON is ready to splice into a runner via shell substitution
|
|
348
|
+
* (`--mcp-config "$(parachute-vault mcp-config <name>)"`). Treat the
|
|
349
|
+
* output as secret — it carries a usable bearer.
|
|
350
|
+
*
|
|
351
|
+
* - **env-var template** (`useEnvVars: true`): the URL becomes
|
|
352
|
+
* `${PARACHUTE_HUB_URL}/vault/<name>/mcp` and the Authorization value
|
|
353
|
+
* becomes `Bearer ${PARACHUTE_VAULT_TOKEN}`. Shape-only; safe to commit.
|
|
354
|
+
* Consumers must expand the placeholders at runtime (most shells do this
|
|
355
|
+
* under double-quoted interpolation; `claude -p` itself does not).
|
|
356
|
+
*
|
|
357
|
+
* The `entryKey` rule mirrors `buildMcpEntryPlan`: vault-explicit installs
|
|
358
|
+
* key as `parachute-vault-<name>` so multi-vault runners can mount more than
|
|
359
|
+
* one vault under distinct slots. (The default install — singular
|
|
360
|
+
* `parachute-vault` — is reserved for the local-scope MCP entry; `mcp-config`
|
|
361
|
+
* always pins the per-vault key, since this is the script-spawning shape
|
|
362
|
+
* where you usually do name the vault.)
|
|
363
|
+
*
|
|
364
|
+
* Always emits stable JSON with two-space indent so the output is diff-able
|
|
365
|
+
* across runs.
|
|
366
|
+
*/
|
|
367
|
+
export interface BuildMcpConfigJsonOpts {
|
|
368
|
+
vaultName: string;
|
|
369
|
+
/** Base URL (without `/vault/<name>/mcp`). Ignored when `useEnvVars` is true. */
|
|
370
|
+
baseUrl: string;
|
|
371
|
+
/** Bearer to embed verbatim. Ignored when `useEnvVars` is true. */
|
|
372
|
+
bearer: string;
|
|
373
|
+
/** Emit `${PARACHUTE_HUB_URL}` / `${PARACHUTE_VAULT_TOKEN}` placeholders instead of inlined values. */
|
|
374
|
+
useEnvVars?: boolean;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function buildMcpConfigJson(opts: BuildMcpConfigJsonOpts): string {
|
|
378
|
+
const { vaultName, baseUrl, bearer, useEnvVars } = opts;
|
|
379
|
+
const entryKey = `parachute-vault-${vaultName}`;
|
|
380
|
+
const url = useEnvVars
|
|
381
|
+
? `\${PARACHUTE_HUB_URL}/vault/${vaultName}/mcp`
|
|
382
|
+
: `${baseUrl.replace(/\/$/, "")}/vault/${vaultName}/mcp`;
|
|
383
|
+
const authValue = useEnvVars
|
|
384
|
+
? "Bearer ${PARACHUTE_VAULT_TOKEN}"
|
|
385
|
+
: `Bearer ${bearer}`;
|
|
386
|
+
const config = {
|
|
387
|
+
mcpServers: {
|
|
388
|
+
[entryKey]: {
|
|
389
|
+
type: "http",
|
|
390
|
+
url,
|
|
391
|
+
headers: { Authorization: authValue },
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
return JSON.stringify(config, null, 2);
|
|
396
|
+
}
|
|
397
|
+
|
|
337
398
|
// ---------------------------------------------------------------------------
|
|
338
399
|
// Install target resolver
|
|
339
400
|
// ---------------------------------------------------------------------------
|