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