| 59e6667 | | | 1 | import { intro, outro, spinner, log } from "@clack/prompts"; |
| e93a978 | | | 2 | import { waitForAuthCallback } from "../auth-server.js"; |
| e93a978 | | | 3 | import { loadConfig, saveConfig } from "../config.js"; |
| e93a978 | | | 4 | |
| 7010ba9 | | | 5 | function isHeadless(): boolean { |
| 7010ba9 | | | 6 | // SSH session, no display, or explicitly requested |
| 7010ba9 | | | 7 | return !!(process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION); |
| 7010ba9 | | | 8 | } |
| 7010ba9 | | | 9 | |
| 7010ba9 | | | 10 | async function deviceCodeFlow(config: { hub: string; token?: string }) { |
| 7010ba9 | | | 11 | // Request a device code from the hub |
| 7010ba9 | | | 12 | const res = await fetch(`${config.hub}/api/auth/device-code`, { method: "POST" }); |
| 7010ba9 | | | 13 | if (!res.ok) { |
| 7010ba9 | | | 14 | throw new Error(`Failed to start device code flow: ${res.statusText}`); |
| 7010ba9 | | | 15 | } |
| 7010ba9 | | | 16 | const { code, url } = await res.json() as { code: string; url: string; expires_in: number }; |
| 7010ba9 | | | 17 | |
| 7010ba9 | | | 18 | log.info(`Open this URL in your browser:\n\n ${url}\n\nYour code: ${code}`); |
| 7010ba9 | | | 19 | |
| 7010ba9 | | | 20 | const s = spinner(); |
| 7010ba9 | | | 21 | s.start("Waiting for approval"); |
| 7010ba9 | | | 22 | |
| 7010ba9 | | | 23 | // Poll for approval |
| 7010ba9 | | | 24 | for (let i = 0; i < 120; i++) { |
| 7010ba9 | | | 25 | await new Promise((r) => setTimeout(r, 5000)); |
| 7010ba9 | | | 26 | |
| 7010ba9 | | | 27 | const pollRes = await fetch(`${config.hub}/api/auth/device-code/${code}`); |
| 7010ba9 | | | 28 | if (!pollRes.ok) { |
| 7010ba9 | | | 29 | s.stop("Authentication failed"); |
| 7010ba9 | | | 30 | throw new Error("Code expired or invalid"); |
| 7010ba9 | | | 31 | } |
| 7010ba9 | | | 32 | |
| 7010ba9 | | | 33 | const data = await pollRes.json() as { status: string; token?: string }; |
| 7010ba9 | | | 34 | if (data.status === "complete" && data.token) { |
| 7010ba9 | | | 35 | s.stop("Authentication received"); |
| 7010ba9 | | | 36 | return data.token; |
| 7010ba9 | | | 37 | } |
| 7010ba9 | | | 38 | } |
| 7010ba9 | | | 39 | |
| 7010ba9 | | | 40 | s.stop("Authentication timed out"); |
| 7010ba9 | | | 41 | throw new Error("Authentication timed out (10 minutes)"); |
| 7010ba9 | | | 42 | } |
| 7010ba9 | | | 43 | |
| e93a978 | | | 44 | export async function authLogin(args: string[]) { |
| e93a978 | | | 45 | const config = await loadConfig(); |
| e93a978 | | | 46 | |
| e93a978 | | | 47 | // Allow --hub flag to override hub URL |
| e93a978 | | | 48 | const hubIdx = args.indexOf("--hub"); |
| e93a978 | | | 49 | if (hubIdx !== -1 && args[hubIdx + 1]) { |
| e93a978 | | | 50 | config.hub = args[hubIdx + 1]; |
| e93a978 | | | 51 | } |
| e93a978 | | | 52 | |
| 59e6667 | | | 53 | intro("grove auth login"); |
| e93a978 | | | 54 | |
| 7010ba9 | | | 55 | if (isHeadless()) { |
| 7010ba9 | | | 56 | // Device code flow for remote/headless environments |
| 7010ba9 | | | 57 | try { |
| 7010ba9 | | | 58 | const token = await deviceCodeFlow(config); |
| 7010ba9 | | | 59 | config.token = token; |
| 7010ba9 | | | 60 | await saveConfig(config); |
| 7010ba9 | | | 61 | outro("Authenticated! Token saved to ~/.grove/config.json"); |
| 7010ba9 | | | 62 | } catch (err: any) { |
| 7010ba9 | | | 63 | log.error(err.message); |
| 7010ba9 | | | 64 | process.exit(1); |
| 7010ba9 | | | 65 | } |
| 7010ba9 | | | 66 | return; |
| 7010ba9 | | | 67 | } |
| 7010ba9 | | | 68 | |
| 7010ba9 | | | 69 | // Browser-based flow for local environments |
| ff43545 | | | 70 | const { port, result } = await waitForAuthCallback(); |
| e93a978 | | | 71 | |
| e93a978 | | | 72 | const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`); |
| e93a978 | | | 73 | const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`; |
| e93a978 | | | 74 | |
| 59e6667 | | | 75 | log.info(`If the browser doesn't open, visit:\n${authUrl}`); |
| e93a978 | | | 76 | |
| e93a978 | | | 77 | const open = (await import("open")).default; |
| e93a978 | | | 78 | await open(authUrl); |
| e93a978 | | | 79 | |
| 59e6667 | | | 80 | const s = spinner(); |
| 59e6667 | | | 81 | s.start("Waiting for authentication in browser"); |
| e93a978 | | | 82 | |
| e93a978 | | | 83 | try { |
| e93a978 | | | 84 | const { token } = await result; |
| 59e6667 | | | 85 | s.stop("Authentication received"); |
| e93a978 | | | 86 | config.token = token; |
| e93a978 | | | 87 | await saveConfig(config); |
| 59e6667 | | | 88 | outro("Authenticated! Token saved to ~/.grove/config.json"); |
| e93a978 | | | 89 | } catch (err: any) { |
| 59e6667 | | | 90 | s.stop("Authentication failed"); |
| 59e6667 | | | 91 | log.error(err.message); |
| e93a978 | | | 92 | process.exit(1); |
| e93a978 | | | 93 | } |
| e93a978 | | | 94 | } |