/** * GitHub Authentication via gh CLI * * Uses the GitHub CLI (gh) for authentication, which handles: * - Device flow OAuth * - Token storage and refresh * - SSO and enterprise GitHub * - Secure credential storage */ // ============================================================================ // Types // ============================================================================ export interface AuthResult { success: boolean; token?: string; error?: string; user?: string; } // ============================================================================ // Token Management // ============================================================================ /** * Get token from environment or gh CLI */ export function getToken(): string | null { // First check environment variables (highest priority) // Treat empty strings as missing const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null; if (envToken) { return envToken; } // Try to get token from gh CLI (synchronous check not possible, return null) // Use getTokenAsync for full check return null; } /** * Set token in environment (for programmatic use) * This does NOT persist the token - it only sets it for the current session */ export function setToken(token: string): void { process.env.GITHUB_TOKEN = token; } /** * Get token from environment or gh CLI (async version) */ export async function getTokenAsync(): Promise { // First check environment variables const envToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || null; if (envToken) { return envToken; } // Try to get token from gh CLI if (process.env.GITFOREST_IGNORE_GH) return null; try { const result = await Bun.$`gh auth token`.quiet().nothrow(); if (result.exitCode === 0) { const token = result.stdout.toString().trim(); if (token) { // Cache in environment for this session process.env.GITHUB_TOKEN = token; return token; } } } catch { // gh not installed or not logged in } return null; } /** * Check if gh CLI is installed */ export async function isGhInstalled(): Promise { if (process.env.GITFOREST_IGNORE_GH) return false; try { const result = await Bun.$`gh --version`.quiet().nothrow(); return result.exitCode === 0; } catch { return false; } } /** * Check if authenticated (has valid token) */ export function isAuthenticated(): boolean { return getToken() !== null; } /** * Check if authenticated (async - includes gh CLI check) */ export async function isAuthenticatedAsync(): Promise { const token = await getTokenAsync(); return token !== null; } // ============================================================================ // Login / Logout // ============================================================================ /** * Login to GitHub using gh CLI device flow */ export async function login(): Promise { // Check if gh is installed const ghInstalled = await isGhInstalled(); if (!ghInstalled) { return { success: false, error: "GitHub CLI (gh) is not installed. Install it from: https://cli.github.com", }; } try { // Run gh auth login with web flow // This will open browser and handle the OAuth flow const result = await Bun.$`gh auth login --web -h github.com`.nothrow(); if (result.exitCode !== 0) { const stderr = result.stderr.toString(); return { success: false, error: stderr || "Login failed", }; } // Get the token and user info const tokenResult = await Bun.$`gh auth token`.quiet(); const token = tokenResult.stdout.toString().trim(); const userResult = await Bun.$`gh api user --jq .login`.quiet().nothrow(); const user = userResult.exitCode === 0 ? userResult.stdout.toString().trim() : undefined; // Cache token in environment process.env.GITHUB_TOKEN = token; return { success: true, token, user, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Login failed", }; } } /** * Logout from GitHub */ export async function logout(): Promise { // Check if gh is installed const ghInstalled = await isGhInstalled(); if (!ghInstalled) { // Just clear env delete process.env.GITHUB_TOKEN; delete process.env.GH_TOKEN; return { success: true }; } try { await Bun.$`gh auth logout -h github.com`.quiet().nothrow(); delete process.env.GITHUB_TOKEN; delete process.env.GH_TOKEN; return { success: true }; } catch { // Clear env anyway delete process.env.GITHUB_TOKEN; delete process.env.GH_TOKEN; return { success: true }; } } /** * Verify token by calling GitHub API directly */ async function verifyTokenWithApi(token: string): Promise<{ valid: boolean; user?: string }> { try { const response = await fetch("https://api.github.com/user", { headers: { "Accept": "application/vnd.github+json", "Authorization": `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28", }, }); if (response.ok) { const data = await response.json() as { login: string }; return { valid: true, user: data.login }; } return { valid: false }; } catch { return { valid: false }; } } /** * Get current auth status */ export async function getAuthStatus(): Promise<{ authenticated: boolean; user?: string; source?: "env" | "gh"; }> { // Check environment first const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; if (envToken) { // Verify token works using GitHub API directly const result = await verifyTokenWithApi(envToken); if (result.valid) { return { authenticated: true, user: result.user, source: "env", }; } } // Check gh CLI if (process.env.GITFOREST_IGNORE_GH) return { authenticated: false }; try { const statusResult = await Bun.$`gh auth status -h github.com`.quiet().nothrow(); if (statusResult.exitCode === 0) { // Get token from gh const tokenResult = await Bun.$`gh auth token`.quiet().nothrow(); if (tokenResult.exitCode === 0) { const token = tokenResult.stdout.toString().trim(); const result = await verifyTokenWithApi(token); if (result.valid) { // Cache token in environment process.env.GITHUB_TOKEN = token; return { authenticated: true, user: result.user, source: "gh", }; } } } } catch { // Not authenticated via gh } return { authenticated: false }; } // ============================================================================ // Ensure Authenticated // ============================================================================ /** * Ensure authenticated - prompt for login if needed * Returns the token if authenticated, null otherwise */ export async function ensureAuthenticated(silent = false): Promise { // Try to get existing token const token = await getTokenAsync(); if (token) { return token; } if (silent) { return null; } // Check if gh is installed const ghInstalled = await isGhInstalled(); if (!ghInstalled) { console.log("\nGitHub CLI (gh) is not installed."); console.log("Install it from: https://cli.github.com"); console.log("\nOr set GITHUB_TOKEN environment variable manually."); return null; } // Check if we are in an interactive TTY if (!process.stdin.isTTY) { console.log("\nNo GitHub token found. Non-interactive mode: skipping auto-login."); return null; } // Prompt for login console.log("\nNo GitHub token found. Starting authentication...\n"); const result = await login(); if (result.success && result.token) { console.log(`\nLogged in as ${result.user ?? "unknown"}\n`); return result.token; } if (result.error) { console.error(`\nAuthentication failed: ${result.error}\n`); } return null; }