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