create-hq 10.8.0 → 10.9.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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=auth.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/auth.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,201 @@
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";
5
+ /**
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
10
+ */
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(),
18
+ }));
19
+ // Import after mocks are in place
20
+ const { githubApi, loadGitHubAuth, saveGitHubAuth, clearGitHubAuth, isGitHubAuthValid, isAppScopedToken, HQ_APP_TOKEN_PATH, } = await import("../auth.js");
21
+ // ─── 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/);
65
+ });
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" }),
71
+ });
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: [] }),
79
+ });
80
+ const result = await githubApi("/user/installations?per_page=100", fakeAuth);
81
+ expect(result).toEqual({ installations: [] });
82
+ });
83
+ });
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);
151
+ });
152
+ });
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);
167
+ });
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();
192
+ });
193
+ });
194
+ // ─── Cleanup ───────────────────────────────────────────────────────────────
195
+ afterAll(() => {
196
+ try {
197
+ fs.rmSync(tmpDir, { recursive: true, force: true });
198
+ }
199
+ catch { }
200
+ });
201
+ //# sourceMappingURL=auth.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.test.js","sourceRoot":"","sources":["../../src/__tests__/auth.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AACnF,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAC7B,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AAEzB;;;;;GAKG;AAEH,8EAA8E;AAE9E,MAAM,SAAS,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC;AAC1B,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;AAElC,yEAAyE;AACzE,EAAE,CAAC,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,CAAC,CAAC;IAC9B,IAAI,EAAE,EAAE,CAAC,EAAE,EAAE;IACb,QAAQ,EAAE,EAAE,CAAC,EAAE,EAAE;CAClB,CAAC,CAAC,CAAC;AAEJ,kCAAkC;AAClC,MAAM,EACJ,SAAS,EACT,cAAc,EACd,cAAc,EACd,eAAe,EACf,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,GAClB,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,CAAC;AAE/B,8EAA8E;AAE9E,MAAM,QAAQ,GAAG;IACf,YAAY,EAAE,oBAAoB;IAClC,KAAK,EAAE,UAAU;IACjB,EAAE,EAAE,KAAK;IACT,IAAI,EAAE,WAAW;IACjB,KAAK,EAAE,kBAAkB;IACzB,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;CACpC,CAAC;AAEF,4DAA4D;AAC5D,MAAM,MAAM,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE,sBAAsB,CAAC,CAAC,CAAC;AAC9E,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;AAEzD,8EAA8E;AAE9E,QAAQ,CAAC,WAAW,EAAE,GAAG,EAAE;IACzB,UAAU,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,SAAS,EAAE,CAAC,CAAC;IAExC,EAAE,CAAC,+DAA+D,EAAE,KAAK,IAAI,EAAE;QAC7E,SAAS,CAAC,qBAAqB,CAAC;YAC9B,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,GAAG;YACX,IAAI,EAAE,KAAK,IAAI,EAAE,CACf,IAAI,CAAC,SAAS,CAAC;gBACb,OAAO,EACL,sGAAsG;gBACxG,iBAAiB,EACf,4GAA4G;gBAC9G,MAAM,EAAE,KAAK;aACd,CAAC;SACL,CAAC,CAAC;QAEH,MAAM,MAAM,CACV,SAAS,CAAC,kCAAkC,EAAE,QAAQ,CAAC,CACxD,CAAC,OAAO,CAAC,OAAO,CAAC,wCAAwC,CAAC,CAAC;IAC9D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yDAAyD,EAAE,KAAK,IAAI,EAAE;QACvE,SAAS,CAAC,qBAAqB,CAAC;YAC9B,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,GAAG;YACX,IAAI,EAAE,KAAK,IAAI,EAAE,CACf,IAAI,CAAC,SAAS,CAAC;gBACb,OAAO,EACL,uEAAuE;aAC1E,CAAC;SACL,CAAC,CAAC;QAEH,MAAM,MAAM,CACV,SAAS,CAAC,kCAAkC,EAAE,QAAQ,CAAC,CACxD,CAAC,OAAO,CAAC,OAAO,CAAC,eAAe,CAAC,CAAC;IACrC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mDAAmD,EAAE,KAAK,IAAI,EAAE;QACjE,SAAS,CAAC,qBAAqB,CAAC;YAC9B,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,GAAG;YACX,IAAI,EAAE,KAAK,IAAI,EAAE,CACf,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,wCAAwC,EAAE,CAAC;SACxE,CAAC,CAAC;QAEH,MAAM,MAAM,CACV,SAAS,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CACxC,CAAC,OAAO,CAAC,OAAO,CAAC,oCAAoC,CAAC,CAAC;IAC1D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2CAA2C,EAAE,KAAK,IAAI,EAAE;QACzD,SAAS,CAAC,qBAAqB,CAAC;YAC9B,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,GAAG;YACX,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC;SAC3D,CAAC,CAAC;QAEH,MAAM,MAAM,CACV,SAAS,CAAC,kCAAkC,EAAE,QAAQ,CAAC,CACxD,CAAC,OAAO,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gCAAgC,EAAE,KAAK,IAAI,EAAE;QAC9C,SAAS,CAAC,qBAAqB,CAAC;YAC9B,EAAE,EAAE,IAAI;YACR,MAAM,EAAE,GAAG;YACX,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,EAAE,aAAa,EAAE,EAAE,EAAE,CAAC;SAC1C,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,MAAM,SAAS,CAC5B,kCAAkC,EAClC,QAAQ,CACT,CAAC;QACF,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,EAAE,aAAa,EAAE,EAAE,EAAE,CAAC,CAAC;IAChD,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,8EAA8E;AAE9E,QAAQ,CAAC,uBAAuB,EAAE,GAAG,EAAE;IACrC,SAAS,CAAC,GAAG,EAAE;QACb,yCAAyC;QACzC,IAAI,CAAC;YAAC,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC,CAAC;QAAC,CAAC;QAAC,MAAM,CAAC,CAAA,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,SAAS,CAAC,GAAG,EAAE;QACb,8DAA8D;QAC9D,iDAAiD;IACnD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,gBAAgB,CAAC,CAAC;QAClE,MAAM,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QAClD,cAAc,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC;QAEvC,mBAAmB;QACnB,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC/C,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC,CAAC;QAClE,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACtC,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC;IACzD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,gBAAgB,CAAC,CAAC;QAC1D,cAAc,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QACjC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE,cAAc,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC;QACvC,MAAM,IAAI,GAAG,EAAE,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;QACvC,qDAAqD;QACrD,MAAM,CAAC,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACxC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mDAAmD,EAAE,GAAG,EAAE;QAC3D,2BAA2B;QAC3B,EAAE,CAAC,aAAa,CAAC,YAAY,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC;QAElE,MAAM,MAAM,GAAG,cAAc,CAAC,YAAY,CAAC,CAAC;QAC5C,MAAM,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC;QAC9B,MAAM,CAAC,MAAO,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACvC,MAAM,CAAC,MAAO,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC;IAC1D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,4DAA4D,EAAE,GAAG,EAAE;QACpE,MAAM,MAAM,GAAG,cAAc,CAAC,YAAY,CAAC,CAAC;QAC5C,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC5B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACxD,EAAE,CAAC,aAAa,CAAC,YAAY,EAAE,mBAAmB,EAAE,OAAO,CAAC,CAAC;QAC7D,MAAM,MAAM,GAAG,cAAc,CAAC,YAAY,CAAC,CAAC;QAC5C,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC5B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qEAAqE,EAAE,GAAG,EAAE;QAC7E,EAAE,CAAC,aAAa,CACd,YAAY,EACZ,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,EACrC,OAAO,CACR,CAAC;QACF,MAAM,MAAM,GAAG,cAAc,CAAC,YAAY,CAAC,CAAC;QAC5C,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC5B,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wCAAwC,EAAE,GAAG,EAAE;QAChD,EAAE,CAAC,aAAa,CAAC,YAAY,EAAE,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC;QAClE,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAE/C,eAAe,CAAC,YAAY,CAAC,CAAC;QAC9B,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAClD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC7D,mBAAmB;QACnB,eAAe,CAAC,YAAY,CAAC,CAAC;IAChC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,8EAA8E;AAE9E,QAAQ,CAAC,mBAAmB,EAAE,GAAG,EAAE;IACjC,UAAU,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,SAAS,EAAE,CAAC,CAAC;IAExC,EAAE,CAAC,sCAAsC,EAAE,KAAK,IAAI,EAAE;QACpD,SAAS,CAAC,qBAAqB,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9C,MAAM,CAAC,MAAM,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACvD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,KAAK,IAAI,EAAE;QACxD,SAAS,CAAC,qBAAqB,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QAC5D,MAAM,CAAC,MAAM,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACxD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gCAAgC,EAAE,KAAK,IAAI,EAAE;QAC9C,SAAS,CAAC,qBAAqB,CAAC,IAAI,KAAK,CAAC,cAAc,CAAC,CAAC,CAAC;QAC3D,MAAM,CAAC,MAAM,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACxD,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,8EAA8E;AAE9E,QAAQ,CAAC,kBAAkB,EAAE,GAAG,EAAE;IAChC,UAAU,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,SAAS,EAAE,CAAC,CAAC;IAExC,EAAE,CAAC,qDAAqD,EAAE,KAAK,IAAI,EAAE;QACnE,SAAS,CAAC,qBAAqB,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QAC3D,MAAM,CAAC,MAAM,gBAAgB,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACvD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qDAAqD,EAAE,KAAK,IAAI,EAAE;QACnE,SAAS,CAAC,qBAAqB,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QAC5D,MAAM,CAAC,MAAM,gBAAgB,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACtD,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mDAAmD,EAAE,KAAK,IAAI,EAAE;QACjE,SAAS,CAAC,qBAAqB,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;QAC5D,MAAM,CAAC,MAAM,gBAAgB,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAC3D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,oCAAoC,EAAE,KAAK,IAAI,EAAE;QAClD,SAAS,CAAC,qBAAqB,CAAC,IAAI,KAAK,CAAC,cAAc,CAAC,CAAC,CAAC;QAC3D,MAAM,CAAC,MAAM,gBAAgB,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAC3D,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,yCAAyC,EAAE,KAAK,IAAI,EAAE;QACvD,MAAM,CAAC,MAAM,gBAAgB,CAAC,EAAE,GAAG,QAAQ,EAAE,YAAY,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7E,oCAAoC;QACpC,MAAM,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,gBAAgB,EAAE,CAAC;IAC3C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,8EAA8E;AAE9E,QAAQ,CAAC,GAAG,EAAE;IACZ,IAAI,CAAC;QAAC,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC,CAAA,CAAC;AACvE,CAAC,CAAC,CAAC"}
package/dist/auth.d.ts CHANGED
@@ -7,17 +7,21 @@
7
7
  * 1. POST github.com/login/device/code with our App's client_id
8
8
  * 2. Display user_code, open verification_uri in browser
9
9
  * 3. Poll github.com/login/oauth/access_token at the GitHub-specified interval
10
- * 4. On success: GET api.github.com/user → configure gh CLI with the token
10
+ * 4. On success: GET api.github.com/user → save token to ~/.hq/app-token.json
11
11
  *
12
- * After authentication, the token is handed to `gh auth login --with-token`
13
- * so that subsequent sessions can use `gh` for all GitHub operations.
14
- * No credentials are persisted to disk by create-hq itself.
12
+ * The HQ App token is stored in ~/.hq/app-token.json (mode 0600) and is
13
+ * completely independent of the user's `gh` CLI auth. This means:
14
+ * - Running `gh auth login` / `gh auth logout` does not affect HQ auth
15
+ * - HQ auth does not overwrite the user's existing `gh` token
16
+ * - The App token is only used for HQ-specific API calls (installations, etc.)
15
17
  *
16
18
  * Token values are NEVER written to stdout or logs.
17
19
  */
18
20
  /** hq-team-sync GitHub App client ID (public — safe to commit). */
19
21
  export declare const HQ_GITHUB_APP_CLIENT_ID = "Iv23liSdkCBQYhrNcRmI";
20
22
  export declare const HQ_GITHUB_APP_SLUG = "hq-team-sync";
23
+ /** Where the HQ App token is persisted. Exported for tests. */
24
+ export declare const HQ_APP_TOKEN_PATH: string;
21
25
  /** Authenticated GitHub user info. The access_token is held in-memory only. */
22
26
  export interface GitHubAuth {
23
27
  /** ghu_ user-to-server token from GitHub App device flow (in-memory only). */
@@ -39,22 +43,51 @@ export interface GitHubAuth {
39
43
  */
40
44
  export type AuthToken = GitHubAuth;
41
45
  /**
42
- * Save the GitHub auth to gh CLI. The token is handed to `gh auth login`
43
- * so it's stored in the OS keychain, not on disk as a plain file.
46
+ * Save the HQ App auth to ~/.hq/app-token.json.
47
+ *
48
+ * The file is written with mode 0600 (owner read+write only). The user's
49
+ * existing `gh` CLI auth is never touched.
50
+ *
51
+ * @param tokenPath — override for testing; defaults to HQ_APP_TOKEN_PATH
44
52
  */
45
- export declare function saveGitHubAuth(auth: GitHubAuth): void;
53
+ export declare function saveGitHubAuth(auth: GitHubAuth, tokenPath?: string): void;
46
54
  /**
47
- * Load GitHub auth from `gh` CLI. Returns null if gh is not installed
48
- * or not authenticated. Fetches user profile from GitHub API via gh.
55
+ * Load HQ App auth from ~/.hq/app-token.json.
56
+ *
57
+ * Returns null if the file doesn't exist, is corrupted, or is missing
58
+ * required fields. Does NOT fall back to `gh` CLI — the HQ App token
59
+ * is separate from the user's personal GitHub auth.
60
+ *
61
+ * @param tokenPath — override for testing; defaults to HQ_APP_TOKEN_PATH
49
62
  */
50
- export declare function loadGitHubAuth(): GitHubAuth | null;
51
- /** Remove stored credentials by logging out of gh. */
52
- export declare function clearGitHubAuth(): void;
63
+ export declare function loadGitHubAuth(tokenPath?: string): GitHubAuth | null;
64
+ /**
65
+ * Remove stored HQ App credentials.
66
+ *
67
+ * Deletes ~/.hq/app-token.json. Does NOT touch `gh` CLI auth.
68
+ *
69
+ * @param tokenPath — override for testing; defaults to HQ_APP_TOKEN_PATH
70
+ */
71
+ export declare function clearGitHubAuth(tokenPath?: string): void;
53
72
  /**
54
73
  * Quick liveness probe — does the stored token still work?
55
- * Uses `gh auth status` which validates the token against GitHub.
74
+ * Validates the token by hitting GET /user on api.github.com.
56
75
  */
57
76
  export declare function isGitHubAuthValid(auth: GitHubAuth): Promise<boolean>;
77
+ /** Result of the App scope probe. */
78
+ export type AppScopeResult = "yes" | "no" | "unknown";
79
+ /**
80
+ * Probe whether a token has GitHub App scopes by hitting /user/installations.
81
+ *
82
+ * Returns:
83
+ * - `"yes"` — 2xx, token has App scopes
84
+ * - `"no"` — 403, token is definitively the wrong type
85
+ * - `"unknown"` — transient failure (network error, 5xx, timeout)
86
+ *
87
+ * Callers should only delete cached tokens on `"no"`, not on `"unknown"`.
88
+ * This is a lightweight check — we request per_page=1 to minimise payload.
89
+ */
90
+ export declare function isAppScopedToken(auth: GitHubAuth): Promise<AppScopeResult>;
58
91
  /** Open a URL in the user's default browser. Best-effort, never throws. */
59
92
  export declare function openBrowser(url: string): void;
60
93
  /**
@@ -62,7 +95,9 @@ export declare function openBrowser(url: string): void;
62
95
  *
63
96
  * On success, the token is:
64
97
  * 1. Returned in-memory as part of GitHubAuth (for the current session)
65
- * 2. Configured in `gh` CLI for future sessions (via gh auth login --with-token)
98
+ * 2. Persisted to ~/.hq/app-token.json for future sessions
99
+ *
100
+ * The user's existing `gh` CLI auth is never modified.
66
101
  *
67
102
  * Throws on:
68
103
  * - Network errors talking to github.com
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAUH,mEAAmE;AACnE,eAAO,MAAM,uBAAuB,yBAAyB,CAAC;AAC9D,eAAO,MAAM,kBAAkB,iBAAiB,CAAC;AAUjD,+EAA+E;AAC/E,MAAM,WAAW,UAAU;IACzB,8EAA8E;IAC9E,YAAY,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,8BAA8B;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,gEAAgE;IAChE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,yDAAyD;IACzD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,UAAU,CAAC;AA8EnC;;;GAGG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAErD;AAED;;;GAGG;AACH,wBAAgB,cAAc,IAAI,UAAU,GAAG,IAAI,CA+BlD;AAED,sDAAsD;AACtD,wBAAgB,eAAe,IAAI,IAAI,CAUtC;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAoB1E;AAID,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAkB7C;AAQD;;;;;;;;;;;;GAYG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,UAAU,CAAC,CA0HjE;AAID;;;GAGG;AACH,wBAAsB,SAAS,CAAC,CAAC,EAC/B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,UAAU,EAChB,IAAI,GAAE,WAAgB,GACrB,OAAO,CAAC,CAAC,CAAC,CAwBZ"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAUH,mEAAmE;AACnE,eAAO,MAAM,uBAAuB,yBAAyB,CAAC;AAC9D,eAAO,MAAM,kBAAkB,iBAAiB,CAAC;AAQjD,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,QAAsC,CAAC;AAIrE,+EAA+E;AAC/E,MAAM,WAAW,UAAU;IACzB,8EAA8E;IAC9E,YAAY,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,8BAA8B;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,gEAAgE;IAChE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,yDAAyD;IACzD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,0CAA0C;IAC1C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,GAAG,UAAU,CAAC;AA4BnC;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,SAAS,SAAoB,GAAG,IAAI,CASpF;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,SAAS,SAAoB,GAAG,UAAU,GAAG,IAAI,CAW/E;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,SAAS,SAAoB,GAAG,IAAI,CAQnE;AAED;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAe1E;AAED,qCAAqC;AACrC,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,IAAI,GAAG,SAAS,CAAC;AAEtD;;;;;;;;;;GAUG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,cAAc,CAAC,CAuBhF;AAID,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAkB7C;AAQD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,UAAU,CAAC,CA0HjE;AAID;;;GAGG;AACH,wBAAsB,SAAS,CAAC,CAAC,EAC/B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,UAAU,EAChB,IAAI,GAAE,WAAgB,GACrB,OAAO,CAAC,CAAC,CAAC,CAuCZ"}
package/dist/auth.js CHANGED
@@ -7,17 +7,20 @@
7
7
  * 1. POST github.com/login/device/code with our App's client_id
8
8
  * 2. Display user_code, open verification_uri in browser
9
9
  * 3. Poll github.com/login/oauth/access_token at the GitHub-specified interval
10
- * 4. On success: GET api.github.com/user → configure gh CLI with the token
10
+ * 4. On success: GET api.github.com/user → save token to ~/.hq/app-token.json
11
11
  *
12
- * After authentication, the token is handed to `gh auth login --with-token`
13
- * so that subsequent sessions can use `gh` for all GitHub operations.
14
- * No credentials are persisted to disk by create-hq itself.
12
+ * The HQ App token is stored in ~/.hq/app-token.json (mode 0600) and is
13
+ * completely independent of the user's `gh` CLI auth. This means:
14
+ * - Running `gh auth login` / `gh auth logout` does not affect HQ auth
15
+ * - HQ auth does not overwrite the user's existing `gh` token
16
+ * - The App token is only used for HQ-specific API calls (installations, etc.)
15
17
  *
16
18
  * Token values are NEVER written to stdout or logs.
17
19
  */
20
+ import * as fs from "fs";
18
21
  import * as path from "path";
19
22
  import * as os from "os";
20
- import { exec, execSync } from "child_process";
23
+ import { exec } from "child_process";
21
24
  import chalk from "chalk";
22
25
  // ─── Constants ──────────────────────────────────────────────────────────────
23
26
  /** hq-team-sync GitHub App client ID (public — safe to commit). */
@@ -27,129 +30,126 @@ const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
27
30
  const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
28
31
  const GITHUB_API_USER_URL = "https://api.github.com/user";
29
32
  const HQ_DIR = path.join(os.homedir(), ".hq");
30
- // ─── gh CLI helpers ────────────────────────────────────────────────────────
31
- /** Check if `gh` CLI is installed. */
32
- function isGhInstalled() {
33
- try {
34
- execSync("gh --version", { stdio: "ignore" });
35
- return true;
36
- }
37
- catch {
38
- return false;
33
+ /** Where the HQ App token is persisted. Exported for tests. */
34
+ export const HQ_APP_TOKEN_PATH = path.join(HQ_DIR, "app-token.json");
35
+ // ─── Token persistence (~/.hq/app-token.json) ────────────────────────────
36
+ /**
37
+ * Save the HQ App auth to ~/.hq/app-token.json.
38
+ *
39
+ * The file is written with mode 0600 (owner read+write only). The user's
40
+ * existing `gh` CLI auth is never touched.
41
+ *
42
+ * @param tokenPath — override for testing; defaults to HQ_APP_TOKEN_PATH
43
+ */
44
+ export function saveGitHubAuth(auth, tokenPath = HQ_APP_TOKEN_PATH) {
45
+ const dir = path.dirname(tokenPath);
46
+ if (!fs.existsSync(dir)) {
47
+ fs.mkdirSync(dir, { recursive: true });
39
48
  }
49
+ fs.writeFileSync(tokenPath, JSON.stringify(auth, null, 2), {
50
+ encoding: "utf-8",
51
+ mode: 0o600,
52
+ });
40
53
  }
41
- /** Check if `gh` is authenticated with any GitHub host. */
42
- function isGhAuthenticated() {
54
+ /**
55
+ * Load HQ App auth from ~/.hq/app-token.json.
56
+ *
57
+ * Returns null if the file doesn't exist, is corrupted, or is missing
58
+ * required fields. Does NOT fall back to `gh` CLI — the HQ App token
59
+ * is separate from the user's personal GitHub auth.
60
+ *
61
+ * @param tokenPath — override for testing; defaults to HQ_APP_TOKEN_PATH
62
+ */
63
+ export function loadGitHubAuth(tokenPath = HQ_APP_TOKEN_PATH) {
43
64
  try {
44
- execSync("gh auth status", { stdio: "ignore" });
45
- return true;
65
+ if (!fs.existsSync(tokenPath))
66
+ return null;
67
+ const raw = fs.readFileSync(tokenPath, "utf-8");
68
+ const data = JSON.parse(raw);
69
+ // Minimal validation — must have at least a token and login
70
+ if (!data.access_token || !data.login)
71
+ return null;
72
+ return data;
46
73
  }
47
74
  catch {
48
- return false;
75
+ return null;
49
76
  }
50
77
  }
51
78
  /**
52
- * Configure `gh` CLI with a token and set up git credential helper.
53
- * This makes the token available for all future `gh` and `git` operations.
79
+ * Remove stored HQ App credentials.
80
+ *
81
+ * Deletes ~/.hq/app-token.json. Does NOT touch `gh` CLI auth.
82
+ *
83
+ * @param tokenPath — override for testing; defaults to HQ_APP_TOKEN_PATH
54
84
  */
55
- function configureGhAuth(token) {
56
- if (!isGhInstalled()) {
57
- console.error(chalk.dim(" (gh CLI not found — install it from https://cli.github.com for team commands)"));
58
- return;
59
- }
85
+ export function clearGitHubAuth(tokenPath = HQ_APP_TOKEN_PATH) {
60
86
  try {
61
- // Pipe token to gh auth login (stdin, non-interactive)
62
- execSync("gh auth login --with-token", {
63
- input: token,
64
- stdio: ["pipe", "ignore", "ignore"],
65
- });
66
- // Configure git to use gh for HTTPS auth
67
- execSync("gh auth setup-git", { stdio: "ignore" });
87
+ if (fs.existsSync(tokenPath)) {
88
+ fs.unlinkSync(tokenPath);
89
+ }
68
90
  }
69
- catch (err) {
70
- console.error(chalk.dim(" (could not configure gh CLI you can run `gh auth login` manually)"));
91
+ catch {
92
+ // ignoremay already be gone
71
93
  }
72
94
  }
73
95
  /**
74
- * Save the GitHub auth to gh CLI. The token is handed to `gh auth login`
75
- * so it's stored in the OS keychain, not on disk as a plain file.
76
- */
77
- export function saveGitHubAuth(auth) {
78
- configureGhAuth(auth.access_token);
79
- }
80
- /**
81
- * Load GitHub auth from `gh` CLI. Returns null if gh is not installed
82
- * or not authenticated. Fetches user profile from GitHub API via gh.
96
+ * Quick liveness probe does the stored token still work?
97
+ * Validates the token by hitting GET /user on api.github.com.
83
98
  */
84
- export function loadGitHubAuth() {
85
- if (!isGhInstalled() || !isGhAuthenticated())
86
- return null;
99
+ export async function isGitHubAuthValid(auth) {
100
+ if (!auth.access_token)
101
+ return false;
87
102
  try {
88
- // Get the token from gh
89
- const token = execSync("gh auth token", {
90
- encoding: "utf-8",
91
- stdio: ["pipe", "pipe", "ignore"],
92
- }).trim();
93
- if (!token)
94
- return null;
95
- // Get user profile via gh api
96
- const userJson = execSync('gh api user --jq \'{"login":.login,"id":.id,"name":.name,"email":.email}\'', {
97
- encoding: "utf-8",
98
- stdio: ["pipe", "pipe", "ignore"],
99
- }).trim();
100
- const user = JSON.parse(userJson);
101
- return {
102
- access_token: token,
103
- login: user.login,
104
- id: user.id,
105
- name: user.name,
106
- email: user.email,
107
- issued_at: new Date().toISOString(),
108
- };
103
+ const res = await fetch(GITHUB_API_USER_URL, {
104
+ headers: {
105
+ Authorization: `token ${auth.access_token}`,
106
+ Accept: "application/vnd.github+json",
107
+ "User-Agent": "create-hq",
108
+ },
109
+ signal: AbortSignal.timeout(10_000),
110
+ });
111
+ return res.ok;
109
112
  }
110
113
  catch {
111
- return null;
114
+ return false;
112
115
  }
113
116
  }
114
- /** Remove stored credentials by logging out of gh. */
115
- export function clearGitHubAuth() {
116
- if (!isGhInstalled())
117
- return;
117
+ /**
118
+ * Probe whether a token has GitHub App scopes by hitting /user/installations.
119
+ *
120
+ * Returns:
121
+ * - `"yes"` — 2xx, token has App scopes
122
+ * - `"no"` — 403, token is definitively the wrong type
123
+ * - `"unknown"` — transient failure (network error, 5xx, timeout)
124
+ *
125
+ * Callers should only delete cached tokens on `"no"`, not on `"unknown"`.
126
+ * This is a lightweight check — we request per_page=1 to minimise payload.
127
+ */
128
+ export async function isAppScopedToken(auth) {
129
+ if (!auth.access_token)
130
+ return "no";
118
131
  try {
119
- execSync("gh auth logout --hostname github.com", {
120
- input: "Y\n",
121
- stdio: ["pipe", "ignore", "ignore"],
132
+ const res = await fetch("https://api.github.com/user/installations?per_page=1", {
133
+ headers: {
134
+ Authorization: `token ${auth.access_token}`,
135
+ Accept: "application/vnd.github+json",
136
+ "User-Agent": "create-hq",
137
+ },
138
+ signal: AbortSignal.timeout(10_000),
122
139
  });
140
+ if (res.ok)
141
+ return "yes";
142
+ // 401/403 = definitive "wrong token type"
143
+ if (res.status === 401 || res.status === 403)
144
+ return "no";
145
+ // Anything else (429, 5xx) = transient
146
+ return "unknown";
123
147
  }
124
148
  catch {
125
- // ignore may already be logged out
149
+ // Network error, timeout, DNS failure = transient
150
+ return "unknown";
126
151
  }
127
152
  }
128
- /**
129
- * Quick liveness probe — does the stored token still work?
130
- * Uses `gh auth status` which validates the token against GitHub.
131
- */
132
- export async function isGitHubAuthValid(auth) {
133
- // If we have a token in memory, verify it directly
134
- if (auth.access_token) {
135
- try {
136
- const res = await fetch(GITHUB_API_USER_URL, {
137
- headers: {
138
- Authorization: `token ${auth.access_token}`,
139
- Accept: "application/vnd.github+json",
140
- "User-Agent": "create-hq",
141
- },
142
- signal: AbortSignal.timeout(10_000),
143
- });
144
- return res.ok;
145
- }
146
- catch {
147
- return false;
148
- }
149
- }
150
- // Fall back to gh auth status
151
- return isGhAuthenticated();
152
- }
153
153
  // ─── Browser open (cross-platform) ──────────────────────────────────────────
154
154
  /** Open a URL in the user's default browser. Best-effort, never throws. */
155
155
  export function openBrowser(url) {
@@ -180,7 +180,9 @@ function sleep(ms) {
180
180
  *
181
181
  * On success, the token is:
182
182
  * 1. Returned in-memory as part of GitHubAuth (for the current session)
183
- * 2. Configured in `gh` CLI for future sessions (via gh auth login --with-token)
183
+ * 2. Persisted to ~/.hq/app-token.json for future sessions
184
+ *
185
+ * The user's existing `gh` CLI auth is never modified.
184
186
  *
185
187
  * Throws on:
186
188
  * - Network errors talking to github.com
@@ -287,7 +289,7 @@ export async function startGitHubDeviceFlow() {
287
289
  email: user.email,
288
290
  issued_at: new Date().toISOString(),
289
291
  };
290
- // Configure gh CLI with the token for future sessions
292
+ // Persist for future sessions (does not touch gh CLI)
291
293
  saveGitHubAuth(auth);
292
294
  return auth;
293
295
  }
@@ -314,6 +316,15 @@ export async function githubApi(pathname, auth, init = {}) {
314
316
  });
315
317
  if (!res.ok) {
316
318
  const body = await res.text().catch(() => "");
319
+ // Friendly error when a non-App token hits the App-only installations endpoint.
320
+ if (res.status === 403 &&
321
+ pathname.startsWith("/user/installations") &&
322
+ body.includes("authorized to a GitHub App")) {
323
+ throw new Error("You're signed in with a regular GitHub token that can't list App installations.\n" +
324
+ " HQ Teams requires authentication through the HQ GitHub App.\n\n" +
325
+ " To fix this, re-run the installer — it will prompt you to authorize the HQ App:\n" +
326
+ " npx create-hq");
327
+ }
317
328
  throw new Error(`GitHub API ${res.status} ${pathname}: ${body}`);
318
329
  }
319
330
  // Some endpoints return 204