@openparachute/vault 0.4.0 → 0.4.4-rc.11
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 +191 -2
- package/core/src/core.test.ts +1295 -526
- package/core/src/mcp.ts +129 -428
- package/core/src/notes.ts +405 -32
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +233 -171
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +103 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +52 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +330 -207
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +1052 -333
- package/core/src/note-schemas.ts +0 -232
package/src/mcp-install.ts
CHANGED
|
@@ -1,36 +1,133 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* `~/.claude.json` must match vault's advertised OAuth issuer for the origin
|
|
4
|
-
* the client will reach the server on — otherwise strict clients (Claude
|
|
5
|
-
* Code's MCP SDK) reject discovery on origin/issuer mismatch (RFC 8414 §3.1).
|
|
2
|
+
* Helpers for `parachute-vault mcp-install`. Three concerns live here:
|
|
6
3
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* 1. **URL pickers.** The MCP URL written into the client config must match
|
|
5
|
+
* vault's advertised OAuth issuer for the origin the client will reach
|
|
6
|
+
* the server on — otherwise strict clients (Claude Code's MCP SDK)
|
|
7
|
+
* reject discovery on origin/issuer mismatch (RFC 8414 §3.1). Two
|
|
8
|
+
* pickers: `chooseMcpUrl` returns the full `<origin>/vault/<name>/mcp`
|
|
9
|
+
* shape for the entry; `chooseHubOrigin` returns the bare `<origin>` for
|
|
10
|
+
* the hub-mint API call.
|
|
11
|
+
*
|
|
12
|
+
* 2. **Operator-token reader.** Reads `~/.parachute/operator.token` (or
|
|
13
|
+
* `$PARACHUTE_HOME/operator.token`). The hub-mint path uses it as the
|
|
14
|
+
* bearer for `POST <hub>/api/auth/mint-token`. Returns null when absent
|
|
15
|
+
* or empty — caller decides whether that's a hard error.
|
|
16
|
+
*
|
|
17
|
+
* 3. **Hub mint-token client.** `mintHubJwt` posts to
|
|
18
|
+
* `<hub>/api/auth/mint-token` with the operator bearer and returns the
|
|
19
|
+
* scope-narrow JWT. Test seam for `fetch` injected as an opt so unit
|
|
20
|
+
* tests don't need a real hub.
|
|
21
|
+
*
|
|
22
|
+
* 4. **Install target resolver.** `resolveInstallTarget` picks between
|
|
23
|
+
* `~/.claude.json` (user) and `./.mcp.json` (project) based on the
|
|
24
|
+
* `--install-scope` flag.
|
|
12
25
|
*/
|
|
13
26
|
|
|
14
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
28
|
import { homedir } from "node:os";
|
|
16
29
|
import { resolve } from "node:path";
|
|
17
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Strip every vault MCP entry from ~/.claude.json (user-scope + every
|
|
33
|
+
* local-scope `projects[*].mcpServers`) and ./.mcp.json (project-scope at
|
|
34
|
+
* cwd). Cleanup walks every `projects[*]` slot so an operator who installed
|
|
35
|
+
* locally in one directory can uninstall from anywhere without remembering
|
|
36
|
+
* where. Silent no-op on missing files / malformed JSON.
|
|
37
|
+
*
|
|
38
|
+
* Lives in mcp-install.ts (not cli.ts) so tests can call it directly
|
|
39
|
+
* without triggering cli.ts's top-level dispatch on import.
|
|
40
|
+
*/
|
|
41
|
+
export function removeMcpConfig(): void {
|
|
42
|
+
// Prefer `process.env.HOME` over cached `homedir()` — Bun caches the OS
|
|
43
|
+
// userinfo at process start so in-process HOME overrides (tests, exotic
|
|
44
|
+
// chrooting) don't apply via homedir(). Matches resolveInstallTarget's
|
|
45
|
+
// home-resolution pattern.
|
|
46
|
+
const home = process.env.HOME ?? homedir();
|
|
47
|
+
const claudeJsonPath = resolve(home, ".claude.json");
|
|
48
|
+
const projectMcpJsonPath = resolve(process.cwd(), ".mcp.json");
|
|
49
|
+
for (const path of [claudeJsonPath, projectMcpJsonPath]) {
|
|
50
|
+
if (!existsSync(path)) continue;
|
|
51
|
+
try {
|
|
52
|
+
const config = JSON.parse(readFileSync(path, "utf-8"));
|
|
53
|
+
// Drop the singular key and every per-vault `parachute-vault-<name>`
|
|
54
|
+
// entry. Legacy `parachute-vault/<name>` (slash-form) sub-keys from a
|
|
55
|
+
// pre-multi-vault pattern still get cleaned up here.
|
|
56
|
+
const stripVaultKeys = (servers: Record<string, unknown> | undefined) => {
|
|
57
|
+
if (!servers) return;
|
|
58
|
+
for (const key of Object.keys(servers)) {
|
|
59
|
+
if (
|
|
60
|
+
key === "parachute-vault" ||
|
|
61
|
+
key.startsWith("parachute-vault-") ||
|
|
62
|
+
key.startsWith("parachute-vault/")
|
|
63
|
+
) {
|
|
64
|
+
delete servers[key];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
stripVaultKeys(config.mcpServers);
|
|
69
|
+
// Local-scope cleanup: walk every project entry and strip vault keys.
|
|
70
|
+
if (config.projects && typeof config.projects === "object") {
|
|
71
|
+
for (const projectKey of Object.keys(config.projects)) {
|
|
72
|
+
const projectEntry = config.projects[projectKey];
|
|
73
|
+
if (projectEntry && typeof projectEntry === "object") {
|
|
74
|
+
stripVaultKeys(projectEntry.mcpServers);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// URL picking
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
18
87
|
export type McpUrlSource = "hub-origin" | "expose-state" | "loopback";
|
|
19
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Pick the URL written into the MCP client's `mcpServers.<key>.url` slot.
|
|
91
|
+
* Returns the per-vault MCP endpoint (`/vault/<name>/mcp`); see
|
|
92
|
+
* `chooseHubOrigin` for the bare-origin form used by hub API calls.
|
|
93
|
+
*
|
|
94
|
+
* Source order:
|
|
95
|
+
* 1. `PARACHUTE_HUB_ORIGIN` env (vault is advertising the hub as issuer).
|
|
96
|
+
* 2. `~/.parachute/expose-state.json` canonical FQDN (active tailnet /
|
|
97
|
+
* public exposure the CLI brought up).
|
|
98
|
+
* 3. Loopback on the configured port.
|
|
99
|
+
*/
|
|
20
100
|
export function chooseMcpUrl(
|
|
21
101
|
vaultName: string,
|
|
22
102
|
port: number,
|
|
23
103
|
env: { PARACHUTE_HUB_ORIGIN?: string | undefined } = process.env as { PARACHUTE_HUB_ORIGIN?: string },
|
|
104
|
+
): { url: string; source: McpUrlSource } {
|
|
105
|
+
const origin = chooseHubOrigin(port, env);
|
|
106
|
+
return { url: `${origin.url}/vault/${vaultName}/mcp`, source: origin.source };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Pick the bare hub origin (no path suffix). Used by hub-mint when posting
|
|
111
|
+
* to `<origin>/api/auth/mint-token`. Same source order as `chooseMcpUrl`.
|
|
112
|
+
*
|
|
113
|
+
* Note: when the source is `loopback`, the origin is *vault's* loopback URL,
|
|
114
|
+
* not a hub. Hub-mint against a loopback origin will fail at the network
|
|
115
|
+
* layer (no hub on that port) — the caller catches and surfaces a clear
|
|
116
|
+
* "no hub configured" error.
|
|
117
|
+
*/
|
|
118
|
+
export function chooseHubOrigin(
|
|
119
|
+
port: number,
|
|
120
|
+
env: { PARACHUTE_HUB_ORIGIN?: string | undefined } = process.env as { PARACHUTE_HUB_ORIGIN?: string },
|
|
24
121
|
): { url: string; source: McpUrlSource } {
|
|
25
122
|
const hub = env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
|
|
26
123
|
if (hub) {
|
|
27
|
-
return { url:
|
|
124
|
+
return { url: hub, source: "hub-origin" };
|
|
28
125
|
}
|
|
29
126
|
const fqdn = readExposedFqdn();
|
|
30
127
|
if (fqdn) {
|
|
31
|
-
return { url: `https://${fqdn}
|
|
128
|
+
return { url: `https://${fqdn}`, source: "expose-state" };
|
|
32
129
|
}
|
|
33
|
-
return { url: `http://127.0.0.1:${port}
|
|
130
|
+
return { url: `http://127.0.0.1:${port}`, source: "loopback" };
|
|
34
131
|
}
|
|
35
132
|
|
|
36
133
|
/**
|
|
@@ -58,3 +155,473 @@ function readExposedFqdn(): string | undefined {
|
|
|
58
155
|
} catch {}
|
|
59
156
|
return undefined;
|
|
60
157
|
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Entry-key + URL builder (shared by preview render + write path)
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Single source of truth for the MCP entry's slot key and URL. Both the
|
|
165
|
+
* interactive walkthrough's preview render and the writer (`executeMcpInstall`
|
|
166
|
+
* → `installMcpConfig`) call this so the JSON shape the operator confirms is
|
|
167
|
+
* the JSON shape that lands on disk. Drift between the two would silently
|
|
168
|
+
* mislead — they used to compute these independently (preview from
|
|
169
|
+
* `${ctx.hubOrigin}/vault/<name>/mcp` directly; writer through `chooseMcpUrl`).
|
|
170
|
+
* They agree today but a future change to one path could diverge from the
|
|
171
|
+
* other. vault#293.
|
|
172
|
+
*
|
|
173
|
+
* `existingEntryKey` wins when an update of a pre-existing entry is in
|
|
174
|
+
* progress — the walkthrough already pins this earlier in the flow; passing
|
|
175
|
+
* it here just keeps the preview honest about the slot the writer will
|
|
176
|
+
* actually use.
|
|
177
|
+
*/
|
|
178
|
+
export function buildMcpEntryPlan(opts: {
|
|
179
|
+
vaultName: string;
|
|
180
|
+
vaultExplicit: boolean;
|
|
181
|
+
port: number;
|
|
182
|
+
env?: { PARACHUTE_HUB_ORIGIN?: string | undefined };
|
|
183
|
+
/** When updating an existing entry, the slot key the operator picked previously. */
|
|
184
|
+
existingEntryKey?: string;
|
|
185
|
+
}): { entryKey: string; url: string; source: McpUrlSource } {
|
|
186
|
+
const { vaultName, vaultExplicit, port, env, existingEntryKey } = opts;
|
|
187
|
+
const entryKey =
|
|
188
|
+
existingEntryKey ??
|
|
189
|
+
(vaultExplicit ? `parachute-vault-${vaultName}` : "parachute-vault");
|
|
190
|
+
const { url, source } = chooseMcpUrl(vaultName, port, env ?? (process.env as { PARACHUTE_HUB_ORIGIN?: string }));
|
|
191
|
+
return { entryKey, url, source };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Operator-token reader
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Read the operator bearer from `<root>/operator.token`. Root is
|
|
200
|
+
* `$PARACHUTE_HOME` if set, otherwise `~/.parachute`. Returns null when the
|
|
201
|
+
* file is absent or empty — caller decides whether that's a hard error.
|
|
202
|
+
*
|
|
203
|
+
* We don't enforce the 0600 mode check here (hub's reader does). The CLI
|
|
204
|
+
* runs locally; if the operator chmod'd it loose, that's already their
|
|
205
|
+
* footgun, and vault's install command isn't the place to gate on it.
|
|
206
|
+
*/
|
|
207
|
+
export function readOperatorToken(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
208
|
+
try {
|
|
209
|
+
const root = env.PARACHUTE_HOME ?? resolve(homedir(), ".parachute");
|
|
210
|
+
const path = resolve(root, "operator.token");
|
|
211
|
+
if (!existsSync(path)) return null;
|
|
212
|
+
const raw = readFileSync(path, "utf-8").trim();
|
|
213
|
+
return raw.length > 0 ? raw : null;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Hub mint-token client
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Default lifetime when `--expires-in` isn't passed. Matches the hub CLI's
|
|
225
|
+
* default (90 days) — see `parachute-hub/src/api-mint-token.ts`.
|
|
226
|
+
*/
|
|
227
|
+
export const HUB_MINT_DEFAULT_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Result of a successful hub mint-token call. Shape mirrors the hub HTTP
|
|
231
|
+
* response so callers can pass `expires_at` through to logs / UX.
|
|
232
|
+
*/
|
|
233
|
+
export interface MintedHubJwt {
|
|
234
|
+
/** The signed JWT to write into `Authorization: Bearer …`. */
|
|
235
|
+
token: string;
|
|
236
|
+
/** JTI for revocation. */
|
|
237
|
+
jti: string;
|
|
238
|
+
/** ISO timestamp at which the token expires. */
|
|
239
|
+
expires_at: string;
|
|
240
|
+
/** Whitespace-joined scope claim, mirrors the request. */
|
|
241
|
+
scope: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Discriminated failure modes from `mintHubJwt`. Callers turn each into a
|
|
246
|
+
* different operator-facing message — hub-unreachable has its own remediation
|
|
247
|
+
* (check `PARACHUTE_HUB_ORIGIN` / start the hub); API-error propagates the
|
|
248
|
+
* hub's own `error_description`.
|
|
249
|
+
*
|
|
250
|
+
* Operator-token absence is *not* a `mintHubJwt` failure mode: the caller is
|
|
251
|
+
* responsible for `readOperatorToken()` before invoking us — by the time we
|
|
252
|
+
* see `operatorToken: string`, it's guaranteed present.
|
|
253
|
+
*/
|
|
254
|
+
export type MintHubJwtError =
|
|
255
|
+
| { kind: "network"; cause: string; origin: string }
|
|
256
|
+
| { kind: "api-error"; status: number; error: string; description: string };
|
|
257
|
+
|
|
258
|
+
export interface MintHubJwtOpts {
|
|
259
|
+
hubOrigin: string;
|
|
260
|
+
operatorToken: string;
|
|
261
|
+
scope: string;
|
|
262
|
+
subject?: string;
|
|
263
|
+
expiresInSeconds?: number;
|
|
264
|
+
/** Test seam — defaults to global fetch. */
|
|
265
|
+
fetchImpl?: typeof fetch;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* POST to `<hub>/api/auth/mint-token`. The operator-token bearer must carry
|
|
270
|
+
* `parachute:host:auth` (the admin scope-set covers it). Returns the minted
|
|
271
|
+
* JWT or a discriminated error the caller turns into a clear message.
|
|
272
|
+
*
|
|
273
|
+
* Network errors are caught and returned as `{ kind: "network" }` rather
|
|
274
|
+
* than bubbling — the CLI doesn't want stack traces, and the operator wants
|
|
275
|
+
* to know *which* endpoint failed.
|
|
276
|
+
*/
|
|
277
|
+
export async function mintHubJwt(opts: MintHubJwtOpts): Promise<MintedHubJwt | MintHubJwtError> {
|
|
278
|
+
const url = `${opts.hubOrigin.replace(/\/$/, "")}/api/auth/mint-token`;
|
|
279
|
+
const body: Record<string, unknown> = {
|
|
280
|
+
scope: opts.scope,
|
|
281
|
+
expires_in: opts.expiresInSeconds ?? HUB_MINT_DEFAULT_TTL_SECONDS,
|
|
282
|
+
};
|
|
283
|
+
if (opts.subject) body.subject = opts.subject;
|
|
284
|
+
|
|
285
|
+
const fetchFn = opts.fetchImpl ?? fetch;
|
|
286
|
+
let res: Response;
|
|
287
|
+
try {
|
|
288
|
+
res = await fetchFn(url, {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: {
|
|
291
|
+
"Authorization": `Bearer ${opts.operatorToken}`,
|
|
292
|
+
"Content-Type": "application/json",
|
|
293
|
+
},
|
|
294
|
+
body: JSON.stringify(body),
|
|
295
|
+
});
|
|
296
|
+
} catch (err) {
|
|
297
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
298
|
+
return { kind: "network", cause, origin: opts.hubOrigin };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!res.ok) {
|
|
302
|
+
// Hub responses are JSON `{ error, error_description }`. Parse defensively
|
|
303
|
+
// — a misconfigured hub or a network appliance returning HTML for 502s
|
|
304
|
+
// shouldn't crash the CLI; we'll surface what we got.
|
|
305
|
+
let error = "unknown_error";
|
|
306
|
+
let description = `HTTP ${res.status}`;
|
|
307
|
+
try {
|
|
308
|
+
const payload = (await res.json()) as { error?: unknown; error_description?: unknown };
|
|
309
|
+
if (typeof payload.error === "string") error = payload.error;
|
|
310
|
+
if (typeof payload.error_description === "string") description = payload.error_description;
|
|
311
|
+
} catch {}
|
|
312
|
+
return { kind: "api-error", status: res.status, error, description };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const payload = (await res.json()) as Partial<MintedHubJwt>;
|
|
316
|
+
if (
|
|
317
|
+
typeof payload.token !== "string" ||
|
|
318
|
+
typeof payload.jti !== "string" ||
|
|
319
|
+
typeof payload.expires_at !== "string" ||
|
|
320
|
+
typeof payload.scope !== "string"
|
|
321
|
+
) {
|
|
322
|
+
return {
|
|
323
|
+
kind: "api-error",
|
|
324
|
+
status: res.status,
|
|
325
|
+
error: "malformed_response",
|
|
326
|
+
description: "hub mint-token response is missing required fields (token/jti/expires_at/scope)",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
token: payload.token,
|
|
331
|
+
jti: payload.jti,
|
|
332
|
+
expires_at: payload.expires_at,
|
|
333
|
+
scope: payload.scope,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Install target resolver
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
export type InstallScope = "user" | "project" | "local";
|
|
342
|
+
|
|
343
|
+
export interface InstallTarget {
|
|
344
|
+
/** Absolute path the install will write to. */
|
|
345
|
+
path: string;
|
|
346
|
+
/** Which scope the path corresponds to (for log lines + doctor). */
|
|
347
|
+
scope: InstallScope;
|
|
348
|
+
/**
|
|
349
|
+
* For `local` scope: the absolute CWD the entry is keyed under inside
|
|
350
|
+
* `~/.claude.json`'s `projects` map. Undefined for `user` and `project`.
|
|
351
|
+
*/
|
|
352
|
+
localProjectKey?: string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Pick the MCP client config file path based on `--install-scope`. Three
|
|
357
|
+
* shapes:
|
|
358
|
+
*
|
|
359
|
+
* user → `~/.claude.json` top-level `mcpServers` (global, every project).
|
|
360
|
+
* project → `<cwd>/.mcp.json` (Claude Code convention; check into the repo).
|
|
361
|
+
* local → `~/.claude.json` under `projects[<absolute-cwd>].mcpServers`
|
|
362
|
+
* (private to this machine, scoped to this directory). Matches
|
|
363
|
+
* Claude's own `claude mcp add` default.
|
|
364
|
+
*
|
|
365
|
+
* `homedir()` from node:os is cached at process start on Bun, so in-process
|
|
366
|
+
* `process.env.HOME` overrides don't propagate to it. We prefer the
|
|
367
|
+
* (mutable) env var when set so tests that flip `HOME` see the override
|
|
368
|
+
* without subprocess-spawning. Falls back to `homedir()` for the
|
|
369
|
+
* common case where neither tests nor exotic chrooting touches HOME.
|
|
370
|
+
*/
|
|
371
|
+
export function resolveInstallTarget(
|
|
372
|
+
scope: InstallScope,
|
|
373
|
+
cwd: string = process.cwd(),
|
|
374
|
+
): InstallTarget {
|
|
375
|
+
if (scope === "project") {
|
|
376
|
+
return { path: resolve(cwd, ".mcp.json"), scope: "project" };
|
|
377
|
+
}
|
|
378
|
+
const home = process.env.HOME ?? homedir();
|
|
379
|
+
const claudeJson = resolve(home, ".claude.json");
|
|
380
|
+
if (scope === "local") {
|
|
381
|
+
return {
|
|
382
|
+
path: claudeJson,
|
|
383
|
+
scope: "local",
|
|
384
|
+
localProjectKey: resolve(cwd),
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { path: claudeJson, scope: "user" };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Context detection — feeds the interactive walkthrough's smart defaults
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* "Is this a project directory?" heuristic. Looks for any of the common
|
|
396
|
+
* project-root markers in the supplied directory (defaults to CWD):
|
|
397
|
+
*
|
|
398
|
+
* .git — git checkout (the strong signal)
|
|
399
|
+
* package.json — Node/Bun project
|
|
400
|
+
* pyproject.toml — Python project (modern)
|
|
401
|
+
* Cargo.toml — Rust project
|
|
402
|
+
* go.mod — Go module
|
|
403
|
+
* deno.json — Deno project
|
|
404
|
+
* .parachute — Parachute config dir present (matches our own convention)
|
|
405
|
+
*
|
|
406
|
+
* The detection is intentionally shallow — only the supplied directory,
|
|
407
|
+
* not its ancestors. Walking up to find a marker would create surprising
|
|
408
|
+
* defaults from arbitrary subdirectories ("why does installing from
|
|
409
|
+
* ~/code/myproject/subdir behave like ~/code/myproject?"). The operator
|
|
410
|
+
* can always opt explicitly with `--install-scope project` from anywhere.
|
|
411
|
+
*/
|
|
412
|
+
export function detectProjectContext(cwd: string = process.cwd()): boolean {
|
|
413
|
+
const markers = [
|
|
414
|
+
".git",
|
|
415
|
+
"package.json",
|
|
416
|
+
"pyproject.toml",
|
|
417
|
+
"Cargo.toml",
|
|
418
|
+
"go.mod",
|
|
419
|
+
"deno.json",
|
|
420
|
+
".parachute",
|
|
421
|
+
];
|
|
422
|
+
for (const marker of markers) {
|
|
423
|
+
if (existsSync(resolve(cwd, marker))) return true;
|
|
424
|
+
}
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Shape of an existing vault MCP entry the interactive walkthrough cares
|
|
430
|
+
* about. Used to default the "update where it is" branch in the install-
|
|
431
|
+
* scope prompt without re-reading the same files multiple times.
|
|
432
|
+
*/
|
|
433
|
+
export interface ExistingMcpEntry {
|
|
434
|
+
/** Absolute path of the config file the entry lives in. */
|
|
435
|
+
path: string;
|
|
436
|
+
/** Display label for prompts (~/.claude.json or ./.mcp.json). */
|
|
437
|
+
label: string;
|
|
438
|
+
/** Whether the entry sits in user or project scope. */
|
|
439
|
+
scope: InstallScope;
|
|
440
|
+
/** `mcpServers` key the entry occupies. */
|
|
441
|
+
entryKey: string;
|
|
442
|
+
/** The URL field of the entry (operator-visible state). */
|
|
443
|
+
url: string;
|
|
444
|
+
/** Whether the entry has an Authorization header. */
|
|
445
|
+
hasAuth: boolean;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Look for an existing parachute-vault entry across the three scopes:
|
|
450
|
+
* user — `~/.claude.json` top-level `mcpServers`.
|
|
451
|
+
* local — `~/.claude.json` under `projects[<cwd>].mcpServers`.
|
|
452
|
+
* project — `<cwd>/.mcp.json`.
|
|
453
|
+
*
|
|
454
|
+
* Returns each matching entry independently so the walkthrough can present
|
|
455
|
+
* the most relevant one to the operator.
|
|
456
|
+
*
|
|
457
|
+
* `parachute-vault` (singular) wins over `parachute-vault-<name>` at the
|
|
458
|
+
* same file — the singular slot is the canonical default install; per-
|
|
459
|
+
* vault keys are the multi-vault add-ons.
|
|
460
|
+
*/
|
|
461
|
+
export function detectExistingEntries(
|
|
462
|
+
cwd: string = process.cwd(),
|
|
463
|
+
): { user?: ExistingMcpEntry; local?: ExistingMcpEntry; project?: ExistingMcpEntry } {
|
|
464
|
+
const userTarget = resolveInstallTarget("user");
|
|
465
|
+
const localTarget = resolveInstallTarget("local", cwd);
|
|
466
|
+
const projectTarget = resolveInstallTarget("project", cwd);
|
|
467
|
+
const userConfig = readJsonOrNull(userTarget.path);
|
|
468
|
+
return {
|
|
469
|
+
...maybeUserEntry(userConfig, userTarget.path),
|
|
470
|
+
...maybeLocalEntry(userConfig, localTarget.path, localTarget.localProjectKey!),
|
|
471
|
+
...maybeProjectEntry(projectTarget.path, `${cwd}/.mcp.json`),
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
function readJsonOrNull(p: string): any {
|
|
475
|
+
if (!existsSync(p)) return null;
|
|
476
|
+
try {
|
|
477
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
478
|
+
} catch {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function pickEntry(servers: Record<string, any>): { entry: any; entryKey: string } | null {
|
|
484
|
+
let entry = servers["parachute-vault"];
|
|
485
|
+
let entryKey = "parachute-vault";
|
|
486
|
+
if (!entry) {
|
|
487
|
+
for (const key of Object.keys(servers)) {
|
|
488
|
+
if (key.startsWith("parachute-vault-")) {
|
|
489
|
+
entry = servers[key];
|
|
490
|
+
entryKey = key;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (!entry || typeof entry.url !== "string") return null;
|
|
496
|
+
return { entry, entryKey };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function maybeUserEntry(config: any, p: string): { user?: ExistingMcpEntry } | {} {
|
|
500
|
+
if (!config) return {};
|
|
501
|
+
const servers: Record<string, any> = config?.mcpServers ?? {};
|
|
502
|
+
const picked = pickEntry(servers);
|
|
503
|
+
if (!picked) return {};
|
|
504
|
+
return {
|
|
505
|
+
user: {
|
|
506
|
+
path: p,
|
|
507
|
+
label: "~/.claude.json",
|
|
508
|
+
scope: "user",
|
|
509
|
+
entryKey: picked.entryKey,
|
|
510
|
+
url: picked.entry.url,
|
|
511
|
+
hasAuth: Boolean(picked.entry.headers?.Authorization),
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function maybeLocalEntry(
|
|
517
|
+
config: any,
|
|
518
|
+
p: string,
|
|
519
|
+
projectKey: string,
|
|
520
|
+
): { local?: ExistingMcpEntry } | {} {
|
|
521
|
+
if (!config) return {};
|
|
522
|
+
const servers: Record<string, any> = config?.projects?.[projectKey]?.mcpServers ?? {};
|
|
523
|
+
const picked = pickEntry(servers);
|
|
524
|
+
if (!picked) return {};
|
|
525
|
+
return {
|
|
526
|
+
local: {
|
|
527
|
+
path: p,
|
|
528
|
+
label: `~/.claude.json (projects["${projectKey}"])`,
|
|
529
|
+
scope: "local",
|
|
530
|
+
entryKey: picked.entryKey,
|
|
531
|
+
url: picked.entry.url,
|
|
532
|
+
hasAuth: Boolean(picked.entry.headers?.Authorization),
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function maybeProjectEntry(p: string, label: string): { project?: ExistingMcpEntry } | {} {
|
|
538
|
+
const config = readJsonOrNull(p);
|
|
539
|
+
if (!config) return {};
|
|
540
|
+
const servers: Record<string, any> = config?.mcpServers ?? {};
|
|
541
|
+
const picked = pickEntry(servers);
|
|
542
|
+
if (!picked) return {};
|
|
543
|
+
return {
|
|
544
|
+
project: {
|
|
545
|
+
path: p,
|
|
546
|
+
label,
|
|
547
|
+
scope: "project",
|
|
548
|
+
entryKey: picked.entryKey,
|
|
549
|
+
url: picked.entry.url,
|
|
550
|
+
hasAuth: Boolean(picked.entry.headers?.Authorization),
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Snapshot of everything the interactive walkthrough needs to pick smart
|
|
558
|
+
* defaults. Computed once at the start of the flow so each prompt's
|
|
559
|
+
* reasoning is consistent (no surprise mid-flow re-reads).
|
|
560
|
+
*/
|
|
561
|
+
export interface InstallContext {
|
|
562
|
+
/** All vault names declared on this host. */
|
|
563
|
+
vaults: string[];
|
|
564
|
+
/** The vault `default_vault` config points at (or first vault, or "default"). */
|
|
565
|
+
defaultVault: string;
|
|
566
|
+
/** Whether a hub origin is configured beyond the loopback fallback. */
|
|
567
|
+
hubReachable: boolean;
|
|
568
|
+
/** The resolved hub origin (loopback if no hub configured). */
|
|
569
|
+
hubOrigin: string;
|
|
570
|
+
/**
|
|
571
|
+
* Vault listen port. Carried on the context so the preview's
|
|
572
|
+
* `buildMcpEntryPlan` call resolves the MCP URL through the same
|
|
573
|
+
* `chooseMcpUrl` path the writer uses. Without this, preview was building
|
|
574
|
+
* the URL from `${hubOrigin}/vault/<name>/mcp` directly — coincidentally
|
|
575
|
+
* identical today but liable to drift.
|
|
576
|
+
*/
|
|
577
|
+
port: number;
|
|
578
|
+
/**
|
|
579
|
+
* Environment snapshot used for hub-origin resolution. Held on the
|
|
580
|
+
* context so the preview's URL build sees the same `PARACHUTE_HUB_ORIGIN`
|
|
581
|
+
* the writer will see (tests can override deterministically).
|
|
582
|
+
*/
|
|
583
|
+
env: { PARACHUTE_HUB_ORIGIN?: string | undefined };
|
|
584
|
+
/** Whether `~/.parachute/operator.token` exists and is non-empty. */
|
|
585
|
+
operatorTokenPresent: boolean;
|
|
586
|
+
/** Heuristic: is CWD a project directory? */
|
|
587
|
+
inProjectContext: boolean;
|
|
588
|
+
/** Where the walkthrough was invoked from. */
|
|
589
|
+
cwd: string;
|
|
590
|
+
/** Pre-existing entries at user / local / project scope, if any. */
|
|
591
|
+
existing: { user?: ExistingMcpEntry; local?: ExistingMcpEntry; project?: ExistingMcpEntry };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Build an `InstallContext` from the current process + filesystem. Pure-
|
|
596
|
+
* function-shaped (takes everything it needs as args with sensible
|
|
597
|
+
* defaults), so tests can synthesize alternate contexts without monkey-
|
|
598
|
+
* patching globals.
|
|
599
|
+
*/
|
|
600
|
+
export function detectInstallContext(opts: {
|
|
601
|
+
vaults: string[];
|
|
602
|
+
defaultVault: string;
|
|
603
|
+
port: number;
|
|
604
|
+
env?: NodeJS.ProcessEnv;
|
|
605
|
+
cwd?: string;
|
|
606
|
+
}): InstallContext {
|
|
607
|
+
const env = opts.env ?? process.env;
|
|
608
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
609
|
+
// Narrow to chooseHubOrigin's expected shape — NodeJS.ProcessEnv is a
|
|
610
|
+
// string-index type that doesn't structurally match the explicit shape
|
|
611
|
+
// chooseHubOrigin declares; passing a sliced view sidesteps the
|
|
612
|
+
// structural-incompatibility complaint without losing safety.
|
|
613
|
+
const hubEnv = { PARACHUTE_HUB_ORIGIN: env.PARACHUTE_HUB_ORIGIN };
|
|
614
|
+
const hub = chooseHubOrigin(opts.port, hubEnv);
|
|
615
|
+
return {
|
|
616
|
+
vaults: opts.vaults,
|
|
617
|
+
defaultVault: opts.defaultVault,
|
|
618
|
+
hubReachable: hub.source !== "loopback",
|
|
619
|
+
hubOrigin: hub.url,
|
|
620
|
+
port: opts.port,
|
|
621
|
+
env: hubEnv,
|
|
622
|
+
operatorTokenPresent: readOperatorToken(env) !== null,
|
|
623
|
+
inProjectContext: detectProjectContext(cwd),
|
|
624
|
+
cwd,
|
|
625
|
+
existing: detectExistingEntries(cwd),
|
|
626
|
+
};
|
|
627
|
+
}
|