| 59e6667 | | | 1 | import { intro, outro, spinner, log } from "@clack/prompts"; |
| cd75344 | | | 2 | import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, symlinkSync, unlinkSync } from "node:fs"; |
| fafa260 | | | 3 | import { join } from "node:path"; |
| 4ae9b20 | | | 4 | import { homedir, platform, arch } from "node:os"; |
| 4ae9b20 | | | 5 | import { execSync } from "node:child_process"; |
| e93a978 | | | 6 | import { waitForAuthCallback } from "../auth-server.js"; |
| e93a978 | | | 7 | import { loadConfig, saveConfig } from "../config.js"; |
| 17fe7cd | | | 8 | import { downloadAndInstallSapling, ensureGroveBinOnPath, isSaplingInstalled } from "../install-sapling.js"; |
| e93a978 | | | 9 | |
| 7010ba9 | | | 10 | function isHeadless(): boolean { |
| 7010ba9 | | | 11 | // SSH session, no display, or explicitly requested |
| 7010ba9 | | | 12 | return !!(process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION); |
| 7010ba9 | | | 13 | } |
| 7010ba9 | | | 14 | |
| 7010ba9 | | | 15 | async function deviceCodeFlow(config: { hub: string; token?: string }) { |
| 7010ba9 | | | 16 | // Request a device code from the hub |
| 7010ba9 | | | 17 | const res = await fetch(`${config.hub}/api/auth/device-code`, { method: "POST" }); |
| 7010ba9 | | | 18 | if (!res.ok) { |
| 7010ba9 | | | 19 | throw new Error(`Failed to start device code flow: ${res.statusText}`); |
| 7010ba9 | | | 20 | } |
| 7010ba9 | | | 21 | const { code, url } = await res.json() as { code: string; url: string; expires_in: number }; |
| 7010ba9 | | | 22 | |
| 7010ba9 | | | 23 | log.info(`Open this URL in your browser:\n\n ${url}\n\nYour code: ${code}`); |
| 7010ba9 | | | 24 | |
| 7010ba9 | | | 25 | const s = spinner(); |
| 7010ba9 | | | 26 | s.start("Waiting for approval"); |
| 7010ba9 | | | 27 | |
| 7010ba9 | | | 28 | // Poll for approval |
| 7010ba9 | | | 29 | for (let i = 0; i < 120; i++) { |
| 7010ba9 | | | 30 | await new Promise((r) => setTimeout(r, 5000)); |
| 7010ba9 | | | 31 | |
| 7010ba9 | | | 32 | const pollRes = await fetch(`${config.hub}/api/auth/device-code/${code}`); |
| 7010ba9 | | | 33 | if (!pollRes.ok) { |
| 7010ba9 | | | 34 | s.stop("Authentication failed"); |
| 7010ba9 | | | 35 | throw new Error("Code expired or invalid"); |
| 7010ba9 | | | 36 | } |
| 7010ba9 | | | 37 | |
| 7010ba9 | | | 38 | const data = await pollRes.json() as { status: string; token?: string }; |
| 7010ba9 | | | 39 | if (data.status === "complete" && data.token) { |
| 7010ba9 | | | 40 | s.stop("Authentication received"); |
| 7010ba9 | | | 41 | return data.token; |
| 7010ba9 | | | 42 | } |
| 7010ba9 | | | 43 | } |
| 7010ba9 | | | 44 | |
| 7010ba9 | | | 45 | s.stop("Authentication timed out"); |
| 7010ba9 | | | 46 | throw new Error("Authentication timed out (10 minutes)"); |
| 7010ba9 | | | 47 | } |
| 7010ba9 | | | 48 | |
| fafa260 | | | 49 | async function provisionTlsCerts(hub: string, token: string) { |
| fafa260 | | | 50 | const s = spinner(); |
| fafa260 | | | 51 | s.start("Downloading TLS certificates"); |
| fafa260 | | | 52 | try { |
| fafa260 | | | 53 | const res = await fetch(`${hub}/api/auth/tls-certs`, { |
| fafa260 | | | 54 | headers: { Authorization: `Bearer ${token}` }, |
| fafa260 | | | 55 | }); |
| fafa260 | | | 56 | if (!res.ok) { |
| fafa260 | | | 57 | s.stop("TLS certificates not available (non-fatal)"); |
| fafa260 | | | 58 | return; |
| fafa260 | | | 59 | } |
| fafa260 | | | 60 | const { ca, cert, key } = await res.json() as { ca: string; cert: string; key: string }; |
| fafa260 | | | 61 | const tlsDir = join(homedir(), ".grove", "tls"); |
| fafa260 | | | 62 | mkdirSync(tlsDir, { recursive: true }); |
| fafa260 | | | 63 | writeFileSync(join(tlsDir, "ca.crt"), ca); |
| fafa260 | | | 64 | writeFileSync(join(tlsDir, "client.crt"), cert); |
| fafa260 | | | 65 | writeFileSync(join(tlsDir, "client.key"), key, { mode: 0o600 }); |
| fafa260 | | | 66 | s.stop("TLS certificates saved"); |
| fafa260 | | | 67 | } catch { |
| fafa260 | | | 68 | s.stop("TLS certificates not available (non-fatal)"); |
| fafa260 | | | 69 | } |
| fafa260 | | | 70 | } |
| fafa260 | | | 71 | |
| 7a611b5 | | | 72 | function getSaplingConfigPath(): string { |
| 7a611b5 | | | 73 | if (platform() === "darwin") { |
| 7a611b5 | | | 74 | return join(homedir(), "Library", "Preferences", "sapling", "sapling.conf"); |
| 7a611b5 | | | 75 | } |
| 7a611b5 | | | 76 | return join(homedir(), ".config", "sapling", "sapling.conf"); |
| 7a611b5 | | | 77 | } |
| 7a611b5 | | | 78 | |
| 7a611b5 | | | 79 | function configureSaplingUsername(username: string) { |
| 7a611b5 | | | 80 | try { |
| 7a611b5 | | | 81 | const configPath = getSaplingConfigPath(); |
| 7a611b5 | | | 82 | mkdirSync(join(configPath, ".."), { recursive: true }); |
| 7a611b5 | | | 83 | |
| 7a611b5 | | | 84 | if (existsSync(configPath)) { |
| 7a611b5 | | | 85 | const existing = readFileSync(configPath, "utf-8"); |
| 7a611b5 | | | 86 | if (existing.includes("username =")) return; |
| 7a611b5 | | | 87 | // Prepend [ui] section |
| 7a611b5 | | | 88 | writeFileSync(configPath, `[ui]\nusername = ${username}\n\n` + existing); |
| 7a611b5 | | | 89 | } else { |
| 7a611b5 | | | 90 | writeFileSync(configPath, `[ui]\nusername = ${username}\n`); |
| 7a611b5 | | | 91 | } |
| 7a611b5 | | | 92 | } catch { |
| 7a611b5 | | | 93 | // non-fatal |
| 7a611b5 | | | 94 | } |
| 7a611b5 | | | 95 | } |
| 7a611b5 | | | 96 | |
| 4ae9b20 | | | 97 | async function installSapling(hub: string) { |
| 17fe7cd | | | 98 | const sl = isSaplingInstalled(); |
| 17fe7cd | | | 99 | if (sl.installed && !sl.outdated) return; |
| 4ae9b20 | | | 100 | |
| 4ae9b20 | | | 101 | const s = spinner(); |
| 4ae9b20 | | | 102 | s.start("Installing Sapling (sl)"); |
| 4ae9b20 | | | 103 | try { |
| 17fe7cd | | | 104 | const result = await downloadAndInstallSapling(hub); |
| 17fe7cd | | | 105 | if (!result || !result.installed) { |
| 17fe7cd | | | 106 | s.stop(result?.message || "Sapling binary not available yet (non-fatal)"); |
| 4ae9b20 | | | 107 | return; |
| 4ae9b20 | | | 108 | } |
| 17fe7cd | | | 109 | const pathResult = ensureGroveBinOnPath(); |
| 4ae9b20 | | | 110 | s.stop("Sapling (sl) installed"); |
| 17fe7cd | | | 111 | if (pathResult.added) { |
| 17fe7cd | | | 112 | log.info(pathResult.message); |
| 17fe7cd | | | 113 | } |
| 4ae9b20 | | | 114 | } catch { |
| 4ae9b20 | | | 115 | s.stop("Could not install Sapling (non-fatal)"); |
| 4ae9b20 | | | 116 | } |
| 4ae9b20 | | | 117 | } |
| 4ae9b20 | | | 118 | |
| e93a978 | | | 119 | export async function authLogin(args: string[]) { |
| e93a978 | | | 120 | const config = await loadConfig(); |
| e93a978 | | | 121 | |
| e93a978 | | | 122 | // Allow --hub flag to override hub URL |
| e93a978 | | | 123 | const hubIdx = args.indexOf("--hub"); |
| e93a978 | | | 124 | if (hubIdx !== -1 && args[hubIdx + 1]) { |
| e93a978 | | | 125 | config.hub = args[hubIdx + 1]; |
| e93a978 | | | 126 | } |
| e93a978 | | | 127 | |
| 59e6667 | | | 128 | intro("grove auth login"); |
| e93a978 | | | 129 | |
| 7010ba9 | | | 130 | if (isHeadless()) { |
| 7010ba9 | | | 131 | // Device code flow for remote/headless environments |
| 7010ba9 | | | 132 | try { |
| 7010ba9 | | | 133 | const token = await deviceCodeFlow(config); |
| 7010ba9 | | | 134 | config.token = token; |
| 7010ba9 | | | 135 | await saveConfig(config); |
| fafa260 | | | 136 | await provisionTlsCerts(config.hub, token); |
| 7a611b5 | | | 137 | const payload = JSON.parse(atob(token.split(".")[1])); |
| 7a611b5 | | | 138 | configureSaplingUsername(`${payload.display_name || payload.username} <${payload.username}@grove.host>`); |
| 4ae9b20 | | | 139 | await installSapling(config.hub); |
| 7010ba9 | | | 140 | outro("Authenticated! Token saved to ~/.grove/config.json"); |
| 7010ba9 | | | 141 | } catch (err: any) { |
| 7010ba9 | | | 142 | log.error(err.message); |
| 7010ba9 | | | 143 | process.exit(1); |
| 7010ba9 | | | 144 | } |
| 7010ba9 | | | 145 | return; |
| 7010ba9 | | | 146 | } |
| 7010ba9 | | | 147 | |
| 7010ba9 | | | 148 | // Browser-based flow for local environments |
| ff43545 | | | 149 | const { port, result } = await waitForAuthCallback(); |
| e93a978 | | | 150 | |
| e93a978 | | | 151 | const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`); |
| e93a978 | | | 152 | const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`; |
| e93a978 | | | 153 | |
| 59e6667 | | | 154 | log.info(`If the browser doesn't open, visit:\n${authUrl}`); |
| e93a978 | | | 155 | |
| e93a978 | | | 156 | const open = (await import("open")).default; |
| e93a978 | | | 157 | await open(authUrl); |
| e93a978 | | | 158 | |
| 59e6667 | | | 159 | const s = spinner(); |
| 59e6667 | | | 160 | s.start("Waiting for authentication in browser"); |
| e93a978 | | | 161 | |
| e93a978 | | | 162 | try { |
| e93a978 | | | 163 | const { token } = await result; |
| 59e6667 | | | 164 | s.stop("Authentication received"); |
| e93a978 | | | 165 | config.token = token; |
| e93a978 | | | 166 | await saveConfig(config); |
| fafa260 | | | 167 | await provisionTlsCerts(config.hub, token); |
| 7a611b5 | | | 168 | const payload = JSON.parse(atob(token.split(".")[1])); |
| 7a611b5 | | | 169 | configureSaplingUsername(`${payload.display_name || payload.username} <${payload.username}@grove.host>`); |
| 4ae9b20 | | | 170 | await installSapling(config.hub); |
| 59e6667 | | | 171 | outro("Authenticated! Token saved to ~/.grove/config.json"); |
| e93a978 | | | 172 | } catch (err: any) { |
| 59e6667 | | | 173 | s.stop("Authentication failed"); |
| 59e6667 | | | 174 | log.error(err.message); |
| e93a978 | | | 175 | process.exit(1); |
| e93a978 | | | 176 | } |
| e93a978 | | | 177 | } |