import crypto from 'crypto'; import http from 'http'; import { URL } from 'url'; import chalk from 'chalk'; import { exec } from 'child_process'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import { APP_CONFIG } from './config'; /* c8 ignore start -- Browser callback/login UI is covered by manual/e2e flows; token helpers below are unit-tested. */ /* Stryker disable all: Browser opening is OS integration; token helpers below are mutation-tested. */ // Cross-platform browser opener function openBrowser(url: string): void { const platform = process.platform; let command: string; if (platform === 'darwin') { command = `open "${url}"`; } else if (platform === 'win32') { command = `start "" "${url}"`; } else { // linux command = `xdg-open "${url}"`; } exec(command, (err) => { if (err) { console.log(chalk.yellow(`Unable to open browser automatically. Please visit manually: ${url}`)); } }); } /* Stryker restore all */ const CONFIG_DIR = path.join(os.homedir(), '.pinme'); const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json'); export interface AuthConfig { address: string; token: string; expires_at?: number; user_id?: string; email?: string; } export interface LoginOptions { apiBaseUrl?: string; webBaseUrl?: string; callbackPort?: number; callbackPath?: string; } const DEFAULT_OPTIONS: Required = { apiBaseUrl: APP_CONFIG.pinmeApiBase, webBaseUrl: APP_CONFIG.pinmeWebUrl, callbackPort: 34567, callbackPath: '/cli/callback', }; /* Stryker disable all: Interactive browser login and callback HTML are covered by manual/e2e flows. */ export class WebLoginManager { private config: Required; private server: http.Server | null = null; private resolvePromise: ((value: string) => void) | null = null; private rejectPromise: ((reason: Error) => void) | null = null; private loginToken: string = ''; constructor(options: LoginOptions = {}) { this.config = { ...DEFAULT_OPTIONS, ...options }; } async login(): Promise { console.log(chalk.blue('Starting login flow...\n')); // 1. Generate temporary login token this.loginToken = this.generateLoginToken(); // 2. Start local server to wait for callback console.log(chalk.blue('Starting local callback server...')); await this.startCallbackServer(); try { // 3. Build login URL and open browser const loginUrl = this.buildLoginUrl(); console.log(chalk.blue('Opening browser...')); console.log(chalk.white('If browser does not open automatically, please visit manually:')); console.log(chalk.cyan(` ${loginUrl}\n`)); openBrowser(loginUrl); console.log(chalk.yellow('Please complete login in browser...')); console.log(chalk.gray('Browser will close automatically after successful login.\n')); // 4. Wait for user to complete login const authToken = await this.waitForCallback(); // 5. Parse token const authConfig = this.parseAuthToken(authToken); // 6. Save auth config this.saveAuthConfig(authConfig); console.log(chalk.green('\nLogin successful!')); if (authConfig.email) { console.log(chalk.green(`Welcome, ${authConfig.email}`)); } console.log(chalk.gray(`Address: ${authConfig.address}`)); return authConfig; } catch (error: any) { console.error(chalk.red(`\nLogin failed: ${error.message}`)); throw error; } finally { this.closeServer(); } } private generateLoginToken(): string { return crypto.randomBytes(32).toString('hex'); } private async startCallbackServer(): Promise { return new Promise((resolve, reject) => { this.server = http.createServer(async (req, res) => { try { const url = new URL(req.url || '', `http://localhost:${this.config.callbackPort}`); if (url.pathname === this.config.callbackPath) { const authToken = url.searchParams.get('token'); const error = url.searchParams.get('error'); const loginToken = url.searchParams.get('login_token'); if (error) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(this.getErrorHtml(error)); if (this.rejectPromise) { this.rejectPromise(new Error(error)); } return; } if (!authToken) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(this.getErrorHtml('Auth token not received')); if (this.rejectPromise) { this.rejectPromise(new Error('Auth token not received')); } return; } // Verify loginToken if (loginToken !== this.loginToken) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(this.getErrorHtml('Login token invalid or expired')); if (this.rejectPromise) { this.rejectPromise(new Error('Login token invalid or expired')); } return; } // Return success page res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(this.getSuccessHtml()); // Pass token if (this.resolvePromise) { this.resolvePromise(authToken); } } else { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); } } catch (err: any) { console.error('Callback error:', err); res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Internal Server Error'); } }); this.server.on('error', (err) => { reject(err); }); this.server.listen(this.config.callbackPort, '127.0.0.1', () => { console.log(chalk.gray(`Local server: http://localhost:${this.config.callbackPort}`)); resolve(); }); // Set timeout (10 minutes) setTimeout(() => { if (this.rejectPromise) { this.rejectPromise(new Error('Login timeout, please try again')); } this.closeServer(); }, 10 * 60 * 1000); }); } private buildLoginUrl(): string { const callbackUrl = `http://localhost:${this.config.callbackPort}${this.config.callbackPath}`; const url = `${this.config.webBaseUrl}/#/cli-login?` + `login_token=${this.loginToken}&` + `callback_url=${encodeURIComponent(callbackUrl)}`; return url; } private waitForCallback(): Promise { return new Promise((resolve, reject) => { this.resolvePromise = resolve; this.rejectPromise = reject; }); } private parseAuthToken(authToken: string): AuthConfig { // authToken format: "
-" const firstDash = authToken.indexOf('-'); if (firstDash <= 0 || firstDash === authToken.length - 1) { throw new Error('Invalid token format'); } const address = authToken.slice(0, firstDash).trim(); const token = authToken.slice(firstDash + 1).trim(); if (!address || !token) { throw new Error('Token parsing failed: address or token is empty'); } return { address, token }; } private saveAuthConfig(config: AuthConfig): void { fs.ensureDirSync(CONFIG_DIR); fs.writeJsonSync(AUTH_FILE, config, { spaces: 2 }); // Set file permissions (only current user can read) fs.chmodSync(AUTH_FILE, 0o600); } private closeServer(): void { if (this.server) { this.server.close(); this.server = null; } } // HTML templates private getSuccessHtml(): string { return ` Login Success - PinMe
🎉

Welcome to PinMe

You are now logged in! 🚀
Return to your terminal to continue.

`; } private getErrorHtml(error: string): string { const encodedError = encodeURIComponent(error); return ` Login Failed - PinMe
😵

Oops!

${error}
`; } } // Export singleton export const webLoginManager = new WebLoginManager(); /* Stryker restore all */ /* c8 ignore stop */ // Legacy interface export function setAuthToken(combined: string): AuthConfig { const firstDash = combined.indexOf('-'); if (firstDash <= 0 || firstDash === combined.length - 1) { throw new Error('Invalid token format. Expected "
-".'); } const address = combined.slice(0, firstDash).trim(); const token = combined.slice(firstDash + 1).trim(); if (!address || !token) { throw new Error('Invalid token content. Address or token is empty.'); } const config: AuthConfig = { address, token }; fs.ensureDirSync(CONFIG_DIR); fs.writeJsonSync(AUTH_FILE, config, { spaces: 2 }); return config; } export function getAuthConfig(): AuthConfig | null { try { if (!fs.existsSync(AUTH_FILE)) return null; const data = fs.readJsonSync(AUTH_FILE) as AuthConfig; if (!data?.address || !data?.token) return null; return data; } catch { return null; } } export function clearAuthToken(): void { try { if (fs.existsSync(AUTH_FILE)) { fs.removeSync(AUTH_FILE); } } catch (error) { console.error(`Failed to clear auth token: ${error}`); } } export function getAuthHeaders(): Record { const conf = getAuthConfig(); if (!conf) { throw new Error('Auth not set. Run: pinme login'); } return { 'token-address': conf.address, 'authentication-tokens': conf.token, }; } export async function login(): Promise { return webLoginManager.login(); } export async function logout(): Promise { clearAuthToken(); console.log(chalk.green('Logged out successfully')); }