cli/src/commands/auth-login.tsblame
View source
59e66671import { intro, outro, spinner, log } from "@clack/prompts";
cd753442import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, symlinkSync, unlinkSync } 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
7a611b571function getSaplingConfigPath(): string {
7a611b572 if (platform() === "darwin") {
7a611b573 return join(homedir(), "Library", "Preferences", "sapling", "sapling.conf");
7a611b574 }
7a611b575 return join(homedir(), ".config", "sapling", "sapling.conf");
7a611b576}
7a611b577
7a611b578function configureSaplingUsername(username: string) {
7a611b579 try {
7a611b580 const configPath = getSaplingConfigPath();
7a611b581 mkdirSync(join(configPath, ".."), { recursive: true });
7a611b582
7a611b583 if (existsSync(configPath)) {
7a611b584 const existing = readFileSync(configPath, "utf-8");
7a611b585 if (existing.includes("username =")) return;
7a611b586 // Prepend [ui] section
7a611b587 writeFileSync(configPath, `[ui]\nusername = ${username}\n\n` + existing);
7a611b588 } else {
7a611b589 writeFileSync(configPath, `[ui]\nusername = ${username}\n`);
7a611b590 }
7a611b591 } catch {
7a611b592 // non-fatal
7a611b593 }
7a611b594}
7a611b595
4ae9b2096async function installSapling(hub: string) {
7a611b597 const os = platform();
7a611b598 if (os !== "linux" && os !== "darwin") return;
4ae9b2099
4ae9b20100 // Check if sl is already installed and working
4ae9b20101 try {
4ae9b20102 const version = execSync("sl --version 2>&1", { stdio: "pipe" }).toString();
4ae9b20103 // Our builds are version 4.x+, old official release is 0.2
4ae9b20104 if (!version.includes("0.2.")) return;
4ae9b20105 } catch {
4ae9b20106 // sl not found — need to install
4ae9b20107 }
4ae9b20108
4ae9b20109 const s = spinner();
4ae9b20110 s.start("Installing Sapling (sl)");
4ae9b20111 try {
7a611b5112 const archName = arch() === "x64" ? "x86_64" : arch();
cd75344113 const tarball = `sl-${os}-${archName}.tar.gz`;
cd75344114 const res = await fetch(`${hub}/downloads/${tarball}`);
4ae9b20115 if (!res.ok) {
4ae9b20116 s.stop("Sapling binary not available yet (non-fatal)");
4ae9b20117 return;
4ae9b20118 }
4ae9b20119 const buf = Buffer.from(await res.arrayBuffer());
cd75344120 const installDir = "/usr/local/lib/sapling";
cd75344121 const symlink = "/usr/local/bin/sl";
cd75344122
cd75344123 // Write tarball to temp file and extract
cd75344124 const tmpTar = "/tmp/sl-install.tar.gz";
cd75344125 writeFileSync(tmpTar, buf);
cd75344126 execSync(`rm -rf ${installDir} && mkdir -p ${installDir} && tar xzf ${tmpTar} -C ${installDir}`, { stdio: "pipe" });
cd75344127 execSync(`rm -f ${tmpTar}`, { stdio: "pipe" });
cd75344128
cd75344129 // Create symlink at /usr/local/bin/sl -> wrapper script
cd75344130 try { unlinkSync(symlink); } catch {}
cd75344131 symlinkSync(join(installDir, "sl"), symlink);
cd75344132
4ae9b20133 s.stop("Sapling (sl) installed");
4ae9b20134 } catch {
4ae9b20135 s.stop("Could not install Sapling (non-fatal)");
4ae9b20136 }
4ae9b20137}
4ae9b20138
e93a978139export async function authLogin(args: string[]) {
e93a978140 const config = await loadConfig();
e93a978141
e93a978142 // Allow --hub flag to override hub URL
e93a978143 const hubIdx = args.indexOf("--hub");
e93a978144 if (hubIdx !== -1 && args[hubIdx + 1]) {
e93a978145 config.hub = args[hubIdx + 1];
e93a978146 }
e93a978147
59e6667148 intro("grove auth login");
e93a978149
7010ba9150 if (isHeadless()) {
7010ba9151 // Device code flow for remote/headless environments
7010ba9152 try {
7010ba9153 const token = await deviceCodeFlow(config);
7010ba9154 config.token = token;
7010ba9155 await saveConfig(config);
fafa260156 await provisionTlsCerts(config.hub, token);
7a611b5157 const payload = JSON.parse(atob(token.split(".")[1]));
7a611b5158 configureSaplingUsername(`${payload.display_name || payload.username} <${payload.username}@grove.host>`);
4ae9b20159 await installSapling(config.hub);
7010ba9160 outro("Authenticated! Token saved to ~/.grove/config.json");
7010ba9161 } catch (err: any) {
7010ba9162 log.error(err.message);
7010ba9163 process.exit(1);
7010ba9164 }
7010ba9165 return;
7010ba9166 }
7010ba9167
7010ba9168 // Browser-based flow for local environments
ff43545169 const { port, result } = await waitForAuthCallback();
e93a978170
e93a978171 const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`);
e93a978172 const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`;
e93a978173
59e6667174 log.info(`If the browser doesn't open, visit:\n${authUrl}`);
e93a978175
e93a978176 const open = (await import("open")).default;
e93a978177 await open(authUrl);
e93a978178
59e6667179 const s = spinner();
59e6667180 s.start("Waiting for authentication in browser");
e93a978181
e93a978182 try {
e93a978183 const { token } = await result;
59e6667184 s.stop("Authentication received");
e93a978185 config.token = token;
e93a978186 await saveConfig(config);
fafa260187 await provisionTlsCerts(config.hub, token);
7a611b5188 const payload = JSON.parse(atob(token.split(".")[1]));
7a611b5189 configureSaplingUsername(`${payload.display_name || payload.username} <${payload.username}@grove.host>`);
4ae9b20190 await installSapling(config.hub);
59e6667191 outro("Authenticated! Token saved to ~/.grove/config.json");
e93a978192 } catch (err: any) {
59e6667193 s.stop("Authentication failed");
59e6667194 log.error(err.message);
e93a978195 process.exit(1);
e93a978196 }
e93a978197}