| e93a978 | | | 1 | import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; |
| e93a978 | | | 2 | |
| e93a978 | | | 3 | interface AuthResult { |
| e93a978 | | | 4 | token: string; |
| e93a978 | | | 5 | } |
| e93a978 | | | 6 | |
| 46c7779 | | | 7 | const STYLES = ` |
| 46c7779 | | | 8 | @import url('https://fonts.googleapis.com/css2?family=Libre+Caslon+Text:ital,wght@0,400;1,400&display=swap'); |
| 46c7779 | | | 9 | :root{--bg:#faf8f5;--card:#f2efe9;--border:#e8e3db;--text:#2c2824;--muted:#7a746c;--accent:#4d8a78;--error:#a05050} |
| 46c7779 | | | 10 | @media(prefers-color-scheme:dark){:root{--bg:#1a1918;--card:#242220;--border:#302e2b;--text:#e8e4df;--muted:#9a948c;--accent:#7aab9c;--error:#cc9292}} |
| 46c7779 | | | 11 | *{margin:0;box-sizing:border-box} |
| 46c7779 | | | 12 | body{font-family:'Libre Caslon Text',Georgia,serif;background:var(--bg);color:var(--text);display:flex;justify-content:center;align-items:center;height:100vh} |
| 46c7779 | | | 13 | .card{text-align:center;padding:2rem;max-width:24rem;width:100%;background:var(--card);border:1px solid var(--border)} |
| 46c7779 | | | 14 | .logo{margin-bottom:1.5rem} |
| 46c7779 | | | 15 | h1{font-size:1.125rem;margin-bottom:0.25rem} |
| 46c7779 | | | 16 | p{font-size:0.875rem;color:var(--muted);margin-top:0.5rem} |
| 46c7779 | | | 17 | `; |
| 46c7779 | | | 18 | |
| 46c7779 | | | 19 | const LOGO = `<svg width="40" height="40" viewBox="-4 -4 72 72" style="color:var(--accent)"> |
| 46c7779 | | | 20 | <path d="M32,58 C32,45 31,35 32,22 C33,14 32,6 32,2" stroke="currentColor" stroke-width="5" fill="none" stroke-linecap="round"/> |
| 46c7779 | | | 21 | <path d="M32,38 Q43,34 52,38" stroke="currentColor" stroke-width="4.5" fill="none" stroke-linecap="round"/> |
| 46c7779 | | | 22 | <path d="M32,20 Q22,15 14,10" stroke="currentColor" stroke-width="4" fill="none" stroke-linecap="round"/> |
| 46c7779 | | | 23 | <circle cx="32" cy="58" r="6" fill="currentColor"/><circle cx="32" cy="38" r="5.5" fill="currentColor"/> |
| 46c7779 | | | 24 | <circle cx="52" cy="38" r="5" fill="currentColor"/><circle cx="32" cy="20" r="5" fill="currentColor"/> |
| 46c7779 | | | 25 | <circle cx="14" cy="10" r="4.5" fill="currentColor"/><circle cx="32" cy="2" r="4.5" fill="currentColor"/> |
| 46c7779 | | | 26 | </svg>`; |
| 46c7779 | | | 27 | |
| e93a978 | | | 28 | const SUCCESS_HTML = `<!DOCTYPE html> |
| 46c7779 | | | 29 | <html><head><title>Grove CLI</title><style>${STYLES}</style></head> |
| 46c7779 | | | 30 | <body><div class="card"><div class="logo">${LOGO}</div> |
| 46c7779 | | | 31 | <h1>CLI Authorized</h1> |
| 46c7779 | | | 32 | <p>You can close this tab and return to the terminal.</p> |
| 46c7779 | | | 33 | </div></body></html>`; |
| e93a978 | | | 34 | |
| e93a978 | | | 35 | const ERROR_HTML = `<!DOCTYPE html> |
| 46c7779 | | | 36 | <html><head><title>Grove CLI</title><style>${STYLES} h1{color:var(--error)}</style></head> |
| 46c7779 | | | 37 | <body><div class="card"><div class="logo">${LOGO}</div> |
| 46c7779 | | | 38 | <h1>Authentication Failed</h1> |
| 46c7779 | | | 39 | <p>Please try again from the terminal.</p> |
| 46c7779 | | | 40 | </div></body></html>`; |
| e93a978 | | | 41 | |
| ff43545 | | | 42 | export async function waitForAuthCallback(): Promise<{ |
| e93a978 | | | 43 | port: number; |
| e93a978 | | | 44 | result: Promise<AuthResult>; |
| e93a978 | | | 45 | close: () => void; |
| ff43545 | | | 46 | }> { |
| e93a978 | | | 47 | let resolveResult: (value: AuthResult) => void; |
| e93a978 | | | 48 | let rejectResult: (reason: Error) => void; |
| e93a978 | | | 49 | |
| e93a978 | | | 50 | const result = new Promise<AuthResult>((resolve, reject) => { |
| e93a978 | | | 51 | resolveResult = resolve; |
| e93a978 | | | 52 | rejectResult = reject; |
| e93a978 | | | 53 | }); |
| e93a978 | | | 54 | |
| e93a978 | | | 55 | const server = createServer((req: IncomingMessage, res: ServerResponse) => { |
| e93a978 | | | 56 | const url = new URL(req.url || "/", `http://localhost`); |
| e93a978 | | | 57 | |
| e93a978 | | | 58 | if (url.pathname === "/callback") { |
| e93a978 | | | 59 | const token = url.searchParams.get("token"); |
| e93a978 | | | 60 | |
| e93a978 | | | 61 | if (token) { |
| e93a978 | | | 62 | res.writeHead(200, { "Content-Type": "text/html" }); |
| e93a978 | | | 63 | res.end(SUCCESS_HTML); |
| e93a978 | | | 64 | resolveResult!({ token }); |
| e93a978 | | | 65 | } else { |
| e93a978 | | | 66 | res.writeHead(400, { "Content-Type": "text/html" }); |
| e93a978 | | | 67 | res.end(ERROR_HTML); |
| e93a978 | | | 68 | rejectResult!(new Error("No token received")); |
| e93a978 | | | 69 | } |
| e93a978 | | | 70 | } else { |
| e93a978 | | | 71 | res.writeHead(404); |
| e93a978 | | | 72 | res.end("Not found"); |
| e93a978 | | | 73 | } |
| e93a978 | | | 74 | }); |
| e93a978 | | | 75 | |
| ff43545 | | | 76 | const port = await new Promise<number>((resolve) => { |
| ff43545 | | | 77 | server.listen(0, "127.0.0.1", () => { |
| ff43545 | | | 78 | const addr = server.address(); |
| ff43545 | | | 79 | resolve(typeof addr === "object" && addr ? addr.port : 0); |
| ff43545 | | | 80 | }); |
| ff43545 | | | 81 | }); |
| e93a978 | | | 82 | |
| e93a978 | | | 83 | const timeout = setTimeout(() => { |
| e93a978 | | | 84 | server.close(); |
| e93a978 | | | 85 | rejectResult!(new Error("Authentication timed out (5 minutes)")); |
| e93a978 | | | 86 | }, 5 * 60 * 1000); |
| e93a978 | | | 87 | |
| e93a978 | | | 88 | const close = () => { |
| e93a978 | | | 89 | clearTimeout(timeout); |
| e93a978 | | | 90 | server.close(); |
| e93a978 | | | 91 | }; |
| e93a978 | | | 92 | |
| e93a978 | | | 93 | result.finally(close); |
| e93a978 | | | 94 | |
| e93a978 | | | 95 | return { port, result, close }; |
| e93a978 | | | 96 | } |