Spaces:
Configuration error
Configuration error
| import { intro, note, outro, spinner } from "@clack/prompts"; | |
| import { ensureAuthProfileStore, upsertAuthProfile } from "../agents/auth-profiles.js"; | |
| import { updateConfig } from "../commands/models/shared.js"; | |
| import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; | |
| import { logConfigUpdated } from "../config/logging.js"; | |
| import type { RuntimeEnv } from "../runtime.js"; | |
| import { stylePromptTitle } from "../terminal/prompt-style.js"; | |
| const CLIENT_ID = "Iv1.b507a08c87ecfe98"; | |
| const DEVICE_CODE_URL = "https://github.com/login/device/code"; | |
| const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; | |
| type DeviceCodeResponse = { | |
| device_code: string; | |
| user_code: string; | |
| verification_uri: string; | |
| expires_in: number; | |
| interval: number; | |
| }; | |
| type DeviceTokenResponse = | |
| | { | |
| access_token: string; | |
| token_type: string; | |
| scope?: string; | |
| } | |
| | { | |
| error: string; | |
| error_description?: string; | |
| error_uri?: string; | |
| }; | |
| function parseJsonResponse<T>(value: unknown): T { | |
| if (!value || typeof value !== "object") { | |
| throw new Error("Unexpected response from GitHub"); | |
| } | |
| return value as T; | |
| } | |
| async function requestDeviceCode(params: { scope: string }): Promise<DeviceCodeResponse> { | |
| const body = new URLSearchParams({ | |
| client_id: CLIENT_ID, | |
| scope: params.scope, | |
| }); | |
| const res = await fetch(DEVICE_CODE_URL, { | |
| method: "POST", | |
| headers: { | |
| Accept: "application/json", | |
| "Content-Type": "application/x-www-form-urlencoded", | |
| }, | |
| body, | |
| }); | |
| if (!res.ok) { | |
| throw new Error(`GitHub device code failed: HTTP ${res.status}`); | |
| } | |
| const json = parseJsonResponse<DeviceCodeResponse>(await res.json()); | |
| if (!json.device_code || !json.user_code || !json.verification_uri) { | |
| throw new Error("GitHub device code response missing fields"); | |
| } | |
| return json; | |
| } | |
| async function pollForAccessToken(params: { | |
| deviceCode: string; | |
| intervalMs: number; | |
| expiresAt: number; | |
| }): Promise<string> { | |
| const bodyBase = new URLSearchParams({ | |
| client_id: CLIENT_ID, | |
| device_code: params.deviceCode, | |
| grant_type: "urn:ietf:params:oauth:grant-type:device_code", | |
| }); | |
| while (Date.now() < params.expiresAt) { | |
| const res = await fetch(ACCESS_TOKEN_URL, { | |
| method: "POST", | |
| headers: { | |
| Accept: "application/json", | |
| "Content-Type": "application/x-www-form-urlencoded", | |
| }, | |
| body: bodyBase, | |
| }); | |
| if (!res.ok) { | |
| throw new Error(`GitHub device token failed: HTTP ${res.status}`); | |
| } | |
| const json = parseJsonResponse<DeviceTokenResponse>(await res.json()); | |
| if ("access_token" in json && typeof json.access_token === "string") { | |
| return json.access_token; | |
| } | |
| const err = "error" in json ? json.error : "unknown"; | |
| if (err === "authorization_pending") { | |
| await new Promise((r) => setTimeout(r, params.intervalMs)); | |
| continue; | |
| } | |
| if (err === "slow_down") { | |
| await new Promise((r) => setTimeout(r, params.intervalMs + 2000)); | |
| continue; | |
| } | |
| if (err === "expired_token") { | |
| throw new Error("GitHub device code expired; run login again"); | |
| } | |
| if (err === "access_denied") { | |
| throw new Error("GitHub login cancelled"); | |
| } | |
| throw new Error(`GitHub device flow error: ${err}`); | |
| } | |
| throw new Error("GitHub device code expired; run login again"); | |
| } | |
| export async function githubCopilotLoginCommand( | |
| opts: { profileId?: string; yes?: boolean }, | |
| runtime: RuntimeEnv, | |
| ) { | |
| if (!process.stdin.isTTY) { | |
| throw new Error("github-copilot login requires an interactive TTY."); | |
| } | |
| intro(stylePromptTitle("GitHub Copilot login")); | |
| const profileId = opts.profileId?.trim() || "github-copilot:github"; | |
| const store = ensureAuthProfileStore(undefined, { | |
| allowKeychainPrompt: false, | |
| }); | |
| if (store.profiles[profileId] && !opts.yes) { | |
| note( | |
| `Auth profile already exists: ${profileId}\nRe-running will overwrite it.`, | |
| stylePromptTitle("Existing credentials"), | |
| ); | |
| } | |
| const spin = spinner(); | |
| spin.start("Requesting device code from GitHub..."); | |
| const device = await requestDeviceCode({ scope: "read:user" }); | |
| spin.stop("Device code ready"); | |
| note( | |
| [`Visit: ${device.verification_uri}`, `Code: ${device.user_code}`].join("\n"), | |
| stylePromptTitle("Authorize"), | |
| ); | |
| const expiresAt = Date.now() + device.expires_in * 1000; | |
| const intervalMs = Math.max(1000, device.interval * 1000); | |
| const polling = spinner(); | |
| polling.start("Waiting for GitHub authorization..."); | |
| const accessToken = await pollForAccessToken({ | |
| deviceCode: device.device_code, | |
| intervalMs, | |
| expiresAt, | |
| }); | |
| polling.stop("GitHub access token acquired"); | |
| upsertAuthProfile({ | |
| profileId, | |
| credential: { | |
| type: "token", | |
| provider: "github-copilot", | |
| token: accessToken, | |
| // GitHub device flow token doesn't reliably include expiry here. | |
| // Leave expires unset; we'll exchange into Copilot token plus expiry later. | |
| }, | |
| }); | |
| await updateConfig((cfg) => | |
| applyAuthProfileConfig(cfg, { | |
| provider: "github-copilot", | |
| profileId, | |
| mode: "token", | |
| }), | |
| ); | |
| logConfigUpdated(runtime); | |
| runtime.log(`Auth profile: ${profileId} (github-copilot/token)`); | |
| outro("Done"); | |
| } | |