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