cli/src/commands/auth-login.tsblame
View source
59e66671import { intro, outro, spinner, log } from "@clack/prompts";
7a611b52import { mkdirSync, writeFileSync, readFileSync, 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
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();
7a611b5113 const binary = `sl-${os}-${archName}`;
4ae9b20114 const res = await fetch(`${hub}/downloads/${binary}`);
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());
4ae9b20120 const dest = "/usr/local/bin/sl";
4ae9b20121 writeFileSync(dest, buf);
4ae9b20122 chmodSync(dest, 0o755);
4ae9b20123 s.stop("Sapling (sl) installed");
4ae9b20124 } catch {
4ae9b20125 s.stop("Could not install Sapling (non-fatal)");
4ae9b20126 }
4ae9b20127}
4ae9b20128
e93a978129export async function authLogin(args: string[]) {
e93a978130 const config = await loadConfig();
e93a978131
e93a978132 // Allow --hub flag to override hub URL
e93a978133 const hubIdx = args.indexOf("--hub");
e93a978134 if (hubIdx !== -1 && args[hubIdx + 1]) {
e93a978135 config.hub = args[hubIdx + 1];
e93a978136 }
e93a978137
59e6667138 intro("grove auth login");
e93a978139
7010ba9140 if (isHeadless()) {
7010ba9141 // Device code flow for remote/headless environments
7010ba9142 try {
7010ba9143 const token = await deviceCodeFlow(config);
7010ba9144 config.token = token;
7010ba9145 await saveConfig(config);
fafa260146 await provisionTlsCerts(config.hub, token);
7a611b5147 const payload = JSON.parse(atob(token.split(".")[1]));
7a611b5148 configureSaplingUsername(`${payload.display_name || payload.username} <${payload.username}@grove.host>`);
4ae9b20149 await installSapling(config.hub);
7010ba9150 outro("Authenticated! Token saved to ~/.grove/config.json");
7010ba9151 } catch (err: any) {
7010ba9152 log.error(err.message);
7010ba9153 process.exit(1);
7010ba9154 }
7010ba9155 return;
7010ba9156 }
7010ba9157
7010ba9158 // Browser-based flow for local environments
ff43545159 const { port, result } = await waitForAuthCallback();
e93a978160
e93a978161 const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`);
e93a978162 const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`;
e93a978163
59e6667164 log.info(`If the browser doesn't open, visit:\n${authUrl}`);
e93a978165
e93a978166 const open = (await import("open")).default;
e93a978167 await open(authUrl);
e93a978168
59e6667169 const s = spinner();
59e6667170 s.start("Waiting for authentication in browser");
e93a978171
e93a978172 try {
e93a978173 const { token } = await result;
59e6667174 s.stop("Authentication received");
e93a978175 config.token = token;
e93a978176 await saveConfig(config);
fafa260177 await provisionTlsCerts(config.hub, token);
7a611b5178 const payload = JSON.parse(atob(token.split(".")[1]));
7a611b5179 configureSaplingUsername(`${payload.display_name || payload.username} <${payload.username}@grove.host>`);
4ae9b20180 await installSapling(config.hub);
59e6667181 outro("Authenticated! Token saved to ~/.grove/config.json");
e93a978182 } catch (err: any) {
59e6667183 s.stop("Authentication failed");
59e6667184 log.error(err.message);
e93a978185 process.exit(1);
e93a978186 }
e93a978187}