@openparachute/vault 0.6.0-rc.1 → 0.6.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.
Files changed (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tests for the shared git-availability preflight (vault#415).
3
+ *
4
+ * Found live: importing a repo on a git-less Amazon Linux EC2 box failed
5
+ * with a raw `Executable not found in $PATH: "git"` 500. The preflight gives
6
+ * every git entry point a fast, friendly, actionable failure instead.
7
+ */
8
+
9
+ import { describe, test, expect } from "bun:test";
10
+ import {
11
+ GitNotInstalledError,
12
+ ensureGitAvailable,
13
+ isGitNotFoundSpawnError,
14
+ } from "./git-preflight.ts";
15
+
16
+ describe("ensureGitAvailable", () => {
17
+ test("throws GitNotInstalledError when which returns null", () => {
18
+ expect(() => ensureGitAvailable(() => null)).toThrow(GitNotInstalledError);
19
+ });
20
+
21
+ test("does not throw when which resolves git", () => {
22
+ expect(() => ensureGitAvailable(() => "/usr/bin/git")).not.toThrow();
23
+ });
24
+
25
+ test("defaults to Bun.which (git is present in this test env)", () => {
26
+ // The test host has git; the default-arg path resolves it cleanly.
27
+ expect(() => ensureGitAvailable()).not.toThrow();
28
+ });
29
+ });
30
+
31
+ describe("GitNotInstalledError message", () => {
32
+ test("is OS-agnostic-but-helpful — names dnf, apt-get, and brew", () => {
33
+ const msg = new GitNotInstalledError().message;
34
+ expect(msg).toContain("git is required for this operation");
35
+ expect(msg).toContain("sudo dnf install git");
36
+ expect(msg).toContain("sudo apt-get install -y git");
37
+ expect(msg).toContain("brew install git");
38
+ });
39
+
40
+ test("carries the GitNotInstalledError name (instanceof + name both work)", () => {
41
+ const err = new GitNotInstalledError();
42
+ expect(err).toBeInstanceOf(GitNotInstalledError);
43
+ expect(err.name).toBe("GitNotInstalledError");
44
+ });
45
+ });
46
+
47
+ describe("isGitNotFoundSpawnError", () => {
48
+ test("matches Bun's executable-not-found message for git", () => {
49
+ expect(
50
+ isGitNotFoundSpawnError(
51
+ new Error('Executable not found in $PATH: "git"'),
52
+ ),
53
+ ).toBe(true);
54
+ });
55
+
56
+ test("matches an ENOENT spawn error mentioning git", () => {
57
+ const err = new Error("spawn git ENOENT") as Error & { code?: string };
58
+ err.code = "ENOENT";
59
+ expect(isGitNotFoundSpawnError(err)).toBe(true);
60
+ });
61
+
62
+ test("does not match an unrelated error", () => {
63
+ expect(isGitNotFoundSpawnError(new Error("network unreachable"))).toBe(false);
64
+ });
65
+
66
+ test("does not match a non-Error value", () => {
67
+ expect(isGitNotFoundSpawnError("git missing")).toBe(false);
68
+ expect(isGitNotFoundSpawnError(null)).toBe(false);
69
+ });
70
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Shared git-availability preflight.
3
+ *
4
+ * Every git-using entry point in vault (mirror import, mirror sync/commit/
5
+ * push, internal-mirror bootstrap) shells out to the `git` binary via
6
+ * `Bun.spawn(["git", ...])`. On a server where `git` isn't installed (a
7
+ * fresh Amazon Linux / minimal Docker image, etc.) Bun throws a raw
8
+ * `Executable not found in $PATH: "git"` error, which the import route only
9
+ * caught in its generic `internal` 500 branch — surfacing an unhelpful,
10
+ * un-actionable error to the operator.
11
+ *
12
+ * This module centralizes the preflight so every git entry point fails
13
+ * fast with a clear, actionable message that tells the operator HOW to
14
+ * fix it (install git via their distro's package manager).
15
+ *
16
+ * Found live on the gitcoin-parachute EC2 deploy (Amazon Linux, no git).
17
+ * See vault#415-era fix.
18
+ */
19
+
20
+ /**
21
+ * Thrown when `git` is required for an operation but isn't on PATH. Carries
22
+ * an OS-agnostic-but-helpful message with the common install commands so the
23
+ * operator can act without leaving the error surface.
24
+ */
25
+ export class GitNotInstalledError extends Error {
26
+ constructor() {
27
+ super(
28
+ "git is required for this operation but was not found on the server. " +
29
+ "Install git and retry — e.g. `sudo dnf install git` (Amazon Linux / Fedora), " +
30
+ "`sudo apt-get install -y git` (Debian / Ubuntu), or `brew install git` (macOS).",
31
+ );
32
+ this.name = "GitNotInstalledError";
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Throw `GitNotInstalledError` if `git` isn't resolvable on PATH.
38
+ *
39
+ * `which` is a TEST SEAM (default `Bun.which`) so tests can force the
40
+ * git-missing branch without uninstalling git from the test host. Production
41
+ * callers pass nothing and get the real `Bun.which`.
42
+ */
43
+ export function ensureGitAvailable(
44
+ which: (cmd: string) => string | null = Bun.which,
45
+ ): void {
46
+ if (which("git") === null) {
47
+ throw new GitNotInstalledError();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Heuristic: does this error look like the "git executable not found" failure
53
+ * Bun throws when it can't resolve the binary? Used as a belt-and-suspenders
54
+ * catch around `Bun.spawn(["git", ...])` so a spawn that slips past the
55
+ * preflight (race where git is removed between check and spawn, or a code path
56
+ * that didn't preflight) still surfaces the friendly error instead of the raw
57
+ * `Executable not found in $PATH: "git"` string.
58
+ */
59
+ export function isGitNotFoundSpawnError(err: unknown): boolean {
60
+ if (!(err instanceof Error)) return false;
61
+ const msg = err.message ?? "";
62
+ // Bun: `Executable not found in $PATH: "git"`.
63
+ // Node/posix: ENOENT spawn errors mention the missing file.
64
+ return (
65
+ (msg.includes("Executable not found") && msg.includes("git")) ||
66
+ ((err as { code?: string }).code === "ENOENT" && msg.includes("git"))
67
+ );
68
+ }
@@ -7,7 +7,10 @@
7
7
  * - pollForToken: granted / pending / slow_down / expired / denied
8
8
  * - fetchUser happy path
9
9
  * - listRepos: single-page, paginated, truncated
10
- * - createRepo happy path
10
+ * - listInstallations: happy (user + org), empty, bad shape, API error
11
+ * - listInstallationRepos: happy, paginated/truncated, bad shape
12
+ * - createRepo happy path + GitHubApiError status (403 = no Administration)
13
+ * - app slug helpers + install URL
11
14
  */
12
15
 
13
16
  import { describe, expect, test } from "bun:test";
@@ -15,9 +18,15 @@ import { describe, expect, test } from "bun:test";
15
18
  import {
16
19
  createRepo,
17
20
  fetchUser,
18
- GITHUB_CLIENT_ID_PLACEHOLDER,
21
+ GITHUB_APP_SLUG_DEFAULT,
22
+ GITHUB_CLIENT_ID_DEFAULT,
23
+ getGithubAppSlug,
19
24
  getGithubClientId,
25
+ GitHubApiError,
26
+ installUrlForSlug,
20
27
  isPlaceholderClientId,
28
+ listInstallationRepos,
29
+ listInstallations,
21
30
  listRepos,
22
31
  pollForToken,
23
32
  requestDeviceCode,
@@ -70,23 +79,68 @@ describe("client id helpers", () => {
70
79
  }
71
80
  });
72
81
 
73
- test("getGithubClientId falls back to placeholder when env unset", () => {
82
+ test("getGithubClientId falls back to the shared-app default when env unset", () => {
74
83
  const prev = process.env.PARACHUTE_GITHUB_CLIENT_ID;
75
84
  delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
76
85
  try {
77
- expect(getGithubClientId()).toBe(GITHUB_CLIENT_ID_PLACEHOLDER);
86
+ expect(getGithubClientId()).toBe(GITHUB_CLIENT_ID_DEFAULT);
78
87
  } finally {
79
88
  if (prev !== undefined) process.env.PARACHUTE_GITHUB_CLIENT_ID = prev;
80
89
  }
81
90
  });
82
91
 
83
- test("isPlaceholderClientId catches the literal + the substring", () => {
84
- expect(isPlaceholderClientId(GITHUB_CLIENT_ID_PLACEHOLDER)).toBe(true);
92
+ test("the shipped default is the registered Parachute GitHub App id", () => {
93
+ // Pin the exact value — it's public by design, and a typo'd constant
94
+ // would otherwise still pass a shape check.
95
+ expect(GITHUB_CLIENT_ID_DEFAULT).toBe("Iv23livaRF4VcvPhu3uB");
96
+ expect(isPlaceholderClientId(GITHUB_CLIENT_ID_DEFAULT)).toBe(false);
97
+ });
98
+
99
+ test("isPlaceholderClientId catches placeholder-shaped env overrides", () => {
100
+ expect(isPlaceholderClientId("Iv1.PLACEHOLDER_REPLACE_ME_BEFORE_RELEASE")).toBe(true);
85
101
  expect(isPlaceholderClientId("PLACEHOLDER_X")).toBe(true);
86
102
  expect(isPlaceholderClientId("Iv1.realone")).toBe(false);
87
103
  });
88
104
  });
89
105
 
106
+ // ---------------------------------------------------------------------------
107
+ // App slug helpers
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe("app slug helpers", () => {
111
+ test("getGithubAppSlug reads from env when set (BYO-app pairing)", () => {
112
+ const prev = process.env.PARACHUTE_GITHUB_APP_SLUG;
113
+ try {
114
+ process.env.PARACHUTE_GITHUB_APP_SLUG = "my-own-app";
115
+ expect(getGithubAppSlug()).toBe("my-own-app");
116
+ } finally {
117
+ if (prev === undefined) delete process.env.PARACHUTE_GITHUB_APP_SLUG;
118
+ else process.env.PARACHUTE_GITHUB_APP_SLUG = prev;
119
+ }
120
+ });
121
+
122
+ test("getGithubAppSlug falls back to the shared-app default when env unset", () => {
123
+ const prev = process.env.PARACHUTE_GITHUB_APP_SLUG;
124
+ delete process.env.PARACHUTE_GITHUB_APP_SLUG;
125
+ try {
126
+ expect(getGithubAppSlug()).toBe(GITHUB_APP_SLUG_DEFAULT);
127
+ } finally {
128
+ if (prev !== undefined) process.env.PARACHUTE_GITHUB_APP_SLUG = prev;
129
+ }
130
+ });
131
+
132
+ test("the shipped default slug is the registered Parachute GitHub App's", () => {
133
+ // Pin the exact value — a typo'd slug would build a 404 install link.
134
+ expect(GITHUB_APP_SLUG_DEFAULT).toBe("parachute-computer");
135
+ });
136
+
137
+ test("installUrlForSlug builds the installations/new URL", () => {
138
+ expect(installUrlForSlug("parachute-computer")).toBe(
139
+ "https://github.com/apps/parachute-computer/installations/new",
140
+ );
141
+ });
142
+ });
143
+
90
144
  // ---------------------------------------------------------------------------
91
145
  // requestDeviceCode
92
146
  // ---------------------------------------------------------------------------
@@ -351,6 +405,187 @@ describe("listRepos", () => {
351
405
  });
352
406
  });
353
407
 
408
+ // ---------------------------------------------------------------------------
409
+ // listInstallations
410
+ // ---------------------------------------------------------------------------
411
+
412
+ describe("listInstallations", () => {
413
+ test("returns user + org installations with typed fields", async () => {
414
+ const fetcher = mockFetch([
415
+ {
416
+ match: (u) => u.includes("/user/installations"),
417
+ response: {
418
+ ok: true,
419
+ status: 200,
420
+ body: {
421
+ total_count: 2,
422
+ installations: [
423
+ {
424
+ id: 101,
425
+ app_slug: "parachute-computer",
426
+ account: { login: "aaron", type: "User" },
427
+ repository_selection: "selected",
428
+ },
429
+ {
430
+ id: 202,
431
+ app_slug: "parachute-computer",
432
+ account: { login: "unforced-org", type: "Organization" },
433
+ repository_selection: "all",
434
+ },
435
+ ],
436
+ },
437
+ },
438
+ },
439
+ ]);
440
+ const installations = await listInstallations("ghu_test", fetcher);
441
+ expect(installations).toHaveLength(2);
442
+ expect(installations[0]!.id).toBe(101);
443
+ expect(installations[0]!.account.login).toBe("aaron");
444
+ expect(installations[0]!.account.type).toBe("User");
445
+ expect(installations[0]!.repository_selection).toBe("selected");
446
+ expect(installations[1]!.account.type).toBe("Organization");
447
+ expect(installations[1]!.app_slug).toBe("parachute-computer");
448
+ });
449
+
450
+ test("empty installations array = authorized but not installed", async () => {
451
+ const fetcher = mockFetch([
452
+ {
453
+ match: () => true,
454
+ response: { ok: true, status: 200, body: { total_count: 0, installations: [] } },
455
+ },
456
+ ]);
457
+ const installations = await listInstallations("ghu_test", fetcher);
458
+ expect(installations).toEqual([]);
459
+ });
460
+
461
+ test("throws on a response missing the installations array", async () => {
462
+ const fetcher = mockFetch([
463
+ {
464
+ match: () => true,
465
+ response: { ok: true, status: 200, body: { total_count: 0 } },
466
+ },
467
+ ]);
468
+ await expect(listInstallations("ghu_test", fetcher)).rejects.toThrow(
469
+ /missing installations array/,
470
+ );
471
+ });
472
+
473
+ test("throws on an item missing required fields", async () => {
474
+ const fetcher = mockFetch([
475
+ {
476
+ match: () => true,
477
+ response: {
478
+ ok: true,
479
+ status: 200,
480
+ body: { total_count: 1, installations: [{ id: "not-a-number", account: null }] },
481
+ },
482
+ },
483
+ ]);
484
+ await expect(listInstallations("ghu_test", fetcher)).rejects.toThrow(
485
+ /missing required fields/,
486
+ );
487
+ });
488
+
489
+ test("throws GitHubApiError carrying the status on non-2xx", async () => {
490
+ const fetcher = mockFetch([
491
+ {
492
+ match: () => true,
493
+ response: { ok: false, status: 401, body: { message: "Bad credentials" } },
494
+ },
495
+ ]);
496
+ try {
497
+ await listInstallations("ghu_revoked", fetcher);
498
+ throw new Error("expected listInstallations to throw");
499
+ } catch (err) {
500
+ expect(err).toBeInstanceOf(GitHubApiError);
501
+ expect((err as GitHubApiError).status).toBe(401);
502
+ }
503
+ });
504
+ });
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // listInstallationRepos
508
+ // ---------------------------------------------------------------------------
509
+
510
+ function installationRepoItem(i: number, owner = "aaron"): Record<string, unknown> {
511
+ return {
512
+ name: `repo${i}`,
513
+ full_name: `${owner}/repo${i}`,
514
+ private: true,
515
+ html_url: `https://github.com/${owner}/repo${i}`,
516
+ description: null,
517
+ updated_at: "2026-06-10T00:00:00Z",
518
+ clone_url: `https://github.com/${owner}/repo${i}.git`,
519
+ owner: { login: owner },
520
+ };
521
+ }
522
+
523
+ describe("listInstallationRepos", () => {
524
+ test("single page returns GitHubRepoInfo shapes from the installation", async () => {
525
+ const fetcher = mockFetch([
526
+ {
527
+ match: (u) => u.includes("/user/installations/101/repositories") && u.includes("page=1"),
528
+ response: {
529
+ ok: true,
530
+ status: 200,
531
+ body: { total_count: 1, repositories: [installationRepoItem(0)] },
532
+ },
533
+ },
534
+ ]);
535
+ const result = await listInstallationRepos("ghu_test", 101, { perPage: 100, maxPages: 3 }, fetcher);
536
+ expect(result.repos).toHaveLength(1);
537
+ expect(result.truncated).toBe(false);
538
+ expect(result.repos[0]!.full_name).toBe("aaron/repo0");
539
+ expect(result.repos[0]!.owner).toBe("aaron");
540
+ expect(result.repos[0]!.private).toBe(true);
541
+ });
542
+
543
+ test("paginates until a short page; truncates at the maxPages cap", async () => {
544
+ const fullPage = { total_count: 4, repositories: [installationRepoItem(0), installationRepoItem(1)] };
545
+ const fetcher = mockFetch([
546
+ { match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
547
+ { match: (u) => u.includes("page=2"), response: { ok: true, status: 200, body: fullPage } },
548
+ ]);
549
+ const result = await listInstallationRepos("ghu_test", 7, { perPage: 2, maxPages: 2 }, fetcher);
550
+ expect(result.repos).toHaveLength(4);
551
+ expect(result.truncated).toBe(true);
552
+
553
+ // Short-page end: page 2 has fewer than perPage → untruncated.
554
+ const fetcher2 = mockFetch([
555
+ { match: (u) => u.includes("page=1"), response: { ok: true, status: 200, body: fullPage } },
556
+ {
557
+ match: (u) => u.includes("page=2"),
558
+ response: { ok: true, status: 200, body: { total_count: 3, repositories: [installationRepoItem(2)] } },
559
+ },
560
+ ]);
561
+ const result2 = await listInstallationRepos("ghu_test", 7, { perPage: 2, maxPages: 3 }, fetcher2);
562
+ expect(result2.repos).toHaveLength(3);
563
+ expect(result2.truncated).toBe(false);
564
+ });
565
+
566
+ test("throws on a response missing the repositories array", async () => {
567
+ const fetcher = mockFetch([
568
+ { match: () => true, response: { ok: true, status: 200, body: { total_count: 0 } } },
569
+ ]);
570
+ await expect(
571
+ listInstallationRepos("ghu_test", 101, {}, fetcher),
572
+ ).rejects.toThrow(/missing repositories array/);
573
+ });
574
+
575
+ test("throws GitHubApiError carrying the status on non-2xx", async () => {
576
+ const fetcher = mockFetch([
577
+ { match: () => true, response: { ok: false, status: 404, body: { message: "Not Found" } } },
578
+ ]);
579
+ try {
580
+ await listInstallationRepos("ghu_test", 999, {}, fetcher);
581
+ throw new Error("expected listInstallationRepos to throw");
582
+ } catch (err) {
583
+ expect(err).toBeInstanceOf(GitHubApiError);
584
+ expect((err as GitHubApiError).status).toBe(404);
585
+ }
586
+ });
587
+ });
588
+
354
589
  // ---------------------------------------------------------------------------
355
590
  // createRepo
356
591
  // ---------------------------------------------------------------------------
@@ -401,4 +636,28 @@ describe("createRepo", () => {
401
636
  createRepo("gho_test", { name: "exists" }, fetcher),
402
637
  ).rejects.toThrow(/already exists/);
403
638
  });
639
+
640
+ test("403 (shared Contents-only app) throws GitHubApiError with status 403", async () => {
641
+ // POST /user/repos needs Administration:write — the shared app's
642
+ // expected failure. The route maps this status to the guided-manual
643
+ // error, so the status must survive the throw.
644
+ const fetcher = mockFetch([
645
+ {
646
+ match: () => true,
647
+ response: {
648
+ ok: false,
649
+ status: 403,
650
+ body: { message: "Resource not accessible by integration" },
651
+ },
652
+ },
653
+ ]);
654
+ try {
655
+ await createRepo("ghu_test", { name: "my-vault" }, fetcher);
656
+ throw new Error("expected createRepo to throw");
657
+ } catch (err) {
658
+ expect(err).toBeInstanceOf(GitHubApiError);
659
+ expect((err as GitHubApiError).status).toBe(403);
660
+ expect((err as Error).message).toContain("Resource not accessible");
661
+ }
662
+ });
404
663
  });