|
| 1 | +import { spawnSync } from "node:child_process" |
| 2 | +import { createServer } from "node:http" |
| 3 | +import type { AddressInfo } from "node:net" |
| 4 | + |
| 5 | +import { DEFAULT_VALUES } from "../../../packages/internal/shared/src/env.common" |
| 6 | +import { CLIError } from "./output" |
| 7 | + |
| 8 | +const LOCAL_CALLBACK_HOST = "127.0.0.1" |
| 9 | +const LOCAL_CALLBACK_PATH = "/callback" |
| 10 | +const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000 |
| 11 | + |
| 12 | +const mappedWebOrigins: Array<{ apiOrigin: string; webOrigin: string }> = [ |
| 13 | + { |
| 14 | + apiOrigin: new URL(DEFAULT_VALUES.PROD.API_URL).origin, |
| 15 | + webOrigin: new URL(DEFAULT_VALUES.PROD.WEB_URL).origin, |
| 16 | + }, |
| 17 | + { |
| 18 | + apiOrigin: new URL(DEFAULT_VALUES.DEV.API_URL).origin, |
| 19 | + webOrigin: new URL(DEFAULT_VALUES.DEV.WEB_URL).origin, |
| 20 | + }, |
| 21 | + { |
| 22 | + apiOrigin: new URL(DEFAULT_VALUES.LOCAL.API_URL).origin, |
| 23 | + webOrigin: new URL(DEFAULT_VALUES.LOCAL.WEB_URL).origin, |
| 24 | + }, |
| 25 | +] |
| 26 | + |
| 27 | +const successPageHtml = `<!doctype html> |
| 28 | +<html lang="en"> |
| 29 | + <head> |
| 30 | + <meta charset="utf-8" /> |
| 31 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 32 | + <title>Folo CLI Login</title> |
| 33 | + </head> |
| 34 | + <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; line-height: 1.5;"> |
| 35 | + <h1>Folo CLI login complete</h1> |
| 36 | + <p>You can close this window and return to your terminal.</p> |
| 37 | + </body> |
| 38 | +</html> |
| 39 | +` |
| 40 | + |
| 41 | +const failurePageHtml = `<!doctype html> |
| 42 | +<html lang="en"> |
| 43 | + <head> |
| 44 | + <meta charset="utf-8" /> |
| 45 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 46 | + <title>Folo CLI Login</title> |
| 47 | + </head> |
| 48 | + <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; line-height: 1.5;"> |
| 49 | + <h1>Folo CLI login failed</h1> |
| 50 | + <p>Missing token in callback URL. Please retry in terminal.</p> |
| 51 | + </body> |
| 52 | +</html> |
| 53 | +` |
| 54 | + |
| 55 | +const getOpenBrowserCommand = (url: string): { command: string; args: string[] } => { |
| 56 | + if (process.platform === "darwin") { |
| 57 | + return { command: "open", args: [url] } |
| 58 | + } |
| 59 | + |
| 60 | + if (process.platform === "win32") { |
| 61 | + return { command: "cmd", args: ["/c", "start", "", url] } |
| 62 | + } |
| 63 | + |
| 64 | + return { command: "xdg-open", args: [url] } |
| 65 | +} |
| 66 | + |
| 67 | +const openBrowser = (url: string) => { |
| 68 | + const { command, args } = getOpenBrowserCommand(url) |
| 69 | + const result = spawnSync(command, args, { |
| 70 | + stdio: "ignore", |
| 71 | + }) |
| 72 | + |
| 73 | + if (result.error || result.status !== 0) { |
| 74 | + throw new CLIError( |
| 75 | + "BROWSER_OPEN_FAILED", |
| 76 | + `Failed to open browser automatically. Open this URL manually: ${url}`, |
| 77 | + ) |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +export const resolveCLILoginUrl = (apiUrl: string, callbackUrl: string): string => { |
| 82 | + let api: URL |
| 83 | + try { |
| 84 | + api = new URL(apiUrl) |
| 85 | + } catch { |
| 86 | + throw new CLIError("INVALID_ARGUMENT", `Invalid API URL: ${apiUrl}`) |
| 87 | + } |
| 88 | + |
| 89 | + const mappedWebOrigin = mappedWebOrigins.find((item) => item.apiOrigin === api.origin)?.webOrigin |
| 90 | + const webUrl = new URL(mappedWebOrigin ?? api.origin) |
| 91 | + |
| 92 | + webUrl.pathname = "/login" |
| 93 | + webUrl.search = "" |
| 94 | + webUrl.hash = "" |
| 95 | + webUrl.searchParams.set("cli_callback", callbackUrl) |
| 96 | + |
| 97 | + return webUrl.toString() |
| 98 | +} |
| 99 | + |
| 100 | +export interface BrowserLoginOptions { |
| 101 | + apiUrl: string |
| 102 | + timeoutMs?: number |
| 103 | + onStatus?: (message: string) => void |
| 104 | +} |
| 105 | + |
| 106 | +export interface BrowserLoginResult { |
| 107 | + token: string |
| 108 | + callbackUrl: string |
| 109 | + loginUrl: string |
| 110 | +} |
| 111 | + |
| 112 | +export const loginWithBrowser = async ( |
| 113 | + options: BrowserLoginOptions, |
| 114 | +): Promise<BrowserLoginResult> => { |
| 115 | + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS |
| 116 | + const onStatus = options.onStatus ?? (() => {}) |
| 117 | + |
| 118 | + if (timeoutMs <= 0) { |
| 119 | + throw new CLIError("INVALID_ARGUMENT", "Browser login timeout must be greater than 0.") |
| 120 | + } |
| 121 | + |
| 122 | + const result = await new Promise<BrowserLoginResult>((resolve, reject) => { |
| 123 | + let settled = false |
| 124 | + let timer: NodeJS.Timeout | undefined |
| 125 | + |
| 126 | + const settle = (handler: () => void) => { |
| 127 | + if (settled) { |
| 128 | + return |
| 129 | + } |
| 130 | + settled = true |
| 131 | + if (timer) { |
| 132 | + clearTimeout(timer) |
| 133 | + } |
| 134 | + server.close(() => { |
| 135 | + handler() |
| 136 | + }) |
| 137 | + } |
| 138 | + |
| 139 | + const server = createServer((req, res) => { |
| 140 | + const requestUrl = new URL( |
| 141 | + req.url ?? "/", |
| 142 | + `http://${req.headers.host ?? LOCAL_CALLBACK_HOST}`, |
| 143 | + ) |
| 144 | + |
| 145 | + if (requestUrl.pathname !== LOCAL_CALLBACK_PATH) { |
| 146 | + res.statusCode = 404 |
| 147 | + res.end("Not Found") |
| 148 | + return |
| 149 | + } |
| 150 | + |
| 151 | + const token = requestUrl.searchParams.get("token") |
| 152 | + if (!token) { |
| 153 | + res.statusCode = 400 |
| 154 | + res.setHeader("content-type", "text/html; charset=utf-8") |
| 155 | + res.end(failurePageHtml) |
| 156 | + return |
| 157 | + } |
| 158 | + |
| 159 | + res.statusCode = 200 |
| 160 | + res.setHeader("content-type", "text/html; charset=utf-8") |
| 161 | + res.end(successPageHtml) |
| 162 | + |
| 163 | + const callbackAddress = server.address() as AddressInfo | null |
| 164 | + const callbackUrl = callbackAddress |
| 165 | + ? `http://${LOCAL_CALLBACK_HOST}:${callbackAddress.port}${LOCAL_CALLBACK_PATH}` |
| 166 | + : "" |
| 167 | + const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl) |
| 168 | + |
| 169 | + settle(() => { |
| 170 | + resolve({ |
| 171 | + token, |
| 172 | + callbackUrl, |
| 173 | + loginUrl, |
| 174 | + }) |
| 175 | + }) |
| 176 | + }) |
| 177 | + |
| 178 | + server.once("error", (error) => { |
| 179 | + settle(() => { |
| 180 | + reject( |
| 181 | + new CLIError("NETWORK_ERROR", `Failed to start local callback server: ${error.message}`), |
| 182 | + ) |
| 183 | + }) |
| 184 | + }) |
| 185 | + |
| 186 | + server.listen(0, LOCAL_CALLBACK_HOST, () => { |
| 187 | + const address = server.address() as AddressInfo | null |
| 188 | + if (!address) { |
| 189 | + settle(() => { |
| 190 | + reject(new CLIError("NETWORK_ERROR", "Failed to bind local callback server.")) |
| 191 | + }) |
| 192 | + return |
| 193 | + } |
| 194 | + |
| 195 | + const callbackUrl = `http://${LOCAL_CALLBACK_HOST}:${address.port}${LOCAL_CALLBACK_PATH}` |
| 196 | + const loginUrl = resolveCLILoginUrl(options.apiUrl, callbackUrl) |
| 197 | + |
| 198 | + onStatus(`Open this URL to sign in: ${loginUrl}`) |
| 199 | + |
| 200 | + try { |
| 201 | + openBrowser(loginUrl) |
| 202 | + onStatus("Browser opened. Waiting for login confirmation...") |
| 203 | + } catch (error) { |
| 204 | + onStatus((error as Error).message) |
| 205 | + onStatus("Waiting for login confirmation...") |
| 206 | + } |
| 207 | + |
| 208 | + timer = setTimeout(() => { |
| 209 | + settle(() => { |
| 210 | + reject( |
| 211 | + new CLIError( |
| 212 | + "TIMEOUT", |
| 213 | + "Timed out waiting for browser login. Please run `folo auth login` again.", |
| 214 | + ), |
| 215 | + ) |
| 216 | + }) |
| 217 | + }, timeoutMs) |
| 218 | + }) |
| 219 | + }) |
| 220 | + |
| 221 | + return result |
| 222 | +} |
0 commit comments