Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion shell/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,18 @@ <h3 class="state__title">Not signed in</h3>
<span class="gh-reuse__divider-label">or</span>
</div>
<div class="subsection">
<h3 class="subsection__title">Reuse a GitHub CLI account</h3>
<div class="gh-reuse__header">
<h3 class="subsection__title">
Reuse a GitHub CLI account
</h3>
<button
type="button"
class="btn btn--ghost btn--sm"
data-action="gh-refresh"
>
Refresh
</button>
</div>
<p class="state__caption">
Sign in with an account you&rsquo;re already logged into
with <code class="mono">gh</code> on this Mac &mdash; no code
Expand Down
37 changes: 36 additions & 1 deletion shell/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,21 @@ function hideGhError(): void {
el.textContent = "";
}

/** apiCall surfaces the raw response body in `error`. Our settings endpoints
* reply `{ error: { message } }`; pull that out so the UI shows the specific
* reason (e.g. the /gh/use pre-flight message) rather than raw JSON. */
function apiErrorMessage(raw: string): string {
try {
const parsed = JSON.parse(raw) as { error?: { message?: string } };
if (typeof parsed.error?.message === "string" && parsed.error.message) {
return parsed.error.message;
}
} catch {
// not JSON — use the raw text
}
return raw;
}

/**
* Populate the "reuse a GitHub CLI account" list from GET /gh/status. The
* wrapper stays hidden unless gh is installed AND has ≥1 account — a user
Expand Down Expand Up @@ -732,8 +747,11 @@ async function useGhAccount(button: HTMLElement): Promise<void> {
for (const b of signInButtons) b.disabled = false;
buttonEl.textContent = originalLabel;
row?.removeAttribute("aria-busy");
// The pre-flight (POST /gh/use) returns a specific reason — stale token,
// no Copilot subscription, etc. — caught BEFORE any reboot. Show it.
showGhError(
"Couldn't sign in with that account. Try the code-based sign-in above.",
apiErrorMessage(result.error) ||
"Couldn't sign in with that account. Try the code-based sign-in above.",
);
buttonEl.focus();
return;
Expand Down Expand Up @@ -995,6 +1013,9 @@ function wireAccount(): void {
case "gh-use":
void useGhAccount(button);
break;
case "gh-refresh":
void loadGhAccounts();
break;
default:
break;
}
Expand Down Expand Up @@ -1030,3 +1051,17 @@ window.addEventListener("hashchange", () => {
stopAuthPolling();
}
});

// Refresh on returning to the window. The common flow is: open the Account
// section here, switch to a terminal, `gh auth login` (or out) of an account,
// then switch back — re-loading auth status on focus re-runs gh discovery so
// the new account appears without leaving + re-entering the section. We do NOT
// poll (gh auth status touches the OS keyring); focus/visibility is the right
// "moment of attention" to refresh.
function refreshAccountOnAttention(): void {
if (readHashSection() === "account") void loadAuthStatus();
}
window.addEventListener("focus", refreshAccountOnAttention);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") refreshAccountOnAttention();
});
8 changes: 8 additions & 0 deletions shell/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,14 @@ a:hover {
display: none;
}

/* Title + a quiet "Refresh" affordance on one row. */
.gh-reuse__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
}

/* Centered "or" rule between the device-code button and the gh list. */
.gh-reuse__divider {
display: flex;
Expand Down
40 changes: 39 additions & 1 deletion src/routes/settings/gh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,39 @@

import { Hono } from "hono"

import { forwardError } from "~/lib/error"
import { forwardError, HTTPError } from "~/lib/error"
import { makeRecord, writeDefaultRecord } from "~/lib/github-token-store"
import { detectGhCli, getGhAccountToken } from "~/services/gh-cli"
import { getCopilotUsage } from "~/services/github/get-copilot-usage"

/**
* Verify a gh account's token actually works for Copilot BEFORE we adopt it.
* Mirrors what boot does (the live identity check that was failing silently):
* a stale/revoked token → GitHub rejects it; an account with no Copilot →
* /copilot_internal/user 403/404. Returns a specific, user-facing message so
* the UI can say WHY synchronously, instead of writing the token, rebooting,
* and surfacing a generic "came back unauthenticated" 20s later. Returns null
* when the token is usable.
*/
export async function preflightCopilotError(
token: string,
login: string,
usage: (token: string) => Promise<unknown> = getCopilotUsage,
): Promise<string | null> {
try {
await usage(token)
return null
} catch (error) {
const status = error instanceof HTTPError ? error.response.status : 0
if (status === 401) {
return `GitHub rejected ${login}'s token — it may be expired or revoked. Run \`gh auth login\` and try again, or sign in with a code.`
}
if (status === 403 || status === 404) {
return `${login} doesn't have access to GitHub Copilot. Pick another account, or sign in with a code.`
}
return `Couldn't verify ${login} with GitHub${status ? ` (HTTP ${status})` : ""}. Check your connection and try again.`
}
}

export const ghRoutes = new Hono()

Expand Down Expand Up @@ -70,6 +100,14 @@ ghRoutes.post("/use", async (c) => {
)
}

// Pre-flight: confirm the token works for Copilot BEFORE writing it +
// rebooting, so a stale/no-subscription account fails fast with a specific
// reason rather than a generic post-reboot "came back unauthenticated".
const preflightError = await preflightCopilotError(token, login)
if (preflightError) {
return c.json({ error: { message: preflightError } }, 422)
}

await writeDefaultRecord(makeRecord(token))
return c.json({ ok: true, login, host })
} catch (error) {
Expand Down
4 changes: 4 additions & 0 deletions src/services/copilot/get-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import consola from "consola"

import { copilotBaseUrl, copilotModelsHeaders } from "~/lib/api-config"
import { HTTPError } from "~/lib/error"
import { GITHUB_API_TIMEOUT_MS } from "~/lib/http-timeouts"
import { state } from "~/lib/state"

export const getModels = async () => {
consola.info(`Fetching models from ${copilotBaseUrl(state)}/models`)
const response = await fetch(`${copilotBaseUrl(state)}/models`, {
headers: copilotModelsHeaders(state),
// Bounded like the other auth/discovery fetches — cacheModels runs on the
// cold-boot critical path, so an unbounded hang here would stall boot.
signal: AbortSignal.timeout(GITHUB_API_TIMEOUT_MS),
})

if (!response.ok) {
Expand Down
44 changes: 44 additions & 0 deletions tests/gh-preflight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"

import { HTTPError } from "~/lib/error"
import { preflightCopilotError } from "~/routes/settings/gh"

const ok = () => Promise.resolve({ copilot_plan: "enterprise" })
const throwsHttp = (status: number) => () =>
Promise.reject(new HTTPError("x", new Response(null, { status })))

describe("preflightCopilotError", () => {
test("returns null when the token works for Copilot", async () => {
expect(await preflightCopilotError("gho_x", "alice", ok)).toBeNull()
})

test("401 → expired/revoked, names the account", async () => {
const msg = await preflightCopilotError("gho_x", "alice", throwsHttp(401))
expect(msg).toContain("expired or revoked")
expect(msg).toContain("alice")
})

test("403 / 404 → no Copilot subscription", async () => {
for (const status of [403, 404]) {
const msg = await preflightCopilotError(
"gho_x",
"bob",
throwsHttp(status),
)
expect(msg).toContain("doesn't have access to GitHub Copilot")
}
})

test("other status → generic verify message including the status", async () => {
const msg = await preflightCopilotError("gho_x", "carol", throwsHttp(503))
expect(msg).toContain("Couldn't verify")
expect(msg).toContain("503")
})

test("non-HTTP error (network) → generic verify message", async () => {
const msg = await preflightCopilotError("gho_x", "carol", () =>
Promise.reject(new Error("network down")),
)
expect(msg).toContain("Couldn't verify")
})
})
Loading