ever-brain / packages /cli /src /services /auth-service.ts
AlexKurian's picture
feat: complete Ever-Brain engine, MCP server, CLI evb mcp command, Dockerfile and release workflow
61b1da1
Raw History Blame Contribute Delete
5.72 kB
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import prompts from 'prompts';
import { GLOBAL_EVB_DIR, GLOBAL_AUTH_FILE, AUTH_FILE, EVB_DIR } from '../config/constants.js';
export interface AuthProfile {
username?: string;
token?: string;
sourceType: 'pat' | 'gh_cli' | 'env';
savedAt: string;
}
export class AuthService {
detectGhCliToken(): string | null {
try {
const output = execSync('gh auth token', {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
}).trim();
return output || null;
} catch {
return null;
}
}
getStoredToken(workspaceRoot?: string): string | null {
// 1. Check workspace-local .evb/auth.yaml
if (workspaceRoot) {
const localAuthPath = path.join(workspaceRoot, EVB_DIR, AUTH_FILE);
if (fs.existsSync(localAuthPath)) {
try {
const content = fs.readFileSync(localAuthPath, 'utf8');
const match = content.match(/token:\s*["']?([^"'\r\n]+)["']?/);
if (match && match[1]) return match[1].trim();
} catch {}
}
}
// 2. Check global ~/.evb/auth.json
if (fs.existsSync(GLOBAL_AUTH_FILE)) {
try {
const json = JSON.parse(fs.readFileSync(GLOBAL_AUTH_FILE, 'utf8')) as AuthProfile;
if (json.token) return json.token.trim();
} catch {}
}
return null;
}
resolveToken(explicitToken?: string, workspaceRoot?: string): string | null {
if (explicitToken && explicitToken.trim()) {
return explicitToken.trim();
}
if (process.env.EVB_GITHUB_TOKEN && process.env.EVB_GITHUB_TOKEN.trim()) {
return process.env.EVB_GITHUB_TOKEN.trim();
}
if (process.env.EVER_BRAIN_GITHUB_TOKEN && process.env.EVER_BRAIN_GITHUB_TOKEN.trim()) {
return process.env.EVER_BRAIN_GITHUB_TOKEN.trim();
}
if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN.trim()) {
return process.env.GITHUB_TOKEN.trim();
}
return this.getStoredToken(workspaceRoot);
}
saveGlobalToken(token: string, username?: string, sourceType: 'pat' | 'gh_cli' = 'pat'): void {
if (!fs.existsSync(GLOBAL_EVB_DIR)) {
fs.mkdirSync(GLOBAL_EVB_DIR, { recursive: true });
}
const profile: AuthProfile = {
username,
token,
sourceType,
savedAt: new Date().toISOString(),
};
fs.writeFileSync(GLOBAL_AUTH_FILE, JSON.stringify(profile, null, 2), 'utf8');
}
clearGlobalToken(): void {
if (fs.existsSync(GLOBAL_AUTH_FILE)) {
fs.unlinkSync(GLOBAL_AUTH_FILE);
}
}
async promptForAuth(options?: { useGh?: boolean; token?: string }): Promise<{
token: string;
sourceType: 'pat' | 'gh_cli';
}> {
if (options?.token) {
return { token: options.token.trim(), sourceType: 'pat' };
}
const ghToken = this.detectGhCliToken();
if (options?.useGh && ghToken) {
return { token: ghToken, sourceType: 'gh_cli' };
}
// If GitHub CLI is available, explicitly ask the user for their preference
if (ghToken) {
const response = await prompts({
type: 'select',
name: 'choice',
message: 'Detected GitHub CLI (gh). How would you like to authenticate?',
choices: [
{ title: 'Use active GitHub CLI token (gh auth token)', value: 'gh' },
{ title: 'Enter a GitHub Personal Access Token (PAT) manually', value: 'pat' },
],
});
if (response.choice === 'gh') {
return { token: ghToken, sourceType: 'gh_cli' };
}
}
// Prompt for manual PAT
const patResponse = await prompts({
type: 'password',
name: 'pat',
message: 'Enter your GitHub Personal Access Token (PAT):',
validate: (value) => (value && value.trim().length > 0 ? true : 'Token cannot be empty'),
});
if (!patResponse.pat) {
throw new Error('Authentication cancelled.');
}
return { token: patResponse.pat.trim(), sourceType: 'pat' };
}
async validateToken(token: string): Promise<{ username: string; scopes: string[] }> {
const res = await fetch('https://api.github.com/user', {
headers: {
'User-Agent': 'Ever-Brain-CLI/1.0',
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
throw new Error(`Invalid GitHub token (status ${res.status}). Ensure your PAT is valid.`);
}
const scopesHeader = res.headers.get('x-oauth-scopes') || '';
const scopes = scopesHeader.split(',').map((s) => s.trim()).filter(Boolean);
const data = (await res.json()) as { login: string };
return {
username: data.login,
scopes,
};
}
async getRepoPermission(
owner: string,
repo: string,
username: string,
token: string
): Promise<'admin' | 'write' | 'read' | 'none'> {
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}/permission`;
const res = await fetch(url, {
headers: {
'User-Agent': 'Ever-Brain-CLI/1.0',
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (res.status === 404) {
return 'none';
}
if (!res.ok) {
return 'none';
}
const data = (await res.json()) as { permission?: string };
if (data.permission === 'admin') return 'admin';
if (data.permission === 'write' || data.permission === 'push') return 'write';
if (data.permission === 'read' || data.permission === 'pull') return 'read';
return 'none';
}
}
export const authService = new AuthService();