@openparachute/vault 0.4.8 → 0.4.9-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/core/src/core.test.ts +4 -1
- package/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +99 -41
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +304 -1
- package/core/src/portable-md.ts +418 -2
- package/core/src/schema.ts +114 -2
- package/core/src/store.ts +185 -2
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +147 -0
- package/src/auth.ts +121 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/cli.ts +131 -36
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/mcp-http.ts +48 -39
- package/src/mcp-install-interactive.test.ts +10 -21
- package/src/mcp-install-interactive.ts +12 -21
- package/src/mcp-install.test.ts +141 -30
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +460 -3
- package/src/mirror-config.test.ts +277 -14
- package/src/mirror-config.ts +482 -31
- package/src/mirror-credentials.test.ts +601 -0
- package/src/mirror-credentials.ts +700 -0
- package/src/mirror-deps.ts +67 -17
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +487 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +621 -72
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1111 -7
- package/src/module-config.ts +11 -5
- package/src/routes.ts +38 -1
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +193 -20
- package/src/server.ts +116 -35
- package/src/storage.test.ts +132 -7
- package/src/token-store.ts +300 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +681 -2
- package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the GitHub Device Flow client. All HTTP calls go through an
|
|
3
|
+
* injected fetch — no real network round-trips, no real GitHub OAuth app.
|
|
4
|
+
*
|
|
5
|
+
* Coverage:
|
|
6
|
+
* - requestDeviceCode happy path + missing-field error
|
|
7
|
+
* - pollForToken: granted / pending / slow_down / expired / denied
|
|
8
|
+
* - fetchUser happy path
|
|
9
|
+
* - listRepos: single-page, paginated, truncated
|
|
10
|
+
* - createRepo happy path
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createRepo,
|
|
17
|
+
fetchUser,
|
|
18
|
+
GITHUB_CLIENT_ID_PLACEHOLDER,
|
|
19
|
+
getGithubClientId,
|
|
20
|
+
isPlaceholderClientId,
|
|
21
|
+
listRepos,
|
|
22
|
+
pollForToken,
|
|
23
|
+
requestDeviceCode,
|
|
24
|
+
type FetchLike,
|
|
25
|
+
} from "./github-device-flow.ts";
|
|
26
|
+
|
|
27
|
+
/** Build a mock fetch that returns a predefined response per URL match. */
|
|
28
|
+
function mockFetch(
|
|
29
|
+
responses: Array<{
|
|
30
|
+
match: (url: string) => boolean;
|
|
31
|
+
response: {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
status: number;
|
|
34
|
+
body: unknown;
|
|
35
|
+
};
|
|
36
|
+
}>,
|
|
37
|
+
): FetchLike {
|
|
38
|
+
let callIdx = 0;
|
|
39
|
+
return async (url) => {
|
|
40
|
+
// Walk in order; allow each handler to be hit once unless multi-use.
|
|
41
|
+
for (let i = callIdx; i < responses.length; i++) {
|
|
42
|
+
if (responses[i]!.match(url)) {
|
|
43
|
+
callIdx = i + 1;
|
|
44
|
+
const r = responses[i]!.response;
|
|
45
|
+
return {
|
|
46
|
+
ok: r.ok,
|
|
47
|
+
status: r.status,
|
|
48
|
+
text: async () => (typeof r.body === "string" ? r.body : JSON.stringify(r.body)),
|
|
49
|
+
json: async () => r.body,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`mockFetch: no matching response for ${url}`);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Client id helpers
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
describe("client id helpers", () => {
|
|
62
|
+
test("getGithubClientId reads from env when set", () => {
|
|
63
|
+
const prev = process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
64
|
+
try {
|
|
65
|
+
process.env.PARACHUTE_GITHUB_CLIENT_ID = "Iv1.realclient";
|
|
66
|
+
expect(getGithubClientId()).toBe("Iv1.realclient");
|
|
67
|
+
} finally {
|
|
68
|
+
if (prev === undefined) delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
69
|
+
else process.env.PARACHUTE_GITHUB_CLIENT_ID = prev;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("getGithubClientId falls back to placeholder when env unset", () => {
|
|
74
|
+
const prev = process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
75
|
+
delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
|
|
76
|
+
try {
|
|
77
|
+
expect(getGithubClientId()).toBe(GITHUB_CLIENT_ID_PLACEHOLDER);
|
|
78
|
+
} finally {
|
|
79
|
+
if (prev !== undefined) process.env.PARACHUTE_GITHUB_CLIENT_ID = prev;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("isPlaceholderClientId catches the literal + the substring", () => {
|
|
84
|
+
expect(isPlaceholderClientId(GITHUB_CLIENT_ID_PLACEHOLDER)).toBe(true);
|
|
85
|
+
expect(isPlaceholderClientId("PLACEHOLDER_X")).toBe(true);
|
|
86
|
+
expect(isPlaceholderClientId("Iv1.realone")).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// requestDeviceCode
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
describe("requestDeviceCode", () => {
|
|
95
|
+
test("returns the GitHub device-code tuple on success", async () => {
|
|
96
|
+
const fetcher = mockFetch([
|
|
97
|
+
{
|
|
98
|
+
match: (u) => u.includes("/login/device/code"),
|
|
99
|
+
response: {
|
|
100
|
+
ok: true,
|
|
101
|
+
status: 200,
|
|
102
|
+
body: {
|
|
103
|
+
device_code: "abc123",
|
|
104
|
+
user_code: "XXXX-YYYY",
|
|
105
|
+
verification_uri: "https://github.com/login/device",
|
|
106
|
+
expires_in: 900,
|
|
107
|
+
interval: 5,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
const result = await requestDeviceCode("Iv1.test", fetcher);
|
|
113
|
+
expect(result.device_code).toBe("abc123");
|
|
114
|
+
expect(result.user_code).toBe("XXXX-YYYY");
|
|
115
|
+
expect(result.interval).toBe(5);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("throws on missing required fields (bad shape from upstream)", async () => {
|
|
119
|
+
const fetcher = mockFetch([
|
|
120
|
+
{
|
|
121
|
+
match: () => true,
|
|
122
|
+
response: { ok: true, status: 200, body: { user_code: "X" } },
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
await expect(requestDeviceCode("Iv1.test", fetcher)).rejects.toThrow(
|
|
126
|
+
/missing required fields/,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("throws on non-2xx HTTP response", async () => {
|
|
131
|
+
const fetcher = mockFetch([
|
|
132
|
+
{
|
|
133
|
+
match: () => true,
|
|
134
|
+
response: { ok: false, status: 404, body: "not found" },
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
await expect(requestDeviceCode("Iv1.test", fetcher)).rejects.toThrow(/404/);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// pollForToken — every spec state
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
describe("pollForToken", () => {
|
|
146
|
+
test("granted state with access token", async () => {
|
|
147
|
+
const fetcher = mockFetch([
|
|
148
|
+
{
|
|
149
|
+
match: () => true,
|
|
150
|
+
response: {
|
|
151
|
+
ok: true,
|
|
152
|
+
status: 200,
|
|
153
|
+
body: { access_token: "gho_real", scope: "repo", token_type: "bearer" },
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
const r = await pollForToken("Iv1.test", "dev_code", fetcher);
|
|
158
|
+
expect(r.state).toBe("granted");
|
|
159
|
+
if (r.state === "granted") {
|
|
160
|
+
expect(r.access_token).toBe("gho_real");
|
|
161
|
+
expect(r.scope).toBe("repo");
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("pending state", async () => {
|
|
166
|
+
const fetcher = mockFetch([
|
|
167
|
+
{
|
|
168
|
+
match: () => true,
|
|
169
|
+
response: {
|
|
170
|
+
ok: true,
|
|
171
|
+
status: 200,
|
|
172
|
+
body: { error: "authorization_pending" },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
]);
|
|
176
|
+
const r = await pollForToken("Iv1.test", "dev_code", fetcher);
|
|
177
|
+
expect(r.state).toBe("pending");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("slow_down state carries new interval", async () => {
|
|
181
|
+
const fetcher = mockFetch([
|
|
182
|
+
{
|
|
183
|
+
match: () => true,
|
|
184
|
+
response: {
|
|
185
|
+
ok: true,
|
|
186
|
+
status: 200,
|
|
187
|
+
body: { error: "slow_down", interval: 10 },
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
]);
|
|
191
|
+
const r = await pollForToken("Iv1.test", "dev_code", fetcher);
|
|
192
|
+
expect(r.state).toBe("slow_down");
|
|
193
|
+
if (r.state === "slow_down") expect(r.interval).toBe(10);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("expired state", async () => {
|
|
197
|
+
const fetcher = mockFetch([
|
|
198
|
+
{
|
|
199
|
+
match: () => true,
|
|
200
|
+
response: { ok: true, status: 200, body: { error: "expired_token" } },
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
const r = await pollForToken("Iv1.test", "dev_code", fetcher);
|
|
204
|
+
expect(r.state).toBe("expired");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("denied state", async () => {
|
|
208
|
+
const fetcher = mockFetch([
|
|
209
|
+
{
|
|
210
|
+
match: () => true,
|
|
211
|
+
response: { ok: true, status: 200, body: { error: "access_denied" } },
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
const r = await pollForToken("Iv1.test", "dev_code", fetcher);
|
|
215
|
+
expect(r.state).toBe("denied");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("unknown error maps to denied (defense)", async () => {
|
|
219
|
+
const fetcher = mockFetch([
|
|
220
|
+
{
|
|
221
|
+
match: () => true,
|
|
222
|
+
response: {
|
|
223
|
+
ok: true,
|
|
224
|
+
status: 200,
|
|
225
|
+
body: { error: "weird_new_error" },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
]);
|
|
229
|
+
const r = await pollForToken("Iv1.test", "dev_code", fetcher);
|
|
230
|
+
expect(r.state).toBe("denied");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// fetchUser
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
describe("fetchUser", () => {
|
|
239
|
+
test("returns login + id (and optional name)", async () => {
|
|
240
|
+
const fetcher = mockFetch([
|
|
241
|
+
{
|
|
242
|
+
match: (u) => u.includes("/user"),
|
|
243
|
+
response: {
|
|
244
|
+
ok: true,
|
|
245
|
+
status: 200,
|
|
246
|
+
body: { login: "aaron", id: 12345, name: "Aaron Gabriel", avatar_url: "https://x/y.png" },
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
]);
|
|
250
|
+
const user = await fetchUser("gho_test", fetcher);
|
|
251
|
+
expect(user.login).toBe("aaron");
|
|
252
|
+
expect(user.id).toBe(12345);
|
|
253
|
+
expect(user.name).toBe("Aaron Gabriel");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("throws on missing required fields", async () => {
|
|
257
|
+
const fetcher = mockFetch([
|
|
258
|
+
{
|
|
259
|
+
match: () => true,
|
|
260
|
+
response: { ok: true, status: 200, body: { login: "x" } },
|
|
261
|
+
},
|
|
262
|
+
]);
|
|
263
|
+
await expect(fetchUser("gho_test", fetcher)).rejects.toThrow(/missing login or id/);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// listRepos
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
describe("listRepos", () => {
|
|
272
|
+
test("single page (< perPage repos) returns repos, untruncated", async () => {
|
|
273
|
+
const fetcher = mockFetch([
|
|
274
|
+
{
|
|
275
|
+
match: (u) => u.includes("page=1"),
|
|
276
|
+
response: {
|
|
277
|
+
ok: true,
|
|
278
|
+
status: 200,
|
|
279
|
+
body: [
|
|
280
|
+
{
|
|
281
|
+
name: "a",
|
|
282
|
+
full_name: "aaron/a",
|
|
283
|
+
private: true,
|
|
284
|
+
html_url: "https://github.com/aaron/a",
|
|
285
|
+
description: null,
|
|
286
|
+
updated_at: "2026-05-28T00:00:00Z",
|
|
287
|
+
clone_url: "https://github.com/aaron/a.git",
|
|
288
|
+
owner: { login: "aaron" },
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
]);
|
|
294
|
+
const result = await listRepos("gho_test", { perPage: 100, maxPages: 3 }, fetcher);
|
|
295
|
+
expect(result.repos).toHaveLength(1);
|
|
296
|
+
expect(result.truncated).toBe(false);
|
|
297
|
+
expect(result.repos[0]!.owner).toBe("aaron");
|
|
298
|
+
expect(result.repos[0]!.full_name).toBe("aaron/a");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("paginates until a short page (< perPage) signals the end", async () => {
|
|
302
|
+
const fullPage = Array.from({ length: 2 }, (_, i) => ({
|
|
303
|
+
name: `repo${i}`,
|
|
304
|
+
full_name: `aaron/repo${i}`,
|
|
305
|
+
private: false,
|
|
306
|
+
html_url: `https://github.com/aaron/repo${i}`,
|
|
307
|
+
description: null,
|
|
308
|
+
updated_at: "2026-05-28T00:00:00Z",
|
|
309
|
+
clone_url: `https://github.com/aaron/repo${i}.git`,
|
|
310
|
+
owner: { login: "aaron" },
|
|
311
|
+
}));
|
|
312
|
+
const shortPage = [
|
|
313
|
+
{
|
|
314
|
+
name: "last",
|
|
315
|
+
full_name: "aaron/last",
|
|
316
|
+
private: false,
|
|
317
|
+
html_url: "https://github.com/aaron/last",
|
|
318
|
+
description: null,
|
|
319
|
+
updated_at: "2026-05-28T00:00:00Z",
|
|
320
|
+
clone_url: "https://github.com/aaron/last.git",
|
|
321
|
+
owner: { login: "aaron" },
|
|
322
|
+
},
|
|
323
|
+
];
|
|
324
|
+
const fetcher = mockFetch([
|
|
325
|
+
{ match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
|
|
326
|
+
{ match: (u) => u.includes("page=2"), response: { ok: true, status: 200, body: shortPage } },
|
|
327
|
+
]);
|
|
328
|
+
const result = await listRepos("gho_test", { perPage: 2, maxPages: 3 }, fetcher);
|
|
329
|
+
expect(result.repos).toHaveLength(3);
|
|
330
|
+
expect(result.truncated).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("marks truncated when maxPages cap hit and page still full", async () => {
|
|
334
|
+
const fullPage = Array.from({ length: 2 }, (_, i) => ({
|
|
335
|
+
name: `repo${i}`,
|
|
336
|
+
full_name: `aaron/repo${i}`,
|
|
337
|
+
private: false,
|
|
338
|
+
html_url: `https://github.com/aaron/repo${i}`,
|
|
339
|
+
description: null,
|
|
340
|
+
updated_at: "2026-05-28T00:00:00Z",
|
|
341
|
+
clone_url: `https://github.com/aaron/repo${i}.git`,
|
|
342
|
+
owner: { login: "aaron" },
|
|
343
|
+
}));
|
|
344
|
+
const fetcher = mockFetch([
|
|
345
|
+
{ match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
|
|
346
|
+
{ match: (u) => u.includes("page=2"), response: { ok: true, status: 200, body: fullPage } },
|
|
347
|
+
]);
|
|
348
|
+
const result = await listRepos("gho_test", { perPage: 2, maxPages: 2 }, fetcher);
|
|
349
|
+
expect(result.repos).toHaveLength(4);
|
|
350
|
+
expect(result.truncated).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// createRepo
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
describe("createRepo", () => {
|
|
359
|
+
test("creates a private repo with description", async () => {
|
|
360
|
+
const fetcher = mockFetch([
|
|
361
|
+
{
|
|
362
|
+
match: (u) => u.includes("/user/repos"),
|
|
363
|
+
response: {
|
|
364
|
+
ok: true,
|
|
365
|
+
status: 201,
|
|
366
|
+
body: {
|
|
367
|
+
name: "my-vault",
|
|
368
|
+
full_name: "aaron/my-vault",
|
|
369
|
+
private: true,
|
|
370
|
+
html_url: "https://github.com/aaron/my-vault",
|
|
371
|
+
description: "Parachute Vault mirror",
|
|
372
|
+
updated_at: "2026-05-28T00:00:00Z",
|
|
373
|
+
clone_url: "https://github.com/aaron/my-vault.git",
|
|
374
|
+
owner: { login: "aaron" },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
]);
|
|
379
|
+
const repo = await createRepo(
|
|
380
|
+
"gho_test",
|
|
381
|
+
{ name: "my-vault", description: "Parachute Vault mirror" },
|
|
382
|
+
fetcher,
|
|
383
|
+
);
|
|
384
|
+
expect(repo.full_name).toBe("aaron/my-vault");
|
|
385
|
+
expect(repo.private).toBe(true);
|
|
386
|
+
expect(repo.clone_url).toBe("https://github.com/aaron/my-vault.git");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("surfaces GitHub error message on failure (name taken, validation)", async () => {
|
|
390
|
+
const fetcher = mockFetch([
|
|
391
|
+
{
|
|
392
|
+
match: () => true,
|
|
393
|
+
response: {
|
|
394
|
+
ok: false,
|
|
395
|
+
status: 422,
|
|
396
|
+
body: { message: "Repository creation failed: name already exists" },
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
]);
|
|
400
|
+
await expect(
|
|
401
|
+
createRepo("gho_test", { name: "exists" }, fetcher),
|
|
402
|
+
).rejects.toThrow(/already exists/);
|
|
403
|
+
});
|
|
404
|
+
});
|