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.
- package/dist/__tests__/auth.test.d.ts +2 -0
- package/dist/__tests__/auth.test.d.ts.map +1 -0
- package/dist/__tests__/auth.test.js +201 -0
- package/dist/__tests__/auth.test.js.map +1 -0
- package/dist/auth.d.ts +49 -14
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +115 -104
- package/dist/auth.js.map +1 -1
- package/dist/fetch-template.d.ts +3 -3
- package/dist/fetch-template.d.ts.map +1 -1
- package/dist/fetch-template.js +93 -71
- package/dist/fetch-template.js.map +1 -1
- package/dist/git.d.ts +12 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +40 -20
- package/dist/git.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +48 -13
- package/dist/scaffold.js.map +1 -1
- package/dist/teams-flow.d.ts +7 -2
- package/dist/teams-flow.d.ts.map +1 -1
- package/dist/teams-flow.js +31 -8
- package/dist/teams-flow.js.map +1 -1
- package/dist/ui.d.ts +3 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +79 -3
- package/dist/ui.js.map +1 -1
- package/package.json +1 -1
- package/dist/art.d.ts +0 -17
- package/dist/art.d.ts.map +0 -1
- package/dist/art.js +0 -171
- package/dist/art.js.map +0 -1
- package/dist/cloud.d.ts +0 -26
- package/dist/cloud.d.ts.map +0 -1
- package/dist/cloud.js +0 -126
- package/dist/cloud.js.map +0 -1
- package/dist/tui.d.ts +0 -8
- package/dist/tui.d.ts.map +0 -1
- package/dist/tui.js +0 -86
- package/dist/tui.js.map +0 -1
|
@@ -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":"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"}
|
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 →
|
|
10
|
+
* 4. On success: GET api.github.com/user → save token to ~/.hq/app-token.json
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
43
|
-
*
|
|
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
|
|
48
|
-
*
|
|
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
|
-
/**
|
|
52
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
package/dist/auth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA
|
|
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 →
|
|
10
|
+
* 4. On success: GET api.github.com/user → save token to ~/.hq/app-token.json
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
/**
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
75
|
+
return null;
|
|
49
76
|
}
|
|
50
77
|
}
|
|
51
78
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
70
|
-
|
|
91
|
+
catch {
|
|
92
|
+
// ignore — may already be gone
|
|
71
93
|
}
|
|
72
94
|
}
|
|
73
95
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
|
85
|
-
if (!
|
|
86
|
-
return
|
|
99
|
+
export async function isGitHubAuthValid(auth) {
|
|
100
|
+
if (!auth.access_token)
|
|
101
|
+
return false;
|
|
87
102
|
try {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
114
|
+
return false;
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|