cli/src/commands/auth-login.tsblame
View source
59e66671import { intro, outro, spinner, log } from "@clack/prompts";
4ae9b202import { mkdirSync, writeFileSync, existsSync, chmodSync } from "node:fs";
fafa2603import { join } from "node:path";
4ae9b204import { homedir, platform, arch } from "node:os";
4ae9b205import { execSync } from "node:child_process";
e93a9786import { waitForAuthCallback } from "../auth-server.js";
e93a9787import { loadConfig, saveConfig } from "../config.js";
e93a9788
7010ba99function isHeadless(): boolean {
7010ba910 // SSH session, no display, or explicitly requested
7010ba911 return !!(process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION);
7010ba912}
7010ba913
7010ba914async function deviceCodeFlow(config: { hub: string; token?: string }) {
7010ba915 // Request a device code from the hub
7010ba916 const res = await fetch(`${config.hub}/api/auth/device-code`, { method: "POST" });
7010ba917 if (!res.ok) {
7010ba918 throw new Error(`Failed to start device code flow: ${res.statusText}`);
7010ba919 }
7010ba920 const { code, url } = await res.json() as { code: string; url: string; expires_in: number };
7010ba921
7010ba922 log.info(`Open this URL in your browser:\n\n ${url}\n\nYour code: ${code}`);
7010ba923
7010ba924 const s = spinner();
7010ba925 s.start("Waiting for approval");
7010ba926
7010ba927 // Poll for approval
7010ba928 for (let i = 0; i < 120; i++) {
7010ba929 await new Promise((r) => setTimeout(r, 5000));
7010ba930
7010ba931 const pollRes = await fetch(`${config.hub}/api/auth/device-code/${code}`);
7010ba932 if (!pollRes.ok) {
7010ba933 s.stop("Authentication failed");
7010ba934 throw new Error("Code expired or invalid");
7010ba935 }
7010ba936
7010ba937 const data = await pollRes.json() as { status: string; token?: string };
7010ba938 if (data.status === "complete" && data.token) {
7010ba939 s.stop("Authentication received");
7010ba940 return data.token;
7010ba941 }
7010ba942 }
7010ba943
7010ba944 s.stop("Authentication timed out");
7010ba945 throw new Error("Authentication timed out (10 minutes)");
7010ba946}
7010ba947
fafa26048async function provisionTlsCerts(hub: string, token: string) {
fafa26049 const s = spinner();
fafa26050 s.start("Downloading TLS certificates");
fafa26051 try {
fafa26052 const res = await fetch(`${hub}/api/auth/tls-certs`, {
fafa26053 headers: { Authorization: `Bearer ${token}` },
fafa26054 });
fafa26055 if (!res.ok) {
fafa26056 s.stop("TLS certificates not available (non-fatal)");
fafa26057 return;
fafa26058 }
fafa26059 const { ca, cert, key } = await res.json() as { ca: string; cert: string; key: string };
fafa26060 const tlsDir = join(homedir(), ".grove", "tls");
fafa26061 mkdirSync(tlsDir, { recursive: true });
fafa26062 writeFileSync(join(tlsDir, "ca.crt"), ca);
fafa26063 writeFileSync(join(tlsDir, "client.crt"), cert);
fafa26064 writeFileSync(join(tlsDir, "client.key"), key, { mode: 0o600 });
fafa26065 s.stop("TLS certificates saved");
fafa26066 } catch {
fafa26067 s.stop("TLS certificates not available (non-fatal)");
fafa26068 }
fafa26069}
fafa26070
4ae9b2071async function installSapling(hub: string) {
4ae9b2072 // Only install on Linux — macOS users build from source
4ae9b2073 if (platform() !== "linux") return;
4ae9b2074
4ae9b2075 // Check if sl is already installed and working
4ae9b2076 try {
4ae9b2077 const version = execSync("sl --version 2>&1", { stdio: "pipe" }).toString();
4ae9b2078 // Our builds are version 4.x+, old official release is 0.2
4ae9b2079 if (!version.includes("0.2.")) return;
4ae9b2080 } catch {
4ae9b2081 // sl not found — need to install
4ae9b2082 }
4ae9b2083
4ae9b2084 const s = spinner();
4ae9b2085 s.start("Installing Sapling (sl)");
4ae9b2086 try {
4ae9b2087 const binary = `sl-linux-${arch() === "x64" ? "x86_64" : arch()}`;
4ae9b2088 const res = await fetch(`${hub}/downloads/${binary}`);
4ae9b2089 if (!res.ok) {
4ae9b2090 s.stop("Sapling binary not available yet (non-fatal)");
4ae9b2091 return;
4ae9b2092 }
4ae9b2093 const buf = Buffer.from(await res.arrayBuffer());
4ae9b2094 const dest = "/usr/local/bin/sl";
4ae9b2095 writeFileSync(dest, buf);
4ae9b2096 chmodSync(dest, 0o755);
4ae9b2097 s.stop("Sapling (sl) installed");
4ae9b2098 } catch {
4ae9b2099 s.stop("Could not install Sapling (non-fatal)");
4ae9b20100 }
4ae9b20101}
4ae9b20102
e93a978103export async function authLogin(args: string[]) {
e93a978104 const config = await loadConfig();
e93a978105
e93a978106 // Allow --hub flag to override hub URL
e93a978107 const hubIdx = args.indexOf("--hub");
e93a978108 if (hubIdx !== -1 && args[hubIdx + 1]) {
e93a978109 config.hub = args[hubIdx + 1];
e93a978110 }
e93a978111
59e6667112 intro("grove auth login");
e93a978113
7010ba9114 if (isHeadless()) {
7010ba9115 // Device code flow for remote/headless environments
7010ba9116 try {
7010ba9117 const token = await deviceCodeFlow(config);
7010ba9118 config.token = token;
7010ba9119 await saveConfig(config);
fafa260120 await provisionTlsCerts(config.hub, token);
4ae9b20121 await installSapling(config.hub);
7010ba9122 outro("Authenticated! Token saved to ~/.grove/config.json");
7010ba9123 } catch (err: any) {
7010ba9124 log.error(err.message);
7010ba9125 process.exit(1);
7010ba9126 }
7010ba9127 return;
7010ba9128 }
7010ba9129
7010ba9130 // Browser-based flow for local environments
ff43545131 const { port, result } = await waitForAuthCallback();
e93a978132
e93a978133 const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`);
e93a978134 const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`;
e93a978135
59e6667136 log.info(`If the browser doesn't open, visit:\n${authUrl}`);
e93a978137
e93a978138 const open = (await import("open")).default;
e93a978139 await open(authUrl);
e93a978140
59e6667141 const s = spinner();
59e6667142 s.start("Waiting for authentication in browser");
e93a978143
e93a978144 try {
e93a978145 const { token } = await result;
59e6667146 s.stop("Authentication received");
e93a978147 config.token = token;
e93a978148 await saveConfig(config);
fafa260149 await provisionTlsCerts(config.hub, token);
4ae9b20150 await installSapling(config.hub);
59e6667151 outro("Authenticated! Token saved to ~/.grove/config.json");
e93a978152 } catch (err: any) {
59e6667153 s.stop("Authentication failed");
59e6667154 log.error(err.message);
e93a978155 process.exit(1);
e93a978156 }
e93a978157}