create-hq 10.9.1 → 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.
- package/commands/{team-sync.md → sync-team.md} +167 -6
- package/dist/__tests__/auth.test.js +176 -186
- package/dist/__tests__/auth.test.js.map +1 -1
- package/dist/__tests__/scaffold.test.js +5 -3
- package/dist/__tests__/scaffold.test.js.map +1 -1
- package/dist/auth.d.ts +52 -131
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +73 -369
- package/dist/auth.js.map +1 -1
- package/dist/fetch-template.d.ts +7 -2
- package/dist/fetch-template.d.ts.map +1 -1
- package/dist/fetch-template.js +27 -15
- package/dist/fetch-template.js.map +1 -1
- package/dist/index.js +7 -17
- package/dist/index.js.map +1 -1
- package/dist/packages.d.ts.map +1 -1
- package/dist/packages.js +1 -1
- package/dist/packages.js.map +1 -1
- package/dist/recommended-packages.d.ts +93 -0
- package/dist/recommended-packages.d.ts.map +1 -0
- package/dist/recommended-packages.js +221 -0
- package/dist/recommended-packages.js.map +1 -0
- package/dist/scaffold.d.ts +4 -2
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +139 -98
- package/dist/scaffold.js.map +1 -1
- package/dist/ui.d.ts +0 -17
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +0 -45
- package/dist/ui.js.map +1 -1
- package/package.json +2 -1
- package/dist/admin-onboarding.d.ts +0 -44
- package/dist/admin-onboarding.d.ts.map +0 -1
- package/dist/admin-onboarding.js +0 -530
- package/dist/admin-onboarding.js.map +0 -1
- package/dist/company-template.d.ts +0 -34
- package/dist/company-template.d.ts.map +0 -1
- package/dist/company-template.js +0 -142
- package/dist/company-template.js.map +0 -1
- package/dist/invite-command.d.ts +0 -10
- package/dist/invite-command.d.ts.map +0 -1
- package/dist/invite-command.js +0 -110
- package/dist/invite-command.js.map +0 -1
- package/dist/invite.d.ts +0 -91
- package/dist/invite.d.ts.map +0 -1
- package/dist/invite.js +0 -230
- package/dist/invite.js.map +0 -1
- package/dist/join-flow.d.ts +0 -32
- package/dist/join-flow.d.ts.map +0 -1
- package/dist/join-flow.js +0 -205
- package/dist/join-flow.js.map +0 -1
- package/dist/team-setup.d.ts +0 -83
- package/dist/team-setup.d.ts.map +0 -1
- package/dist/team-setup.js +0 -353
- package/dist/team-setup.js.map +0 -1
- package/dist/teams-flow.d.ts +0 -41
- package/dist/teams-flow.d.ts.map +0 -1
- package/dist/teams-flow.js +0 -173
- 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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
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
|
-
|
|
12
|
-
const
|
|
13
|
-
vi.
|
|
14
|
-
|
|
15
|
-
vi.
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
81
|
-
expect(
|
|
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
|
-
// ───
|
|
85
|
-
describe("
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
// ───
|
|
154
|
-
describe("
|
|
155
|
-
beforeEach(() =>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
expect(
|
|
179
|
-
});
|
|
180
|
-
it(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
expect(
|
|
190
|
-
|
|
191
|
-
|
|
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
|