create-hq 10.10.0 → 10.11.0

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 (59) hide show
  1. package/commands/{team-sync.md → sync-team.md} +167 -6
  2. package/dist/__tests__/auth.test.js +176 -186
  3. package/dist/__tests__/auth.test.js.map +1 -1
  4. package/dist/__tests__/scaffold.test.js +5 -3
  5. package/dist/__tests__/scaffold.test.js.map +1 -1
  6. package/dist/auth.d.ts +52 -131
  7. package/dist/auth.d.ts.map +1 -1
  8. package/dist/auth.js +73 -369
  9. package/dist/auth.js.map +1 -1
  10. package/dist/fetch-template.d.ts +7 -2
  11. package/dist/fetch-template.d.ts.map +1 -1
  12. package/dist/fetch-template.js +27 -15
  13. package/dist/fetch-template.js.map +1 -1
  14. package/dist/index.js +7 -17
  15. package/dist/index.js.map +1 -1
  16. package/dist/packages.d.ts.map +1 -1
  17. package/dist/packages.js +1 -1
  18. package/dist/packages.js.map +1 -1
  19. package/dist/recommended-packages.d.ts +93 -0
  20. package/dist/recommended-packages.d.ts.map +1 -0
  21. package/dist/recommended-packages.js +221 -0
  22. package/dist/recommended-packages.js.map +1 -0
  23. package/dist/scaffold.d.ts +4 -2
  24. package/dist/scaffold.d.ts.map +1 -1
  25. package/dist/scaffold.js +127 -232
  26. package/dist/scaffold.js.map +1 -1
  27. package/dist/ui.d.ts +0 -17
  28. package/dist/ui.d.ts.map +1 -1
  29. package/dist/ui.js +0 -45
  30. package/dist/ui.js.map +1 -1
  31. package/package.json +2 -1
  32. package/dist/admin-onboarding.d.ts +0 -44
  33. package/dist/admin-onboarding.d.ts.map +0 -1
  34. package/dist/admin-onboarding.js +0 -530
  35. package/dist/admin-onboarding.js.map +0 -1
  36. package/dist/company-template.d.ts +0 -34
  37. package/dist/company-template.d.ts.map +0 -1
  38. package/dist/company-template.js +0 -142
  39. package/dist/company-template.js.map +0 -1
  40. package/dist/invite-command.d.ts +0 -10
  41. package/dist/invite-command.d.ts.map +0 -1
  42. package/dist/invite-command.js +0 -110
  43. package/dist/invite-command.js.map +0 -1
  44. package/dist/invite.d.ts +0 -91
  45. package/dist/invite.d.ts.map +0 -1
  46. package/dist/invite.js +0 -230
  47. package/dist/invite.js.map +0 -1
  48. package/dist/join-flow.d.ts +0 -32
  49. package/dist/join-flow.d.ts.map +0 -1
  50. package/dist/join-flow.js +0 -315
  51. package/dist/join-flow.js.map +0 -1
  52. package/dist/team-setup.d.ts +0 -83
  53. package/dist/team-setup.d.ts.map +0 -1
  54. package/dist/team-setup.js +0 -353
  55. package/dist/team-setup.js.map +0 -1
  56. package/dist/teams-flow.d.ts +0 -41
  57. package/dist/teams-flow.d.ts.map +0 -1
  58. package/dist/teams-flow.js +0 -173
  59. package/dist/teams-flow.js.map +0 -1
@@ -2,17 +2,17 @@
2
2
 
3
3
  Pull latest team content and push local changes for all joined teams. Bidirectional git sync using `gh` CLI for authentication — no manual git operations needed.
4
4
 
5
- **Usage:** `/sync` or `/sync --team <slug>` or `/sync --dry-run`
5
+ **Usage:** `/sync` or `/sync <company>` or `/sync --dry-run`
6
6
 
7
7
  **Requires:** `gh` CLI authenticated (`gh auth status`)
8
8
 
9
9
  ## Arguments
10
10
 
11
11
  Parse the user's input for:
12
- - `--team <slug>` — Sync only a specific team by slug (e.g., `--team indigo`)
12
+ - `<company>` — Sync (or promote) a specific company by slug (e.g., `/sync indigo`). Also accepts `--team <slug>` for backwards compatibility.
13
13
  - `--dry-run` — Show what would be synced without making changes
14
14
 
15
- If no flags, sync all discovered teams.
15
+ If no company specified, sync all discovered teams.
16
16
 
17
17
  ## Process
18
18
 
@@ -62,16 +62,24 @@ If no files found:
62
62
  ```
63
63
  No teams found. Join a team first:
64
64
  npx create-hq
65
+
66
+ To promote an existing company folder to a team repo:
67
+ /sync <company-slug>
65
68
  ```
66
69
  Stop here.
67
70
 
68
- If `--team <slug>` was specified, filter to only `companies/{slug}/team.json`. If that file doesn't exist:
71
+ If a company slug was specified (e.g., `/sync acme` or `/sync --team acme`):
72
+
73
+ First check if `companies/{slug}/` exists as a directory. If not:
69
74
  ```
70
- Team "{slug}" not found. Available teams:
71
- {list discovered team slugs}
75
+ Company folder "companies/{slug}/" not found.
72
76
  ```
73
77
  Stop here.
74
78
 
79
+ Then check if `companies/{slug}/team.json` exists:
80
+ - **If yes:** filter to only this team and continue to step 3 (normal sync).
81
+ - **If no:** this company isn't a team yet. Proceed to the **Promote Flow** (section 6 below).
82
+
75
83
  ### 3. Sync each team
76
84
 
77
85
  For each team (or the single `--team` target):
@@ -416,6 +424,159 @@ Dry run complete — no changes were made.
416
424
  Would push: {N} local changes
417
425
  ```
418
426
 
427
+ ## 6. Promote Flow (inline)
428
+
429
+ When `/sync <slug>` targets a company folder that has no `team.json`, run this flow instead of the normal sync. This creates a GitHub team repo from the existing local folder.
430
+
431
+ ```
432
+ companies/{slug}/ isn't shared as a team yet.
433
+ Promote it to a team repo so it can be synced and shared? (Y/n)
434
+ ```
435
+
436
+ If the user says no, stop here. If yes, continue.
437
+
438
+ ### 6a. Authenticate
439
+
440
+ Verify `gh` is authenticated (already done in step 1). Get the authenticated user:
441
+ ```bash
442
+ gh api user --jq '.login' 2>/dev/null
443
+ ```
444
+
445
+ ### 6b. Select GitHub organization
446
+
447
+ List orgs the user is admin of:
448
+ ```bash
449
+ gh api user/orgs --jq '.[].login' 2>/dev/null
450
+ ```
451
+
452
+ If no orgs found:
453
+ ```
454
+ You need a GitHub organization to host the team repo.
455
+ Create one at: https://github.com/organizations/new
456
+ Then re-run /sync.
457
+ ```
458
+ Stop the promote flow (sync results still reported normally).
459
+
460
+ If one org, auto-select. If multiple, present numbered list.
461
+
462
+ ### 6c. Pre-push secrets scan
463
+
464
+ Before creating the repo, scan the folder for accidental secrets:
465
+
466
+ ```bash
467
+ # Scan settings/ directory (highest risk)
468
+ grep -rnE '(api[_-]?key|password|secret|token)\s*[:=]\s*\S{10,}|-----BEGIN .* PRIVATE KEY-----|gh[pousr]_[A-Za-z0-9_]{36,}|op://|AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9]{20,}' companies/{slug}/ --include='*.json' --include='*.yaml' --include='*.yml' --include='*.env' --include='*.md' --include='*.ts' --include='*.js' --include='*.sh' 2>/dev/null || true
469
+ ```
470
+
471
+ **If matches found:**
472
+ ```
473
+ Pre-push security scan found potential secrets in companies/{slug}/:
474
+
475
+ {file}:{line}: {match preview}
476
+
477
+ These would be pushed to a shared GitHub repo (visible to all org members).
478
+
479
+ Options:
480
+ 1. Remove the sensitive data first (recommended)
481
+ 2. Continue anyway (I've verified these are safe to share)
482
+ ```
483
+
484
+ If option 1: abort promote flow, report which files to clean.
485
+ If option 2: continue.
486
+
487
+ **If no matches:** continue silently.
488
+
489
+ ### 6d. Create team repo
490
+
491
+ ```bash
492
+ gh api orgs/{org}/repos -X POST -f name="hq-{slug}" -f private=true -f description="HQ Teams workspace for {slug}" --jq '.html_url' 2>&1
493
+ ```
494
+
495
+ If the repo already exists (422 error), ask to reuse or abort.
496
+
497
+ ### 6e. Initialize and push
498
+
499
+ ```bash
500
+ cd companies/{slug}
501
+
502
+ # Init git if not already
503
+ [ -d .git ] || git init -b main
504
+
505
+ # Check for existing remote
506
+ if git remote get-url origin 2>/dev/null; then
507
+ echo "This folder already has a git remote. Aborting to avoid conflict."
508
+ # Stop promote flow
509
+ fi
510
+
511
+ # Configure and push
512
+ git remote add origin "https://github.com/{org}/hq-{slug}.git"
513
+ git add -A
514
+ git commit -m "Initial team content from HQ promote"
515
+ git push -u origin main
516
+ ```
517
+
518
+ ### 6f. Write team.json
519
+
520
+ Create `companies/{slug}/team.json` with metadata:
521
+ ```json
522
+ {
523
+ "team_id": "{generated UUID}",
524
+ "team_name": "{slug}",
525
+ "team_slug": "{slug}",
526
+ "org_login": "{org}",
527
+ "org_id": {org_id},
528
+ "created_by": "{gh_username}",
529
+ "created_at": "{ISO8601}",
530
+ "hq_version": "promoted",
531
+ "repo_url": "https://github.com/{org}/hq-{slug}",
532
+ "clone_url": "https://github.com/{org}/hq-{slug}.git"
533
+ }
534
+ ```
535
+
536
+ Generate UUID:
537
+ ```bash
538
+ uuidgen 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())"
539
+ ```
540
+
541
+ Get org_id:
542
+ ```bash
543
+ gh api orgs/{org} --jq '.id'
544
+ ```
545
+
546
+ Commit and push team.json:
547
+ ```bash
548
+ git -C companies/{slug} add team.json
549
+ git -C companies/{slug} commit -m "chore: add team.json metadata"
550
+ git -C companies/{slug} push origin main
551
+ ```
552
+
553
+ ### 6g. Post-promote wiring
554
+
555
+ Update `companies/manifest.yaml` to include the team repo reference:
556
+ - If company entry exists, add `hq-{slug}` to its `repos` array
557
+ - If no entry exists, create a minimal entry with the repo
558
+
559
+ Run search reindex:
560
+ ```bash
561
+ qmd update 2>/dev/null || true
562
+ ```
563
+
564
+ ### 6h. Report promote results
565
+
566
+ ```
567
+ Promoted companies/{slug}/ → https://github.com/{org}/hq-{slug}
568
+
569
+ Files pushed: {N}
570
+ team.json: companies/{slug}/team.json
571
+ Manifest: updated
572
+
573
+ Next steps:
574
+ /sync — sync this team going forward
575
+ /invite — invite team members
576
+ ```
577
+
578
+ After promoting, the newly created team is immediately eligible for normal sync in step 3.
579
+
419
580
  ## Security Notes
420
581
 
421
582
  - Git authentication is handled by `gh auth setup-git`, which configures a credential helper that delegates to `gh`. No tokens are stored on disk or passed via environment variables.
@@ -1,201 +1,191 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
2
- import * as path from "path";
3
- import * as os from "os";
4
- import * as fs from "fs";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5
2
  /**
6
- * Unit tests for auth.ts:
7
- * - githubApi error handling (403 on /user/installations)
8
- * - ~/.hq/app-token.json persistence (load / save / clear)
9
- * - Token validation via /user/installations probe
3
+ * Unit tests for auth.ts — Cognito version.
4
+ *
5
+ * Covers:
6
+ * - DEFAULT_COGNITO config (Google-only, port 8765, shared pool with installer + CLI)
7
+ * - readIdentity — decodes ID-token JWT payloads safely
8
+ * - signOut — clears the cached session
9
+ * - ensureCognitoToken — cache / refresh / interactive-login branches
10
+ *
11
+ * The hq-cloud module is mocked so tests never touch the filesystem or network.
10
12
  */
11
- // ─── Mocks ─────────────────────────────────────────────────────────────────
12
- const mockFetch = vi.fn();
13
- vi.stubGlobal("fetch", mockFetch);
14
- // Mock child_process so module-level execSync calls in auth.ts don't run
15
- vi.mock("child_process", () => ({
16
- exec: vi.fn(),
17
- execSync: vi.fn(),
13
+ const mockLoadCachedTokens = vi.fn();
14
+ const mockClearCachedTokens = vi.fn();
15
+ const mockIsExpiring = vi.fn();
16
+ const mockRefreshTokens = vi.fn();
17
+ const mockBrowserLogin = vi.fn();
18
+ vi.mock("@indigoai-us/hq-cloud", () => ({
19
+ loadCachedTokens: mockLoadCachedTokens,
20
+ clearCachedTokens: mockClearCachedTokens,
21
+ isExpiring: mockIsExpiring,
22
+ refreshTokens: mockRefreshTokens,
23
+ browserLogin: mockBrowserLogin,
18
24
  }));
19
- // Import after mocks are in place
20
- const { githubApi, loadGitHubAuth, saveGitHubAuth, clearGitHubAuth, isGitHubAuthValid, isAppScopedToken, HQ_APP_TOKEN_PATH, } = await import("../auth.js");
25
+ const { DEFAULT_COGNITO, ensureCognitoToken, readIdentity, signOut, } = await import("../auth.js");
21
26
  // ─── Fixtures ──────────────────────────────────────────────────────────────
22
- const fakeAuth = {
23
- access_token: "ghu_fake_app_token",
24
- login: "testuser",
25
- id: 12345,
26
- name: "Test User",
27
- email: "test@example.com",
28
- issued_at: new Date().toISOString(),
29
- };
30
- // Use a temp directory so tests don't touch the real ~/.hq/
31
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "create-hq-auth-test-"));
32
- const tmpTokenPath = path.join(tmpDir, "app-token.json");
33
- // ─── githubApi ─────────────────────────────────────────────────────────────
34
- describe("githubApi", () => {
35
- beforeEach(() => mockFetch.mockReset());
36
- it("throws a user-friendly message on 403 for /user/installations", async () => {
37
- mockFetch.mockResolvedValueOnce({
38
- ok: false,
39
- status: 403,
40
- text: async () => JSON.stringify({
41
- message: "You must authenticate with an access token authorized to a GitHub App in order to list installations",
42
- documentation_url: "https://docs.github.com/rest/apps/installations#list-app-installations-accessible-to-the-user-access-token",
43
- status: "403",
44
- }),
45
- });
46
- await expect(githubApi("/user/installations?per_page=100", fakeAuth)).rejects.toThrow(/signed in with a regular GitHub token/i);
47
- });
48
- it("includes the re-run hint in the 403 installations error", async () => {
49
- mockFetch.mockResolvedValueOnce({
50
- ok: false,
51
- status: 403,
52
- text: async () => JSON.stringify({
53
- message: "You must authenticate with an access token authorized to a GitHub App",
54
- }),
55
- });
56
- await expect(githubApi("/user/installations?per_page=100", fakeAuth)).rejects.toThrow(/npx create-hq/);
57
- });
58
- it("preserves the raw error for non-installation 403s", async () => {
59
- mockFetch.mockResolvedValueOnce({
60
- ok: false,
61
- status: 403,
62
- text: async () => JSON.stringify({ message: "Resource not accessible by integration" }),
63
- });
64
- await expect(githubApi("/orgs/acme/repos", fakeAuth)).rejects.toThrow(/GitHub API 403 \/orgs\/acme\/repos/);
27
+ /** Encode a JSON payload as a base64url JWT middle segment. */
28
+ function encodeJwtPayload(payload) {
29
+ const json = JSON.stringify(payload);
30
+ const b64 = Buffer.from(json, "utf-8").toString("base64");
31
+ return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
32
+ }
33
+ function makeTokens(idPayload) {
34
+ return {
35
+ accessToken: "access-" + Math.random().toString(36).slice(2),
36
+ idToken: `header.${encodeJwtPayload(idPayload)}.signature`,
37
+ refreshToken: "refresh-" + Math.random().toString(36).slice(2),
38
+ expiresAt: Date.now() + 60 * 60 * 1000,
39
+ tokenType: "Bearer",
40
+ };
41
+ }
42
+ // ─── DEFAULT_COGNITO ───────────────────────────────────────────────────────
43
+ describe("DEFAULT_COGNITO", () => {
44
+ it("matches hq-installer and hq-cli pool (hq-vault-dev, us-east-1)", () => {
45
+ expect(DEFAULT_COGNITO.region).toBe("us-east-1");
46
+ expect(DEFAULT_COGNITO.userPoolDomain).toBe("hq-vault-dev");
47
+ expect(DEFAULT_COGNITO.clientId).toBe("4mmujmjq3srakdueg656b9m0mp");
48
+ });
49
+ it("forces Google as the identity provider", () => {
50
+ expect(DEFAULT_COGNITO.identityProvider).toBe("Google");
51
+ });
52
+ it("prompts Google to re-select the account", () => {
53
+ expect(DEFAULT_COGNITO.prompt).toBe("select_account");
54
+ });
55
+ it("uses port 8765 to match hq-cli", () => {
56
+ expect(DEFAULT_COGNITO.port).toBe(8765);
65
57
  });
66
- it("throws the raw error for non-403 failures", async () => {
67
- mockFetch.mockResolvedValueOnce({
68
- ok: false,
69
- status: 404,
70
- text: async () => JSON.stringify({ message: "Not Found" }),
58
+ });
59
+ // ─── readIdentity ──────────────────────────────────────────────────────────
60
+ describe("readIdentity", () => {
61
+ it("decodes sub, email, name from the ID token", () => {
62
+ const tokens = makeTokens({
63
+ sub: "abc-123",
64
+ email: "stefan@example.com",
65
+ name: "Stefan Johnson",
71
66
  });
72
- await expect(githubApi("/user/installations?per_page=100", fakeAuth)).rejects.toThrow(/GitHub API 404/);
73
- });
74
- it("returns parsed JSON on success", async () => {
75
- mockFetch.mockResolvedValueOnce({
76
- ok: true,
77
- status: 200,
78
- json: async () => ({ installations: [] }),
67
+ const id = readIdentity(tokens);
68
+ expect(id).not.toBeNull();
69
+ expect(id.sub).toBe("abc-123");
70
+ expect(id.email).toBe("stefan@example.com");
71
+ expect(id.name).toBe("Stefan Johnson");
72
+ });
73
+ it("exposes the full decoded claims bag", () => {
74
+ const tokens = makeTokens({
75
+ sub: "abc",
76
+ email: "x@y.com",
77
+ "custom:org": "indigo",
79
78
  });
80
- const result = await githubApi("/user/installations?per_page=100", fakeAuth);
81
- expect(result).toEqual({ installations: [] });
79
+ const id = readIdentity(tokens);
80
+ expect(id.claims["custom:org"]).toBe("indigo");
81
+ });
82
+ it("returns undefined for missing email/name fields", () => {
83
+ const tokens = makeTokens({ sub: "abc" });
84
+ const id = readIdentity(tokens);
85
+ expect(id.sub).toBe("abc");
86
+ expect(id.email).toBeUndefined();
87
+ expect(id.name).toBeUndefined();
88
+ });
89
+ it("returns null when the ID token is malformed", () => {
90
+ const tokens = {
91
+ accessToken: "x",
92
+ idToken: "not-a-jwt",
93
+ refreshToken: "r",
94
+ expiresAt: Date.now() + 60000,
95
+ tokenType: "Bearer",
96
+ };
97
+ expect(readIdentity(tokens)).toBeNull();
98
+ });
99
+ it("returns null when the payload segment is invalid JSON", () => {
100
+ const tokens = {
101
+ accessToken: "x",
102
+ idToken: "header.!!!notbase64valid!!!.sig",
103
+ refreshToken: "r",
104
+ expiresAt: Date.now() + 60000,
105
+ tokenType: "Bearer",
106
+ };
107
+ expect(readIdentity(tokens)).toBeNull();
82
108
  });
83
109
  });
84
- // ─── ~/.hq/app-token.json persistence ──────────────────────────────────────
85
- describe("App token persistence", () => {
86
- afterEach(() => {
87
- // Clean up temp token file between tests
88
- try {
89
- fs.unlinkSync(tmpTokenPath);
90
- }
91
- catch { }
92
- });
93
- afterEach(() => {
94
- // Clean up the real path if any test accidentally wrote there
95
- // (shouldn't happen — we test with tmpTokenPath)
96
- });
97
- it("HQ_APP_TOKEN_PATH points to ~/.hq/app-token.json", () => {
98
- const expected = path.join(os.homedir(), ".hq", "app-token.json");
99
- expect(HQ_APP_TOKEN_PATH).toBe(expected);
100
- });
101
- it("saveGitHubAuth writes token file to disk", () => {
102
- saveGitHubAuth(fakeAuth, tmpTokenPath);
103
- // File was written
104
- expect(fs.existsSync(tmpTokenPath)).toBe(true);
105
- const stored = JSON.parse(fs.readFileSync(tmpTokenPath, "utf-8"));
106
- expect(stored.login).toBe("testuser");
107
- expect(stored.access_token).toBe("ghu_fake_app_token");
108
- });
109
- it("saveGitHubAuth creates ~/.hq/ directory if missing", () => {
110
- const nested = path.join(tmpDir, "sub", "app-token.json");
111
- saveGitHubAuth(fakeAuth, nested);
112
- expect(fs.existsSync(nested)).toBe(true);
113
- });
114
- it("saveGitHubAuth sets restrictive file permissions (0600)", () => {
115
- saveGitHubAuth(fakeAuth, tmpTokenPath);
116
- const stat = fs.statSync(tmpTokenPath);
117
- // Owner read+write only (0600 = 0o600 = 384 decimal)
118
- expect(stat.mode & 0o777).toBe(0o600);
119
- });
120
- it("loadGitHubAuth reads from token file when present", () => {
121
- // Write a valid token file
122
- fs.writeFileSync(tmpTokenPath, JSON.stringify(fakeAuth), "utf-8");
123
- const loaded = loadGitHubAuth(tmpTokenPath);
124
- expect(loaded).not.toBeNull();
125
- expect(loaded.login).toBe("testuser");
126
- expect(loaded.access_token).toBe("ghu_fake_app_token");
127
- });
128
- it("loadGitHubAuth returns null when token file does not exist", () => {
129
- const loaded = loadGitHubAuth(tmpTokenPath);
130
- expect(loaded).toBeNull();
131
- });
132
- it("loadGitHubAuth returns null for corrupted JSON", () => {
133
- fs.writeFileSync(tmpTokenPath, "NOT VALID JSON{{{", "utf-8");
134
- const loaded = loadGitHubAuth(tmpTokenPath);
135
- expect(loaded).toBeNull();
136
- });
137
- it("loadGitHubAuth returns null when token file is missing access_token", () => {
138
- fs.writeFileSync(tmpTokenPath, JSON.stringify({ login: "x", id: 1 }), "utf-8");
139
- const loaded = loadGitHubAuth(tmpTokenPath);
140
- expect(loaded).toBeNull();
141
- });
142
- it("clearGitHubAuth removes the token file", () => {
143
- fs.writeFileSync(tmpTokenPath, JSON.stringify(fakeAuth), "utf-8");
144
- expect(fs.existsSync(tmpTokenPath)).toBe(true);
145
- clearGitHubAuth(tmpTokenPath);
146
- expect(fs.existsSync(tmpTokenPath)).toBe(false);
147
- });
148
- it("clearGitHubAuth is a no-op when file does not exist", () => {
149
- // Should not throw
150
- clearGitHubAuth(tmpTokenPath);
110
+ // ─── signOut ───────────────────────────────────────────────────────────────
111
+ describe("signOut", () => {
112
+ it("delegates to hq-cloud.clearCachedTokens", () => {
113
+ mockClearCachedTokens.mockReset();
114
+ signOut();
115
+ expect(mockClearCachedTokens).toHaveBeenCalledOnce();
151
116
  });
152
117
  });
153
- // ─── isGitHubAuthValid ─────────────────────────────────────────────────────
154
- describe("isGitHubAuthValid", () => {
155
- beforeEach(() => mockFetch.mockReset());
156
- it("returns true when /user responds 200", async () => {
157
- mockFetch.mockResolvedValueOnce({ ok: true });
158
- expect(await isGitHubAuthValid(fakeAuth)).toBe(true);
159
- });
160
- it("returns false when /user responds non-ok", async () => {
161
- mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
162
- expect(await isGitHubAuthValid(fakeAuth)).toBe(false);
163
- });
164
- it("returns false on network error", async () => {
165
- mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
166
- expect(await isGitHubAuthValid(fakeAuth)).toBe(false);
118
+ // ─── ensureCognitoToken ────────────────────────────────────────────────────
119
+ describe("ensureCognitoToken", () => {
120
+ beforeEach(() => {
121
+ mockLoadCachedTokens.mockReset();
122
+ mockIsExpiring.mockReset();
123
+ mockRefreshTokens.mockReset();
124
+ mockBrowserLogin.mockReset();
167
125
  });
168
- });
169
- // ─── isAppScopedToken ──────────────────────────────────────────────────────
170
- describe("isAppScopedToken", () => {
171
- beforeEach(() => mockFetch.mockReset());
172
- it('returns "yes" when /user/installations responds 200', async () => {
173
- mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
174
- expect(await isAppScopedToken(fakeAuth)).toBe("yes");
175
- });
176
- it('returns "no" on 403 (definitive — wrong token type)', async () => {
177
- mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });
178
- expect(await isAppScopedToken(fakeAuth)).toBe("no");
179
- });
180
- it('returns "unknown" on 5xx (transient server error)', async () => {
181
- mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
182
- expect(await isAppScopedToken(fakeAuth)).toBe("unknown");
183
- });
184
- it('returns "unknown" on network error', async () => {
185
- mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
186
- expect(await isAppScopedToken(fakeAuth)).toBe("unknown");
187
- });
188
- it('returns "no" when access_token is empty', async () => {
189
- expect(await isAppScopedToken({ ...fakeAuth, access_token: "" })).toBe("no");
190
- // fetch should not have been called
191
- expect(mockFetch).not.toHaveBeenCalled();
126
+ afterEach(() => {
127
+ vi.restoreAllMocks();
128
+ });
129
+ it("returns cached tokens when they are not expiring", async () => {
130
+ const cached = makeTokens({ sub: "abc" });
131
+ mockLoadCachedTokens.mockReturnValue(cached);
132
+ mockIsExpiring.mockReturnValue(false);
133
+ const result = await ensureCognitoToken();
134
+ expect(result).toBe(cached);
135
+ expect(mockRefreshTokens).not.toHaveBeenCalled();
136
+ expect(mockBrowserLogin).not.toHaveBeenCalled();
137
+ });
138
+ it("refreshes expiring tokens using the cached refresh token", async () => {
139
+ const cached = makeTokens({ sub: "abc" });
140
+ const refreshed = makeTokens({ sub: "abc" });
141
+ mockLoadCachedTokens.mockReturnValue(cached);
142
+ mockIsExpiring.mockReturnValue(true);
143
+ mockRefreshTokens.mockResolvedValue(refreshed);
144
+ const result = await ensureCognitoToken();
145
+ expect(result).toBe(refreshed);
146
+ expect(mockRefreshTokens).toHaveBeenCalledWith(DEFAULT_COGNITO, cached.refreshToken);
147
+ expect(mockBrowserLogin).not.toHaveBeenCalled();
148
+ });
149
+ it("falls back to browser login when refresh fails", async () => {
150
+ const cached = makeTokens({ sub: "abc" });
151
+ const fresh = makeTokens({ sub: "abc" });
152
+ mockLoadCachedTokens.mockReturnValue(cached);
153
+ mockIsExpiring.mockReturnValue(true);
154
+ mockRefreshTokens.mockRejectedValue(new Error("invalid_grant"));
155
+ mockBrowserLogin.mockResolvedValue(fresh);
156
+ const result = await ensureCognitoToken();
157
+ expect(result).toBe(fresh);
158
+ expect(mockBrowserLogin).toHaveBeenCalledWith(DEFAULT_COGNITO);
159
+ });
160
+ it("launches browser login when no cached tokens exist", async () => {
161
+ const fresh = makeTokens({ sub: "abc" });
162
+ mockLoadCachedTokens.mockReturnValue(null);
163
+ mockBrowserLogin.mockResolvedValue(fresh);
164
+ const result = await ensureCognitoToken();
165
+ expect(result).toBe(fresh);
166
+ expect(mockRefreshTokens).not.toHaveBeenCalled();
167
+ expect(mockBrowserLogin).toHaveBeenCalledWith(DEFAULT_COGNITO);
168
+ });
169
+ it("returns null in non-interactive mode when no cached token", async () => {
170
+ mockLoadCachedTokens.mockReturnValue(null);
171
+ const result = await ensureCognitoToken({ interactive: false });
172
+ expect(result).toBeNull();
173
+ expect(mockBrowserLogin).not.toHaveBeenCalled();
174
+ });
175
+ it("returns null in non-interactive mode when refresh fails", async () => {
176
+ const cached = makeTokens({ sub: "abc" });
177
+ mockLoadCachedTokens.mockReturnValue(cached);
178
+ mockIsExpiring.mockReturnValue(true);
179
+ mockRefreshTokens.mockRejectedValue(new Error("invalid_grant"));
180
+ const result = await ensureCognitoToken({ interactive: false });
181
+ expect(result).toBeNull();
182
+ expect(mockBrowserLogin).not.toHaveBeenCalled();
183
+ });
184
+ it("returns null when browser login throws", async () => {
185
+ mockLoadCachedTokens.mockReturnValue(null);
186
+ mockBrowserLogin.mockRejectedValue(new Error("user closed tab"));
187
+ const result = await ensureCognitoToken();
188
+ expect(result).toBeNull();
192
189
  });
193
190
  });
194
- // ─── Cleanup ───────────────────────────────────────────────────────────────
195
- afterAll(() => {
196
- try {
197
- fs.rmSync(tmpDir, { recursive: true, force: true });
198
- }
199
- catch { }
200
- });
201
191
  //# sourceMappingURL=auth.test.js.map