Skip to content

Commit dc17959

Browse files
committed
gh-cli-auto-auth (squashed)
1 parent b4a3503 commit dc17959

11 files changed

Lines changed: 365 additions & 58 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,15 @@ which the PtyManager sets when spawning each terminal.
252252
The user pastes a GitHub personal access token into Settings. It's encrypted
253253
via `safeStorage` and stored in `userData/secrets.enc`. All GitHub data
254254
(PR status, check runs, statuses) goes through `src/main/github.ts` using
255-
`fetch()` against the REST API — there is **no dependency on the `gh` CLI**.
255+
`fetch()` against the REST API.
256+
257+
Token resolution lives in `src/main/github-auth.ts` and runs once at boot
258+
(re-runs on a 401): an explicit PAT in `secrets.enc` or `GITHUB_TOKEN` wins,
259+
then `gh auth token` (spawned through a login zsh so Homebrew's `gh` is on
260+
PATH), then nothing. The `gh` CLI is an **optional** auto-detect convenience
261+
— if it's installed and authenticated, Harness uses its token automatically;
262+
if not, the PAT paste flow in Settings is still the fallback. Harness has no
263+
hard dependency on `gh`.
256264

257265
## Important quirks
258266

src/main/github-auth.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { execFile } from 'child_process'
2+
import { promisify } from 'util'
3+
import { log } from './debug'
4+
import { getSecret } from './secrets'
5+
6+
const execFileAsync = promisify(execFile)
7+
8+
export type TokenSource = 'pat' | 'gh-cli'
9+
10+
interface ResolvedToken {
11+
token: string
12+
source: TokenSource
13+
}
14+
15+
let cached: ResolvedToken | null = null
16+
let resolving: Promise<ResolvedToken | null> | null = null
17+
18+
/** Probe GET /user to confirm the token works. Returns scopes on success. */
19+
async function probeToken(token: string): Promise<{ ok: boolean; scopes: string[]; status: number }> {
20+
try {
21+
const res = await fetch('https://api.github.com/user', {
22+
headers: {
23+
Accept: 'application/vnd.github+json',
24+
'User-Agent': 'Harness',
25+
Authorization: `Bearer ${token}`
26+
}
27+
})
28+
const scopesHeader = res.headers.get('x-oauth-scopes') || ''
29+
const scopes = scopesHeader
30+
.split(',')
31+
.map((s) => s.trim())
32+
.filter(Boolean)
33+
return { ok: res.ok, scopes, status: res.status }
34+
} catch (err) {
35+
log('github-auth', 'probe failed', err instanceof Error ? err.message : err)
36+
return { ok: false, scopes: [], status: 0 }
37+
}
38+
}
39+
40+
const DESIRED_SCOPES = ['repo', 'read:org']
41+
42+
function warnIfScopesShort(source: TokenSource, scopes: string[]): void {
43+
// Fine-grained PATs return an empty x-oauth-scopes header — don't warn
44+
// on empty, only on explicitly-present-but-insufficient classic scopes.
45+
if (scopes.length === 0) return
46+
const missing = DESIRED_SCOPES.filter(
47+
(needed) => !scopes.some((s) => s === needed || s.startsWith(`${needed}:`))
48+
)
49+
if (missing.length > 0) {
50+
log('github-auth', `${source} token missing desired scopes: ${missing.join(', ')} (have: ${scopes.join(', ')})`)
51+
}
52+
}
53+
54+
/** Try reading a token from `gh auth token`. Returns null if gh is absent or not logged in. */
55+
async function readGhCliToken(): Promise<string | null> {
56+
// Login shell so Homebrew's gh is on PATH (matching PtyManager). Login-only
57+
// (no -i) avoids .zshrc side effects that can break non-TTY invocations —
58+
// PATH from Homebrew lives in .zprofile/.zlogin which login mode sources.
59+
try {
60+
const { stdout } = await execFileAsync('/bin/zsh', ['-lc', 'gh auth token'], {
61+
timeout: 3000
62+
})
63+
// gh auth token can include trailing newline + nothing else on stderr.
64+
const token = stdout.trim()
65+
if (!token) {
66+
log('github-auth', 'gh auth token returned empty stdout')
67+
return null
68+
}
69+
return token
70+
} catch (err) {
71+
// execFile errors carry .stdout/.stderr from the failed process.
72+
const e = err as { code?: string | number; stderr?: string; stdout?: string; message?: string }
73+
log('github-auth', `gh auth token failed: code=${e.code} stderr=${(e.stderr || '').trim()} stdout=${(e.stdout || '').trim()} msg=${e.message}`)
74+
return null
75+
}
76+
}
77+
78+
async function doResolve(): Promise<ResolvedToken | null> {
79+
// 1. Explicit PAT from secrets or env wins.
80+
const pat = getSecret('githubToken') || process.env.GITHUB_TOKEN || null
81+
if (pat) {
82+
const probe = await probeToken(pat)
83+
if (probe.ok) {
84+
warnIfScopesShort('pat', probe.scopes)
85+
log('github-auth', 'resolved token source=pat')
86+
return { token: pat, source: 'pat' }
87+
}
88+
log('github-auth', `PAT probe failed (status ${probe.status}), falling through to gh CLI`)
89+
}
90+
91+
// 2. gh CLI.
92+
const ghToken = await readGhCliToken()
93+
if (ghToken) {
94+
const probe = await probeToken(ghToken)
95+
if (probe.ok) {
96+
warnIfScopesShort('gh-cli', probe.scopes)
97+
log('github-auth', 'resolved token source=gh-cli')
98+
return { token: ghToken, source: 'gh-cli' }
99+
}
100+
log('github-auth', `gh CLI token probe failed (status ${probe.status})`)
101+
}
102+
103+
// 3. Nothing.
104+
log('github-auth', 'no GitHub token available')
105+
return null
106+
}
107+
108+
/** Resolve and cache a GitHub token. Safe to call concurrently — only one resolution runs at a time. */
109+
export async function resolveGitHubToken(): Promise<ResolvedToken | null> {
110+
if (cached) return cached
111+
if (resolving) return resolving
112+
resolving = doResolve().then((result) => {
113+
cached = result
114+
resolving = null
115+
return result
116+
})
117+
return resolving
118+
}
119+
120+
/** Synchronous read of the cached token. Returns null if resolve hasn't been called yet or failed. */
121+
export function getCachedToken(): string | null {
122+
return cached?.token ?? null
123+
}
124+
125+
/** Current auth source, or null if unresolved. */
126+
export function getTokenSource(): TokenSource | null {
127+
return cached?.source ?? null
128+
}
129+
130+
/** Drop the cached token so the next resolveGitHubToken() call re-probes. */
131+
export function invalidateTokenCache(): void {
132+
log('github-auth', 'invalidating token cache')
133+
cached = null
134+
}

src/main/github.ts

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import { execFile } from 'child_process'
22
import { promisify } from 'util'
33
import { log } from './debug'
4-
import { getSecret } from './secrets'
4+
import { getCachedToken, invalidateTokenCache, resolveGitHubToken } from './github-auth'
55
import type { CheckStatus, PRReview, PRStatus } from '../shared/state/prs'
66

77
export type { CheckStatus, PRReview, PRStatus }
88

99
const execFileAsync = promisify(execFile)
1010

11-
function getToken(): string | null {
12-
return getSecret('githubToken') || process.env.GITHUB_TOKEN || null
13-
}
14-
1511
/** Parse the GitHub owner/repo from a remote URL like git@github.com:owner/repo.git or https://github.com/owner/repo.git */
1612
function parseRemoteUrl(url: string): { owner: string; repo: string } | null {
1713
// SSH: git@github.com:owner/repo.git
@@ -52,17 +48,27 @@ async function getCurrentBranch(worktreePath: string): Promise<string | null> {
5248
}
5349
}
5450

55-
/** Make an authenticated request to the GitHub REST API */
56-
async function githubFetch(url: string): Promise<unknown> {
57-
const token = getToken()
51+
async function doFetch(url: string, token: string | null): Promise<Response> {
5852
const headers: Record<string, string> = {
5953
Accept: 'application/vnd.github+json',
6054
'User-Agent': 'Harness',
6155
'X-GitHub-Api-Version': '2022-11-28'
6256
}
6357
if (token) headers.Authorization = `Bearer ${token}`
58+
return fetch(url, { headers })
59+
}
6460

65-
const res = await fetch(url, { headers })
61+
/** Make an authenticated request to the GitHub REST API. On 401, invalidate the token cache and retry once. */
62+
async function githubFetch(url: string): Promise<unknown> {
63+
let token = getCachedToken()
64+
let res = await doFetch(url, token)
65+
if (res.status === 401) {
66+
log('github', '401 from GitHub, re-resolving token')
67+
invalidateTokenCache()
68+
const resolved = await resolveGitHubToken()
69+
token = resolved?.token ?? null
70+
res = await doFetch(url, token)
71+
}
6672
if (!res.ok) {
6773
throw new Error(`GitHub API ${res.status}: ${res.statusText}`)
6874
}
@@ -166,7 +172,7 @@ function computeOverall(checks: CheckStatus[]): PRStatus['checksOverall'] {
166172

167173
/** Get PR status for the branch checked out in a worktree. Returns null if no PR or no token. */
168174
export async function getPRStatus(worktreePath: string): Promise<PRStatus | null> {
169-
const token = getToken()
175+
const token = getCachedToken()
170176
if (!token) return null
171177

172178
const repoInfo = await getRepoInfo(worktreePath)
@@ -274,6 +280,43 @@ export async function getPRStatus(worktreePath: string): Promise<PRStatus | null
274280
}
275281
}
276282

283+
/** Check whether the authenticated user has starred the repo. */
284+
export async function isRepoStarred(token: string, owner: string, repo: string): Promise<boolean | null> {
285+
try {
286+
const res = await fetch(`https://api.github.com/user/starred/${owner}/${repo}`, {
287+
headers: {
288+
Accept: 'application/vnd.github+json',
289+
'User-Agent': 'Harness',
290+
Authorization: `Bearer ${token}`
291+
}
292+
})
293+
if (res.status === 204) return true
294+
if (res.status === 404) return false
295+
return null
296+
} catch {
297+
return null
298+
}
299+
}
300+
301+
/** Unstar a repository. Idempotent. */
302+
export async function unstarRepo(token: string, owner: string, repo: string): Promise<{ ok: boolean; error?: string }> {
303+
try {
304+
const res = await fetch(`https://api.github.com/user/starred/${owner}/${repo}`, {
305+
method: 'DELETE',
306+
headers: {
307+
Accept: 'application/vnd.github+json',
308+
'User-Agent': 'Harness',
309+
Authorization: `Bearer ${token}`,
310+
'X-GitHub-Api-Version': '2022-11-28'
311+
}
312+
})
313+
if (res.status === 204) return { ok: true }
314+
return { ok: false, error: `${res.status} ${res.statusText}` }
315+
} catch (err) {
316+
return { ok: false, error: err instanceof Error ? err.message : String(err) }
317+
}
318+
}
319+
277320
/** Star a repository on behalf of the authenticated user. Idempotent. */
278321
export async function starRepo(token: string, owner: string, repo: string): Promise<{ ok: boolean; error?: string }> {
279322
try {

src/main/index.ts

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import { PanesFSM, stripTransientTabFields } from './panes-fsm'
1212
import { ActivityDeriver } from './activity-deriver'
1313
import type { TerminalTab, WorkspacePane } from '../shared/state/terminals'
1414
import { listWorktrees, listBranches, continueWorktree, removeWorktree, isWorktreeDirty, defaultWorktreeDir, getChangedFiles, getFileDiff, getBranchCommits, getCommitDiff, getMainWorktreeStatus, prepareMainForMerge, mergeWorktreeLocally, getBranchSha, previewMergeConflicts, getBranchDiffStats, listAllFiles, readWorktreeFile, runWorktreeScript, type MergeStrategy } from './worktree'
15-
import { getPRStatus, testToken, starRepo } from './github'
15+
import { getPRStatus, testToken, starRepo, unstarRepo, isRepoStarred } from './github'
1616
import { AVAILABLE_EDITORS, DEFAULT_EDITOR_ID, openInEditor } from './editor'
1717
import { setSecret, hasSecret, deleteSecret } from './secrets'
18+
import { resolveGitHubToken, getTokenSource, invalidateTokenCache, getCachedToken } from './github-auth'
1819
import {
1920
loadConfig,
2021
saveConfig,
@@ -119,11 +120,36 @@ const store = new Store({
119120
editor: config.editor || DEFAULT_EDITOR_ID,
120121
worktreeBase: config.worktreeBase || DEFAULT_WORKTREE_BASE,
121122
mergeStrategy: config.mergeStrategy || DEFAULT_MERGE_STRATEGY,
122-
hasGithubToken: hasSecret('githubToken')
123+
hasGithubToken: hasSecret('githubToken'),
124+
githubAuthSource: null
123125
}
124126
})
125127
registerStateTransport(store)
126128

129+
/** Query the harness star state, dispatch it to the slice, and auto-star
130+
* exactly once per user (sticky so manual unstars survive reboots). Safe
131+
* to call after any token resolution — boot, PAT save, etc. */
132+
async function refreshHarnessStarState(): Promise<void> {
133+
const token = getCachedToken()
134+
if (!token) {
135+
store.dispatch({ type: 'settings/harnessStarredChanged', payload: null })
136+
return
137+
}
138+
const starred = await isRepoStarred(token, 'frenchie4111', 'harness')
139+
if (starred === false && !config.harnessAutoStarred) {
140+
const result = await starRepo(token, 'frenchie4111', 'harness')
141+
if (result.ok) {
142+
config.harnessAutoStarred = true
143+
saveConfig(config)
144+
store.dispatch({ type: 'settings/harnessStarredChanged', payload: true })
145+
log('app', 'auto-starred harness on first GitHub connection')
146+
return
147+
}
148+
log('app', 'auto-star failed', result.error)
149+
}
150+
store.dispatch({ type: 'settings/harnessStarredChanged', payload: starred })
151+
}
152+
127153
const prPoller = new PRPoller(store, {
128154
getRepoRoots: () => config.repoRoots || [],
129155
getLocallyMerged: () => config.locallyMerged || {},
@@ -886,36 +912,51 @@ function registerIpcHandlers(): void {
886912
return store.getSnapshot().state.settings.hasGithubToken
887913
})
888914

889-
ipcMain.handle('settings:setGithubToken', async (_, token: string, options?: { starRepo?: boolean }) => {
915+
ipcMain.handle('settings:setGithubToken', async (_, token: string) => {
890916
const trimmed = token.trim()
891917
if (!trimmed) {
892918
deleteSecret('githubToken')
893919
store.dispatch({ type: 'settings/hasGithubTokenChanged', payload: false })
920+
invalidateTokenCache()
921+
await resolveGitHubToken()
922+
store.dispatch({ type: 'settings/githubAuthSourceChanged', payload: getTokenSource() })
923+
await refreshHarnessStarState()
894924
return { ok: true }
895925
}
896926
// Validate the token first by hitting /user
897927
const test = await testToken(trimmed)
898928
if (!test.ok) return { ok: false, error: test.error }
899929
setSecret('githubToken', trimmed)
900930
store.dispatch({ type: 'settings/hasGithubTokenChanged', payload: true })
901-
902-
// Optionally star the repo — fire and forget, don't fail token save if this fails
903-
let starred = false
904-
if (options?.starRepo) {
905-
const result = await starRepo(trimmed, 'frenchie4111', 'harness')
906-
starred = result.ok
907-
if (!result.ok) log('app', 'failed to star repo', result.error)
908-
}
909-
910-
return { ok: true, username: test.username, starred }
931+
invalidateTokenCache()
932+
await resolveGitHubToken()
933+
store.dispatch({ type: 'settings/githubAuthSourceChanged', payload: getTokenSource() })
934+
await refreshHarnessStarState()
935+
return { ok: true, username: test.username }
911936
})
912937

913-
ipcMain.handle('settings:clearGithubToken', () => {
938+
ipcMain.handle('settings:clearGithubToken', async () => {
914939
deleteSecret('githubToken')
915940
store.dispatch({ type: 'settings/hasGithubTokenChanged', payload: false })
941+
invalidateTokenCache()
942+
await resolveGitHubToken()
943+
store.dispatch({ type: 'settings/githubAuthSourceChanged', payload: getTokenSource() })
944+
await refreshHarnessStarState()
916945
return true
917946
})
918947

948+
ipcMain.handle('settings:setHarnessStarred', async (_, starred: boolean) => {
949+
const token = getCachedToken()
950+
if (!token) return { ok: false, error: 'No GitHub token' }
951+
const result = starred
952+
? await starRepo(token, 'frenchie4111', 'harness')
953+
: await unstarRepo(token, 'frenchie4111', 'harness')
954+
if (result.ok) {
955+
store.dispatch({ type: 'settings/harnessStarredChanged', payload: starred })
956+
}
957+
return result
958+
})
959+
919960
// Updater
920961
ipcMain.handle('updater:getVersion', () => {
921962
return app.getVersion()
@@ -1228,10 +1269,17 @@ app.whenReady().then(() => {
12281269
// and writes recordActivity + lastActive without renderer involvement.
12291270
activityDeriver.start()
12301271

1231-
// Kick off the PR poller. An initial refreshAll() runs immediately and
1232-
// then on a 5-minute interval.
1233-
prPoller.start()
1234-
void prPoller.refreshAll()
1272+
// Resolve the GitHub token (PAT → gh CLI → none) before the PR poller
1273+
// makes its first call. The poller's initial refreshAll waits on this.
1274+
void (async () => {
1275+
await resolveGitHubToken()
1276+
const source = getTokenSource()
1277+
store.dispatch({ type: 'settings/githubAuthSourceChanged', payload: source })
1278+
prPoller.start()
1279+
void prPoller.refreshAll()
1280+
1281+
await refreshHarnessStarState()
1282+
})()
12351283

12361284
// Seed hooks.consent from disk. If any known worktree already has the
12371285
// hooks installed, the user must have accepted at some point — remember

0 commit comments

Comments
 (0)