UNPKG

@agentled/cli

Version:

CLI for Agentled — manage workflows, apps, and knowledge from the command line. Zero context-window cost for AI agents.

176 lines 7.75 kB
/** * Browser-based authentication for `agentled auth login`. * * 1. Starts a temporary HTTP server on localhost * 2. Opens the browser to /cli/authorize?port=...&state=... * 3. Waits for the callback with a signed JWT code * 4. Exchanges the code for the raw API key via /api/cli/exchange * * Uses only Node.js built-ins (http, crypto, child_process). */ /* eslint-disable no-console */ import * as http from 'node:http'; import * as crypto from 'node:crypto'; import { spawn } from 'node:child_process'; import * as net from 'node:net'; const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const PORT_RANGE_START = 9876; const PORT_RANGE_END = 9999; /** * Find an available port in the range. */ function findFreePort(start, end) { return new Promise((resolve, reject) => { const tryPort = (port) => { if (port > end) { reject(new Error(`No free port found in range ${start}-${end}`)); return; } const server = net.createServer(); server.once('error', () => tryPort(port + 1)); server.once('listening', () => { server.close(() => resolve(port)); }); server.listen(port, '127.0.0.1'); }; tryPort(start); }); } /** * Open a URL in the user's default browser. * Uses spawn with argument arrays to avoid shell injection. */ function openBrowser(targetUrl) { const platform = process.platform; let cmd; let args; if (platform === 'darwin') { cmd = 'open'; args = [targetUrl]; } else if (platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '', targetUrl]; } else { cmd = 'xdg-open'; args = [targetUrl]; } const child = spawn(cmd, args, { stdio: 'ignore', detached: true }); child.unref(); child.on('error', () => { // Silently fail — the URL is printed to the terminal as fallback }); } export const SUCCESS_HTML = `<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>Agentled CLI</title></head> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f9fafb;"> <div style="text-align: center; padding: 2rem;"> <div style="width: 48px; height: 48px; margin: 0 auto 1rem; background: #d1fae5; border-radius: 50%; display: flex; align-items: center; justify-content: center;"> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="#059669" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg> </div> <h1 style="font-size: 1.25rem; font-weight: 600; color: #111827; margin: 0 0 0.5rem;">Connected</h1> <p style="color: #6b7280; font-size: 0.875rem; margin: 0 0 1rem;">You can close this tab and go back to your terminal &mdash; the CLI is connected and ready to use.</p> <div style="background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 6px; padding: 0.75rem; text-align: left; max-width: 360px; margin: 0 auto; font-size: 0.8125rem; color: #374151;"> <strong style="color: #111827; display: block; margin-bottom: 0.25rem;">Next step</strong> Return to your command line &mdash; your CLI session is now authenticated. Run <code style="background:#e5e7eb;padding:1px 4px;border-radius:3px;font-family:ui-monospace,Menlo,monospace;">agentled auth status</code> to confirm.<br><br><strong style="color:#111827;">Then restart your MCP client</strong> (Claude Code / Codex / Cursor) so it picks up this workspace &mdash; until you do, MCP tools stay on the previous workspace. </div> </div> </body> </html>`; function escapeHtml(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); } const ERROR_HTML = (msg) => `<!DOCTYPE html> <html> <head><meta charset="utf-8"><title>Agentled CLI</title></head> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f9fafb;"> <div style="text-align: center; padding: 2rem;"> <h1 style="font-size: 1.25rem; font-weight: 600; color: #991b1b;">Authentication Failed</h1> <p style="color: #6b7280; font-size: 0.875rem;">${escapeHtml(msg)}</p> </div> </body> </html>`; /** * Run the browser-based login flow. */ export async function browserLogin(baseUrl) { const state = crypto.randomBytes(16).toString('hex'); const port = await findFreePort(PORT_RANGE_START, PORT_RANGE_END); return new Promise((resolve, reject) => { let settled = false; const server = http.createServer(async (req, res) => { if (settled) return; const parsed = new URL(req.url || '', `http://127.0.0.1:${port}`); if (parsed.pathname !== '/callback') { res.writeHead(404); res.end('Not found'); return; } const callbackState = parsed.searchParams.get('state') || ''; const code = parsed.searchParams.get('code') || ''; // Verify state (CSRF protection) if (callbackState !== state) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(ERROR_HTML('State mismatch. Possible CSRF attack.')); return; } if (!code) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(ERROR_HTML('No authorization code received.')); return; } // Serve success page immediately res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(SUCCESS_HTML); // Exchange code for API key try { const exchangeRes = await fetch(`${baseUrl}/api/cli/exchange`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }), }); if (!exchangeRes.ok) { const data = await exchangeRes.json().catch(() => ({})); throw new Error(data.error || `HTTP ${exchangeRes.status}`); } const result = await exchangeRes.json(); settled = true; cleanup(); resolve(result); } catch (err) { settled = true; cleanup(); reject(new Error(`Code exchange failed: ${err.message}`)); } }); // Timeout handler const timer = setTimeout(() => { if (!settled) { settled = true; cleanup(); reject(new Error('Login timed out. No response from browser within 5 minutes.')); } }, TIMEOUT_MS); function cleanup() { clearTimeout(timer); server.close(); } server.listen(port, '127.0.0.1', () => { const authorizeUrl = `${baseUrl}/en/cli/authorize?port=${port}&state=${encodeURIComponent(state)}`; console.error(`Opening browser to authenticate...`); console.error(`If the browser doesn't open, visit:\n ${authorizeUrl}\n`); openBrowser(authorizeUrl); }); server.on('error', (err) => { if (!settled) { settled = true; reject(new Error(`Failed to start local server: ${err.message}`)); } }); }); } //# sourceMappingURL=browser-auth.js.map