6090508bad
- Add curly braces to all if/else/for/while statements - Fix indentation and trailing spaces - Auto-fixed 372 linting errors using eslint --fix - Remaining issues are warnings only (non-null assertions, explicit any types)
165 lines
4.5 KiB
TypeScript
165 lines
4.5 KiB
TypeScript
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
|
|
import { resolve } from 'path';
|
|
import { homedir } from 'os';
|
|
|
|
const COPILOT_CLIENT_ID = 'Ov23li8tweQw6odWQebz';
|
|
const DEVICE_CODE_URL = 'https://github.com/login/device/code';
|
|
const TOKEN_URL = 'https://github.com/login/oauth/access_token';
|
|
const POLLING_SAFETY_MARGIN_MS = 3000;
|
|
|
|
const AUTH_DIR = resolve(homedir(), '.config/flynn');
|
|
const AUTH_FILE = resolve(AUTH_DIR, 'auth.json');
|
|
|
|
export interface DeviceCodeResponse {
|
|
device_code: string;
|
|
user_code: string;
|
|
verification_uri: string;
|
|
expires_in: number;
|
|
interval: number;
|
|
}
|
|
|
|
interface AuthStore {
|
|
github?: {
|
|
access_token: string;
|
|
created_at: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Request a device code from GitHub to start the OAuth device flow.
|
|
*/
|
|
export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
|
|
const response = await fetch(DEVICE_CODE_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ client_id: COPILOT_CLIENT_ID }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to request device code: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
return response.json() as Promise<DeviceCodeResponse>;
|
|
}
|
|
|
|
/**
|
|
* Poll GitHub for an access token after the user has entered the device code.
|
|
* Blocks until the user authorizes or the code expires.
|
|
*/
|
|
export async function pollForToken(deviceCode: string, interval: number): Promise<string> {
|
|
let currentInterval = interval;
|
|
|
|
while (true) {
|
|
await new Promise(r => setTimeout(r, currentInterval * 1000 + POLLING_SAFETY_MARGIN_MS));
|
|
|
|
const response = await fetch(TOKEN_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
client_id: COPILOT_CLIENT_ID,
|
|
device_code: deviceCode,
|
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
}),
|
|
});
|
|
|
|
const data = await response.json() as Record<string, unknown>;
|
|
|
|
if (data.access_token) {
|
|
return data.access_token as string;
|
|
}
|
|
|
|
if (data.error === 'authorization_pending') {
|
|
continue;
|
|
}
|
|
|
|
if (data.error === 'slow_down') {
|
|
// Add 5 seconds as per GitHub spec
|
|
currentInterval = (data.interval as number) ?? currentInterval + 5;
|
|
continue;
|
|
}
|
|
|
|
if (data.error === 'expired_token') {
|
|
throw new Error('Device code expired. Please try again.');
|
|
}
|
|
|
|
if (data.error === 'access_denied') {
|
|
throw new Error('Authorization was denied by the user.');
|
|
}
|
|
|
|
throw new Error(`OAuth error: ${data.error ?? 'unknown'} - ${data.error_description ?? ''}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load a previously stored GitHub OAuth token from disk.
|
|
* Returns null if no token is stored or the file doesn't exist.
|
|
*/
|
|
export function loadStoredToken(): string | null {
|
|
try {
|
|
const raw = readFileSync(AUTH_FILE, 'utf-8');
|
|
const store = JSON.parse(raw) as AuthStore;
|
|
return store.github?.access_token ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Store a GitHub OAuth token to disk with secure permissions.
|
|
*/
|
|
export function storeToken(token: string): void {
|
|
mkdirSync(AUTH_DIR, { recursive: true });
|
|
|
|
let store: AuthStore = {};
|
|
try {
|
|
const raw = readFileSync(AUTH_FILE, 'utf-8');
|
|
store = JSON.parse(raw) as AuthStore;
|
|
} catch {
|
|
// File doesn't exist yet — start fresh
|
|
}
|
|
|
|
store.github = {
|
|
access_token: token,
|
|
created_at: new Date().toISOString(),
|
|
};
|
|
|
|
writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8');
|
|
chmodSync(AUTH_FILE, 0o600);
|
|
}
|
|
|
|
/**
|
|
* Get a GitHub token from any available source.
|
|
* Priority: GITHUB_TOKEN env var → stored OAuth token → null
|
|
*/
|
|
export function getGitHubToken(): string | null {
|
|
// 1. Environment variable
|
|
const envToken = process.env.GITHUB_TOKEN;
|
|
if (envToken) {return envToken;}
|
|
|
|
// 2. Stored OAuth token
|
|
return loadStoredToken();
|
|
}
|
|
|
|
/**
|
|
* Run the full GitHub OAuth device flow interactively.
|
|
* @param onPrompt Callback to display the user code and verification URL to the user.
|
|
* @returns The access token.
|
|
*/
|
|
export async function loginGitHub(
|
|
onPrompt: (userCode: string, verificationUri: string) => void,
|
|
): Promise<string> {
|
|
const deviceCode = await requestDeviceCode();
|
|
|
|
onPrompt(deviceCode.user_code, deviceCode.verification_uri);
|
|
|
|
const token = await pollForToken(deviceCode.device_code, deviceCode.interval);
|
|
storeToken(token);
|
|
return token;
|
|
}
|