|
| 1 | +import http from 'node:http' |
| 2 | +import pc from 'picocolors' |
| 3 | +import open from 'open' |
| 4 | +import { getBaseUrl, post } from '../lib/api.js' |
| 5 | +import { saveCredentials, saveConfig, type Credentials } from '../lib/credentials.js' |
| 6 | + |
| 7 | +const CALLBACK_PORT = 9876 |
| 8 | + |
| 9 | +export async function loginCommand(options: { url?: string }) { |
| 10 | + const baseUrl = getBaseUrl(options.url) |
| 11 | + |
| 12 | + console.log(pc.dim(`Server: ${baseUrl}`)) |
| 13 | + |
| 14 | + // 1. Get the WorkOS auth URL from the server |
| 15 | + let authRes: Response |
| 16 | + try { |
| 17 | + authRes = await fetch( |
| 18 | + `${baseUrl}/auth/url?redirect_uri=${encodeURIComponent(`http://localhost:${CALLBACK_PORT}/callback`)}`, |
| 19 | + ) |
| 20 | + } catch { |
| 21 | + console.error(pc.red(`Cannot reach server at ${baseUrl}`)) |
| 22 | + console.error(pc.dim('Check the URL and ensure the server is running.')) |
| 23 | + process.exit(1) |
| 24 | + return |
| 25 | + } |
| 26 | + if (!authRes.ok) { |
| 27 | + console.error(pc.red(`Failed to get auth URL: ${authRes.status} ${await authRes.text()}`)) |
| 28 | + process.exit(1) |
| 29 | + } |
| 30 | + const { url: authUrl } = (await authRes.json()) as { url: string } |
| 31 | + |
| 32 | + // 2. Start local callback server |
| 33 | + const { code, state } = await captureCallback(authUrl) |
| 34 | + |
| 35 | + // 3. Exchange code for tokens via the server |
| 36 | + console.log(pc.dim('Exchanging code for tokens...')) |
| 37 | + const callbackRes = await post('/auth/callback', { code, state }, baseUrl) |
| 38 | + if (!callbackRes.ok) { |
| 39 | + const body = await callbackRes.text() |
| 40 | + console.error(pc.red(`Authentication failed: ${body}`)) |
| 41 | + process.exit(1) |
| 42 | + } |
| 43 | + |
| 44 | + const data = (await callbackRes.json()) as Credentials |
| 45 | + |
| 46 | + // 4. Save credentials and config |
| 47 | + saveCredentials(data) |
| 48 | + saveConfig({ url: baseUrl }) |
| 49 | + |
| 50 | + console.log() |
| 51 | + console.log(pc.green('✓ Logged in successfully')) |
| 52 | + console.log(` ${pc.bold(data.user.email)} (${data.org.name})`) |
| 53 | + console.log(` Role: ${data.user.role}`) |
| 54 | + console.log() |
| 55 | +} |
| 56 | + |
| 57 | +function captureCallback(authUrl: string): Promise<{ code: string; state: string }> { |
| 58 | + return new Promise((resolve, reject) => { |
| 59 | + const server = http.createServer((req, res) => { |
| 60 | + const url = new URL(req.url ?? '/', `http://localhost:${CALLBACK_PORT}`) |
| 61 | + |
| 62 | + if (url.pathname !== '/callback') { |
| 63 | + res.writeHead(404) |
| 64 | + res.end('Not found') |
| 65 | + return |
| 66 | + } |
| 67 | + |
| 68 | + const code = url.searchParams.get('code') |
| 69 | + const state = url.searchParams.get('state') |
| 70 | + |
| 71 | + if (!code) { |
| 72 | + const error = url.searchParams.get('error') ?? 'No code received' |
| 73 | + res.writeHead(400, { 'Content-Type': 'text/html' }) |
| 74 | + res.end(errorPage(error)) |
| 75 | + server.close() |
| 76 | + reject(new Error(error)) |
| 77 | + return |
| 78 | + } |
| 79 | + |
| 80 | + res.writeHead(200, { 'Content-Type': 'text/html' }) |
| 81 | + res.end(successPage()) |
| 82 | + server.close() |
| 83 | + resolve({ code, state: state ?? '' }) |
| 84 | + }) |
| 85 | + |
| 86 | + server.listen(CALLBACK_PORT, () => { |
| 87 | + console.log(pc.dim(`Listening on http://localhost:${CALLBACK_PORT}`)) |
| 88 | + console.log('Opening browser for authentication...') |
| 89 | + console.log() |
| 90 | + open(authUrl) |
| 91 | + }) |
| 92 | + |
| 93 | + server.on('error', (err) => { |
| 94 | + if ((err as NodeJS.ErrnoException).code === 'EADDRINUSE') { |
| 95 | + reject(new Error(`Port ${CALLBACK_PORT} is in use. Close the process using it and try again.`)) |
| 96 | + } else { |
| 97 | + reject(err) |
| 98 | + } |
| 99 | + }) |
| 100 | + |
| 101 | + // Timeout after 5 minutes |
| 102 | + setTimeout(() => { |
| 103 | + server.close() |
| 104 | + reject(new Error('Login timed out after 5 minutes')) |
| 105 | + }, 5 * 60 * 1000) |
| 106 | + }) |
| 107 | +} |
| 108 | + |
| 109 | +function successPage(): string { |
| 110 | + return `<!DOCTYPE html> |
| 111 | +<html><head><title>AgentOps</title></head> |
| 112 | +<body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0a0a0a;color:#fafafa"> |
| 113 | +<div style="text-align:center"> |
| 114 | +<h1>Authenticated</h1> |
| 115 | +<p>You can close this tab and return to the terminal.</p> |
| 116 | +</div> |
| 117 | +</body></html>` |
| 118 | +} |
| 119 | + |
| 120 | +function errorPage(error: string): string { |
| 121 | + return `<!DOCTYPE html> |
| 122 | +<html><head><title>AgentOps</title></head> |
| 123 | +<body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#0a0a0a;color:#fafafa"> |
| 124 | +<div style="text-align:center"> |
| 125 | +<h1>Authentication Failed</h1> |
| 126 | +<p>${error}</p> |
| 127 | +</div> |
| 128 | +</body></html>` |
| 129 | +} |
0 commit comments