mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
- add hasTokenFile() for fast auth check without subprocess - capture device code from copilot-api auth output - export AuthFlowResult type with deviceCode and verificationUrl - echo auth output to terminal while capturing for UI
207 lines
5.3 KiB
TypeScript
207 lines
5.3 KiB
TypeScript
/**
|
|
* Copilot Auth Handler
|
|
*
|
|
* Handles GitHub OAuth authentication for copilot-api.
|
|
* Uses local installation from ~/.ccs/copilot/ (managed by copilot-package-manager).
|
|
*/
|
|
|
|
import { spawn } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { CopilotAuthStatus, CopilotDebugInfo } from './types';
|
|
import {
|
|
isCopilotApiInstalled as checkInstalled,
|
|
getCopilotApiBinPath,
|
|
} from './copilot-package-manager';
|
|
|
|
/**
|
|
* Get the path to copilot-api's GitHub token file.
|
|
* copilot-api stores tokens in ~/.local/share/copilot-api/github_token (Linux/macOS)
|
|
* or %APPDATA%/copilot-api/github_token (Windows)
|
|
*/
|
|
export function getTokenPath(): string {
|
|
if (process.platform === 'win32') {
|
|
return path.join(process.env.APPDATA || os.homedir(), 'copilot-api', 'github_token');
|
|
}
|
|
return path.join(os.homedir(), '.local', 'share', 'copilot-api', 'github_token');
|
|
}
|
|
|
|
/**
|
|
* Check if GitHub token file exists.
|
|
* Fast check that doesn't require spawning copilot-api process.
|
|
*/
|
|
export function hasTokenFile(): boolean {
|
|
try {
|
|
return fs.existsSync(getTokenPath());
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if copilot-api is installed locally.
|
|
* Uses copilot-package-manager to check ~/.ccs/copilot/node_modules/.bin/copilot-api
|
|
*/
|
|
export function isCopilotApiInstalled(): boolean {
|
|
return checkInstalled();
|
|
}
|
|
|
|
/**
|
|
* Get copilot-api debug info.
|
|
* Returns authentication status and version info.
|
|
*/
|
|
export async function getCopilotDebugInfo(): Promise<CopilotDebugInfo | null> {
|
|
const binPath = getCopilotApiBinPath();
|
|
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const proc = spawn(binPath, ['debug', '--json'], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
shell: process.platform === 'win32',
|
|
timeout: 15000,
|
|
});
|
|
|
|
let stdout = '';
|
|
let _stderr = '';
|
|
|
|
proc.stdout.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
proc.stderr.on('data', (data) => {
|
|
_stderr += data.toString();
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
if (code === 0 && stdout) {
|
|
try {
|
|
const info = JSON.parse(stdout.trim()) as CopilotDebugInfo;
|
|
resolve(info);
|
|
} catch {
|
|
resolve(null);
|
|
}
|
|
} else {
|
|
resolve(null);
|
|
}
|
|
});
|
|
|
|
proc.on('error', () => {
|
|
resolve(null);
|
|
});
|
|
|
|
// Timeout after 15 seconds
|
|
setTimeout(() => {
|
|
proc.kill();
|
|
resolve(null);
|
|
}, 15000);
|
|
} catch {
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check copilot authentication status.
|
|
* Uses fast token file check first, falls back to copilot-api debug if needed.
|
|
*/
|
|
export async function checkAuthStatus(): Promise<CopilotAuthStatus> {
|
|
// Fast path: check if token file exists (instant, no subprocess)
|
|
if (hasTokenFile()) {
|
|
return { authenticated: true };
|
|
}
|
|
|
|
// Slow path: try copilot-api debug --json (may timeout)
|
|
// Only used if token file doesn't exist
|
|
const debugInfo = await getCopilotDebugInfo();
|
|
|
|
if (debugInfo?.authenticated) {
|
|
return { authenticated: true };
|
|
}
|
|
|
|
return {
|
|
authenticated: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Auth flow result with optional device code info.
|
|
*/
|
|
export interface AuthFlowResult {
|
|
success: boolean;
|
|
error?: string;
|
|
/** Device code for user to enter at GitHub */
|
|
deviceCode?: string;
|
|
/** URL where user enters the device code */
|
|
verificationUrl?: string;
|
|
}
|
|
|
|
/**
|
|
* Start GitHub OAuth authentication flow.
|
|
* Captures device code from copilot-api output and returns it.
|
|
*
|
|
* @returns Promise that resolves with auth result including device code
|
|
*/
|
|
export function startAuthFlow(): Promise<AuthFlowResult> {
|
|
const binPath = getCopilotApiBinPath();
|
|
|
|
return new Promise((resolve) => {
|
|
console.log('[i] Starting GitHub authentication for Copilot...');
|
|
console.log('');
|
|
|
|
const proc = spawn(binPath, ['auth'], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
shell: process.platform === 'win32',
|
|
});
|
|
|
|
let stdout = '';
|
|
let deviceCode: string | undefined;
|
|
let verificationUrl: string | undefined;
|
|
|
|
proc.stdout.on('data', (data) => {
|
|
const chunk = data.toString();
|
|
stdout += chunk;
|
|
|
|
// Echo to console for terminal users
|
|
process.stdout.write(chunk);
|
|
|
|
// Parse device code from output like:
|
|
// "Please enter the code "5653-38A1" in https://github.com/login/device"
|
|
const codeMatch = stdout.match(/code\s+"([A-Z0-9]{4}-[A-Z0-9]{4})"/i);
|
|
const urlMatch = stdout.match(/(https:\/\/github\.com\/login\/device)/i);
|
|
|
|
if (codeMatch && !deviceCode) {
|
|
deviceCode = codeMatch[1];
|
|
}
|
|
if (urlMatch && !verificationUrl) {
|
|
verificationUrl = urlMatch[1];
|
|
}
|
|
});
|
|
|
|
proc.stderr.on('data', (data) => {
|
|
// Echo stderr to console
|
|
process.stderr.write(data.toString());
|
|
});
|
|
|
|
proc.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve({ success: true, deviceCode, verificationUrl });
|
|
} else {
|
|
resolve({
|
|
success: false,
|
|
error: `Authentication failed with exit code ${code}`,
|
|
deviceCode,
|
|
verificationUrl,
|
|
});
|
|
}
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
resolve({
|
|
success: false,
|
|
error: `Failed to start auth: ${err.message}`,
|
|
});
|
|
});
|
|
});
|
|
}
|