@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
631 lines (630 loc) • 26.2 kB
JavaScript
/**
* Credential Detection Service
* Detects existing AI provider credentials from various sources on the machine
*
* Supported sources:
* - Environment variables
* - Gemini CLI (~/.gemini/oauth_creds.json, ~/.gemini/settings.json)
* - Claude Code (macOS Keychain, ~/.claude/.credentials.json)
* - GitHub Copilot (~/.config/github-copilot/hosts.json)
* - .env files (workspace and global)
*/
import { exec } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { promisify } from 'node:util';
const execAsync = promisify(exec);
/**
* Credential source types
*/
export const CredentialSource = {
ENV_VAR: 'environment_variable',
ENV_FILE: 'env_file',
GEMINI_CLI: 'gemini_cli',
CLAUDE_CODE: 'claude_code',
GITHUB_COPILOT: 'github_copilot',
MACOS_KEYCHAIN: 'macos_keychain',
};
/**
* Authentication types
*/
export const AuthType = {
API_KEY: 'api_key',
OAUTH_TOKEN: 'oauth_token',
SERVICE_ACCOUNT: 'service_account',
};
/**
* Detected credential structure
* @typedef {Object} DetectedCredential
* @property {string} provider - Provider name (openai, anthropic, google, etc.)
* @property {string} source - Source of the credential (env_var, gemini_cli, etc.)
* @property {string} authType - Type of authentication (api_key, oauth_token, etc.)
* @property {string} value - The credential value (may be redacted for display)
* @property {string} [rawValue] - The actual credential value (only in secure contexts)
* @property {string} [path] - Path to the credential file if applicable
* @property {Date} [expiresAt] - Expiration time for OAuth tokens
* @property {boolean} [isValid] - Whether the credential appears valid
* @property {Object} [metadata] - Additional metadata
*/
export class CredentialDetectionService {
constructor(options = {}) {
this.homeDir = os.homedir();
this.platform = process.platform;
this.options = {
includeRawValues: false,
detectEnvVars: true,
detectEnvFiles: true,
detectGeminiCli: true,
detectClaudeCode: true,
detectGitHubCopilot: true,
...options,
};
}
/**
* Detect all available credentials from all sources
* @returns {Promise<DetectedCredential[]>}
*/
async detectAll() {
const credentials = [];
if (this.options.detectEnvVars) {
credentials.push(...this.detectFromEnvironmentVariables());
}
if (this.options.detectEnvFiles) {
credentials.push(...this.detectFromEnvFiles());
}
if (this.options.detectGeminiCli) {
credentials.push(...(await this.detectFromGeminiCli()));
}
if (this.options.detectClaudeCode) {
credentials.push(...(await this.detectFromClaudeCode()));
}
if (this.options.detectGitHubCopilot) {
credentials.push(...(await this.detectFromGitHubCopilot()));
}
// Deduplicate credentials, preferring OAuth over API keys
return this.deduplicateCredentials(credentials);
}
/**
* Detect credentials for a specific provider
* @param {string} provider - Provider name
* @returns {Promise<DetectedCredential[]>}
*/
async detectForProvider(provider) {
const allCredentials = await this.detectAll();
return allCredentials.filter((c) => c.provider === provider);
}
/**
* Detect credentials from environment variables
* @returns {DetectedCredential[]}
*/
detectFromEnvironmentVariables() {
const credentials = [];
const envMappings = [
{ env: 'OPENAI_API_KEY', provider: 'openai' },
{ env: 'ANTHROPIC_API_KEY', provider: 'anthropic' },
{ env: 'GOOGLE_API_KEY', provider: 'google' },
{ env: 'GEMINI_API_KEY', provider: 'google' },
{ env: 'AZURE_OPENAI_KEY', provider: 'azure' },
{ env: 'HUGGINGFACE_API_KEY', provider: 'huggingface' },
{ env: 'AWS_ACCESS_KEY_ID', provider: 'bedrock' },
{ env: 'AI_GATEWAY_API_KEY', provider: 'vercel-gateway' },
];
for (const mapping of envMappings) {
const value = process.env[mapping.env];
if (value && value.trim()) {
credentials.push({
provider: mapping.provider,
source: CredentialSource.ENV_VAR,
authType: AuthType.API_KEY,
value: this.redactValue(value),
rawValue: this.options.includeRawValues ? value : undefined,
isValid: this.validateApiKeyFormat(value, mapping.provider),
metadata: {
envVar: mapping.env,
},
});
}
}
return credentials;
}
/**
* Detect credentials from .env files
* @returns {DetectedCredential[]}
*/
detectFromEnvFiles() {
const credentials = [];
const envFilePaths = [
// Workspace paths
path.join(process.cwd(), '.env.local'),
path.join(process.cwd(), '.env'),
// Global paths
path.join(this.homeDir, '.env'),
path.join(this.homeDir, '.config', 'ai-changelog', '.env'),
path.join(this.homeDir, '.config', 'ai-changelog', '.env.local'),
];
const keyMappings = {
OPENAI_API_KEY: 'openai',
ANTHROPIC_API_KEY: 'anthropic',
GOOGLE_API_KEY: 'google',
GEMINI_API_KEY: 'google',
AZURE_OPENAI_KEY: 'azure',
HUGGINGFACE_API_KEY: 'huggingface',
AWS_ACCESS_KEY_ID: 'bedrock',
AI_GATEWAY_API_KEY: 'vercel-gateway',
};
for (const envFilePath of envFilePaths) {
if (!fs.existsSync(envFilePath))
continue;
try {
const content = fs.readFileSync(envFilePath, 'utf8');
const vars = this.parseEnvFile(content);
for (const [key, value] of Object.entries(vars)) {
const provider = keyMappings[key];
if (provider && value) {
credentials.push({
provider,
source: CredentialSource.ENV_FILE,
authType: AuthType.API_KEY,
value: this.redactValue(value),
rawValue: this.options.includeRawValues ? value : undefined,
path: envFilePath,
isValid: this.validateApiKeyFormat(value, provider),
metadata: {
envVar: key,
filePath: envFilePath,
},
});
}
}
}
catch {
// Ignore read errors
}
}
return credentials;
}
/**
* Detect credentials from Gemini CLI
* @returns {Promise<DetectedCredential[]>}
*/
async detectFromGeminiCli() {
const credentials = [];
// Check for OAuth credentials
const oauthCredsPath = path.join(this.homeDir, '.gemini', 'oauth_creds.json');
if (fs.existsSync(oauthCredsPath)) {
try {
const content = fs.readFileSync(oauthCredsPath, 'utf8');
const oauthCreds = JSON.parse(content);
if (oauthCreds.access_token) {
const expiresAt = oauthCreds.expiry_date
? new Date(oauthCreds.expiry_date)
: oauthCreds.expires_in
? new Date(Date.now() + oauthCreds.expires_in * 1000)
: undefined;
credentials.push({
provider: 'google',
source: CredentialSource.GEMINI_CLI,
authType: AuthType.OAUTH_TOKEN,
value: this.redactValue(oauthCreds.access_token),
rawValue: this.options.includeRawValues ? oauthCreds.access_token : undefined,
path: oauthCredsPath,
expiresAt,
isValid: !expiresAt || expiresAt > new Date(),
metadata: {
refreshToken: !!oauthCreds.refresh_token,
scope: oauthCreds.scope,
tokenType: oauthCreds.token_type,
},
});
}
}
catch {
// Ignore parse errors
}
}
// Check for API key in .gemini/.env
const geminiEnvPath = path.join(this.homeDir, '.gemini', '.env');
if (fs.existsSync(geminiEnvPath)) {
try {
const content = fs.readFileSync(geminiEnvPath, 'utf8');
const vars = this.parseEnvFile(content);
if (vars.GEMINI_API_KEY || vars.GOOGLE_API_KEY) {
const apiKey = vars.GEMINI_API_KEY || vars.GOOGLE_API_KEY;
credentials.push({
provider: 'google',
source: CredentialSource.GEMINI_CLI,
authType: AuthType.API_KEY,
value: this.redactValue(apiKey),
rawValue: this.options.includeRawValues ? apiKey : undefined,
path: geminiEnvPath,
isValid: this.validateApiKeyFormat(apiKey, 'google'),
metadata: {
envVar: vars.GEMINI_API_KEY ? 'GEMINI_API_KEY' : 'GOOGLE_API_KEY',
},
});
}
}
catch {
// Ignore parse errors
}
}
// Check settings.json for authentication method
const settingsPath = path.join(this.homeDir, '.gemini', 'settings.json');
if (fs.existsSync(settingsPath)) {
try {
const content = fs.readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(content);
// If settings indicate service account auth
if (settings.auth_type === 'service_account' || settings.google_application_credentials) {
const credPath = settings.google_application_credentials || process.env.GOOGLE_APPLICATION_CREDENTIALS;
if (credPath && fs.existsSync(credPath)) {
credentials.push({
provider: 'google',
source: CredentialSource.GEMINI_CLI,
authType: AuthType.SERVICE_ACCOUNT,
value: `Service Account: ${path.basename(credPath)}`,
path: credPath,
isValid: true,
metadata: {
credentialsPath: credPath,
},
});
}
}
}
catch {
// Ignore parse errors
}
}
return credentials;
}
/**
* Detect credentials from Claude Code
* @returns {Promise<DetectedCredential[]>}
*/
async detectFromClaudeCode() {
const credentials = [];
// macOS: Check Keychain
if (this.platform === 'darwin') {
try {
const { stdout } = await execAsync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null');
if (stdout && stdout.trim()) {
try {
const keychainCreds = JSON.parse(stdout.trim());
if (keychainCreds.claudeAiOauth?.accessToken) {
credentials.push({
provider: 'anthropic',
source: CredentialSource.MACOS_KEYCHAIN,
authType: AuthType.OAUTH_TOKEN,
value: this.redactValue(keychainCreds.claudeAiOauth.accessToken),
rawValue: this.options.includeRawValues
? keychainCreds.claudeAiOauth.accessToken
: undefined,
expiresAt: keychainCreds.claudeAiOauth.expiresAt
? new Date(keychainCreds.claudeAiOauth.expiresAt)
: undefined,
isValid: true,
metadata: {
keychainService: 'Claude Code-credentials',
hasRefreshToken: !!keychainCreds.claudeAiOauth.refreshToken,
},
});
}
}
catch {
// Raw token string, not JSON
credentials.push({
provider: 'anthropic',
source: CredentialSource.MACOS_KEYCHAIN,
authType: AuthType.OAUTH_TOKEN,
value: this.redactValue(stdout.trim()),
rawValue: this.options.includeRawValues ? stdout.trim() : undefined,
isValid: true,
metadata: {
keychainService: 'Claude Code-credentials',
},
});
}
}
}
catch {
// Keychain access failed or item not found
}
}
// Linux/Other: Check ~/.claude/.credentials.json
const claudeCredsPath = path.join(this.homeDir, '.claude', '.credentials.json');
if (fs.existsSync(claudeCredsPath)) {
try {
const content = fs.readFileSync(claudeCredsPath, 'utf8');
const claudeCreds = JSON.parse(content);
if (claudeCreds.claudeAiOauth?.accessToken) {
credentials.push({
provider: 'anthropic',
source: CredentialSource.CLAUDE_CODE,
authType: AuthType.OAUTH_TOKEN,
value: this.redactValue(claudeCreds.claudeAiOauth.accessToken),
rawValue: this.options.includeRawValues
? claudeCreds.claudeAiOauth.accessToken
: undefined,
path: claudeCredsPath,
expiresAt: claudeCreds.claudeAiOauth.expiresAt
? new Date(claudeCreds.claudeAiOauth.expiresAt)
: undefined,
isValid: true,
metadata: {
hasRefreshToken: !!claudeCreds.claudeAiOauth.refreshToken,
},
});
}
// Check for API key in credentials
if (claudeCreds.apiKey) {
credentials.push({
provider: 'anthropic',
source: CredentialSource.CLAUDE_CODE,
authType: AuthType.API_KEY,
value: this.redactValue(claudeCreds.apiKey),
rawValue: this.options.includeRawValues ? claudeCreds.apiKey : undefined,
path: claudeCredsPath,
isValid: this.validateApiKeyFormat(claudeCreds.apiKey, 'anthropic'),
});
}
}
catch {
// Ignore parse errors
}
}
return credentials;
}
/**
* Detect credentials from GitHub Copilot
* @returns {Promise<DetectedCredential[]>}
*/
async detectFromGitHubCopilot() {
const credentials = [];
// Check hosts.json
const hostsPath = path.join(this.homeDir, '.config', 'github-copilot', 'hosts.json');
if (fs.existsSync(hostsPath)) {
try {
const content = fs.readFileSync(hostsPath, 'utf8');
const hosts = JSON.parse(content);
// Check for github.com credentials
const githubCreds = hosts['github.com'];
if (githubCreds?.oauth_token) {
credentials.push({
provider: 'github-copilot',
source: CredentialSource.GITHUB_COPILOT,
authType: AuthType.OAUTH_TOKEN,
value: this.redactValue(githubCreds.oauth_token),
rawValue: this.options.includeRawValues ? githubCreds.oauth_token : undefined,
path: hostsPath,
isValid: githubCreds.oauth_token.startsWith('ghu_'),
metadata: {
user: githubCreds.user,
host: 'github.com',
},
});
}
// Check for GitHub Enterprise credentials
for (const [host, creds] of Object.entries(hosts)) {
if (host !== 'github.com' && creds?.oauth_token) {
credentials.push({
provider: 'github-copilot',
source: CredentialSource.GITHUB_COPILOT,
authType: AuthType.OAUTH_TOKEN,
value: this.redactValue(creds.oauth_token),
rawValue: this.options.includeRawValues ? creds.oauth_token : undefined,
path: hostsPath,
isValid: true,
metadata: {
user: creds.user,
host,
enterprise: true,
},
});
}
}
}
catch {
// Ignore parse errors
}
}
// Check for GitHub CLI authentication (can be used for Copilot)
const ghHostsPath = path.join(this.homeDir, '.config', 'gh', 'hosts.yml');
if (fs.existsSync(ghHostsPath)) {
try {
const content = fs.readFileSync(ghHostsPath, 'utf8');
// Simple YAML parsing for oauth_token
const tokenMatch = content.match(/oauth_token:\s*(.+)/);
const userMatch = content.match(/user:\s*(.+)/);
if (tokenMatch && tokenMatch[1]) {
const token = tokenMatch[1].trim();
credentials.push({
provider: 'github-copilot',
source: CredentialSource.GITHUB_COPILOT,
authType: AuthType.OAUTH_TOKEN,
value: this.redactValue(token),
rawValue: this.options.includeRawValues ? token : undefined,
path: ghHostsPath,
isValid: true,
metadata: {
user: userMatch ? userMatch[1].trim() : undefined,
host: 'github.com',
fromGhCli: true,
},
});
}
}
catch {
// Ignore parse errors
}
}
return credentials;
}
/**
* Parse .env file content
* @param {string} content
* @returns {Object<string, string>}
*/
parseEnvFile(content) {
const vars = {};
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('#') || !trimmed || !trimmed.includes('=')) {
continue;
}
const [key, ...valueParts] = trimmed.split('=');
const value = valueParts.join('=').trim();
const cleanValue = value.replace(/^["']|["']$/g, '');
if (key && cleanValue) {
vars[key.trim()] = cleanValue;
}
}
return vars;
}
/**
* Redact a credential value for display
* @param {string} value
* @returns {string}
*/
redactValue(value) {
if (!value || value.length < 12) {
return '***';
}
return `${value.substring(0, 8)}...${value.substring(value.length - 4)}`;
}
/**
* Validate API key format
* @param {string} value
* @param {string} provider
* @returns {boolean}
*/
validateApiKeyFormat(value, provider) {
if (!value || typeof value !== 'string') {
return false;
}
const patterns = {
openai: /^sk-[a-zA-Z0-9-_]{20,}$/,
anthropic: /^sk-ant-[a-zA-Z0-9-_]{20,}$/,
google: /^AIza[a-zA-Z0-9-_]{35,}$/,
azure: /^[a-f0-9]{32}$/i,
huggingface: /^hf_[a-zA-Z0-9]{34,}$/,
bedrock: /^AKI[A-Z0-9]{17}$/,
// Gateway keys are opaque and not documented as a fixed shape, so only a
// conservative minimum length is enforced.
'vercel-gateway': /^.{20,}$/,
};
const pattern = patterns[provider];
if (!pattern) {
return value.length >= 20; // Generic validation
}
return pattern.test(value);
}
/**
* Deduplicate credentials, preferring OAuth over API keys and newer sources
* @param {DetectedCredential[]} credentials
* @returns {DetectedCredential[]}
*/
deduplicateCredentials(credentials) {
const byProvider = new Map();
// Priority order: OAuth > Service Account > API Key
// Source priority: CLI tools > env files > env vars
const authPriority = {
[AuthType.OAUTH_TOKEN]: 3,
[AuthType.SERVICE_ACCOUNT]: 2,
[AuthType.API_KEY]: 1,
};
const sourcePriority = {
[CredentialSource.GEMINI_CLI]: 5,
[CredentialSource.CLAUDE_CODE]: 5,
[CredentialSource.MACOS_KEYCHAIN]: 5,
[CredentialSource.GITHUB_COPILOT]: 5,
[CredentialSource.ENV_FILE]: 2,
[CredentialSource.ENV_VAR]: 1,
};
for (const cred of credentials) {
const existing = byProvider.get(cred.provider);
if (!existing) {
byProvider.set(cred.provider, cred);
continue;
}
// Compare by auth type priority first
const existingAuthPriority = authPriority[existing.authType] || 0;
const newAuthPriority = authPriority[cred.authType] || 0;
if (newAuthPriority > existingAuthPriority) {
byProvider.set(cred.provider, cred);
continue;
}
if (newAuthPriority === existingAuthPriority) {
// Compare by source priority
const existingSourcePriority = sourcePriority[existing.source] || 0;
const newSourcePriority = sourcePriority[cred.source] || 0;
if (newSourcePriority > existingSourcePriority) {
byProvider.set(cred.provider, cred);
}
}
}
return Array.from(byProvider.values());
}
/**
* Get a summary of detected credentials
* @returns {Promise<Object>}
*/
async getSummary() {
const credentials = await this.detectAll();
return {
total: credentials.length,
byProvider: credentials.reduce((acc, cred) => {
acc[cred.provider] = cred;
return acc;
}, {}),
bySource: credentials.reduce((acc, cred) => {
if (!acc[cred.source])
acc[cred.source] = [];
acc[cred.source].push(cred.provider);
return acc;
}, {}),
providers: credentials.map((c) => c.provider),
sources: [...new Set(credentials.map((c) => c.source))],
};
}
/**
* Get the best credential for a provider
* @param {string} provider
* @returns {Promise<DetectedCredential|null>}
*/
async getBestCredential(provider) {
const credentials = await this.detectForProvider(provider);
if (credentials.length === 0) {
return null;
}
// Credentials are already sorted by priority from deduplication
return credentials[0];
}
/**
* Convert detected credential to config format
* @param {DetectedCredential} credential
* @returns {Object}
*/
toConfigFormat(credential) {
const keyMappings = {
openai: 'OPENAI_API_KEY',
anthropic: 'ANTHROPIC_API_KEY',
google: 'GOOGLE_API_KEY',
azure: 'AZURE_OPENAI_KEY',
huggingface: 'HUGGINGFACE_API_KEY',
bedrock: 'AWS_ACCESS_KEY_ID',
'github-copilot': 'GITHUB_COPILOT_TOKEN',
'vercel-gateway': 'AI_GATEWAY_API_KEY',
};
const configKey = keyMappings[credential.provider] || `${credential.provider.toUpperCase()}_API_KEY`;
return {
[configKey]: credential.rawValue || credential.value,
[`${credential.provider.toUpperCase()}_AUTH_TYPE`]: credential.authType,
[`${credential.provider.toUpperCase()}_AUTH_SOURCE`]: credential.source,
};
}
}
export default CredentialDetectionService;