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";
17fe7cd8import { downloadAndInstallSapling, ensureGroveBinOnPath, isSaplingInstalled } from "../install-sapling.js";
e93a9789
7010ba910function isHeadless(): boolean {
7010ba911 // SSH session, no display, or explicitly requested
7010ba912 return !!(process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION);
7010ba913}
7010ba914
7010ba915async function deviceCodeFlow(config: { hub: string; token?: string }) {
7010ba916 // Request a device code from the hub
7010ba917 const res = await fetch(`${config.hub}/api/auth/device-code`, { method: "POST" });
7010ba918 if (!res.ok) {
7010ba919 throw new Error(`Failed to start device code flow: ${res.statusText}`);
7010ba920 }
7010ba921 const { code, url } = await res.json() as { code: string; url: string; expires_in: number };
7010ba922
7010ba923 log.info(`Open this URL in your browser:\n\n ${url}\n\nYour code: ${code}`);
7010ba924
7010ba925 const s = spinner();
7010ba926 s.start("Waiting for approval");
7010ba927
7010ba928 // Poll for approval
7010ba929 for (let i = 0; i < 120; i++) {
7010ba930 await new Promise((r) => setTimeout(r, 5000));
7010ba931
7010ba932 const pollRes = await fetch(`${config.hub}/api/auth/device-code/${code}`);
7010ba933 if (!pollRes.ok) {
7010ba934 s.stop("Authentication failed");
7010ba935 throw new Error("Code expired or invalid");
7010ba936 }
7010ba937
7010ba938 const data = await pollRes.json() as { status: string; token?: string };
7010ba939 if (data.status === "complete" && data.token) {
7010ba940 s.stop("Authentication received");
7010ba941 return data.token;
7010ba942 }
7010ba943 }
7010ba944
7010ba945 s.stop("Authentication timed out");
7010ba946 throw new Error("Authentication timed out (10 minutes)");
7010ba947}
7010ba948
fafa26049async function provisionTlsCerts(hub: string, token: string) {
fafa26050 const s = spinner();
fafa26051 s.start("Downloading TLS certificates");
fafa26052 try {
fafa26053 const res = await fetch(`${hub}/api/auth/tls-certs`, {
fafa26054 headers: { Authorization: `Bearer ${token}` },
fafa26055 });
fafa26056 if (!res.ok) {
fafa26057 s.stop("TLS certificates not available (non-fatal)");
fafa26058 return;
fafa26059 }
fafa26060 const { ca, cert, key } = await res.json() as { ca: string; cert: string; key: string };
fafa26061 const tlsDir = join(homedir(), ".grove", "tls");
fafa26062 mkdirSync(tlsDir, { recursive: true });
fafa26063 writeFileSync(join(tlsDir, "ca.crt"), ca);
fafa26064 writeFileSync(join(tlsDir, "client.crt"), cert);
fafa26065 writeFileSync(join(tlsDir, "client.key"), key, { mode: 0o600 });
fafa26066 s.stop("TLS certificates saved");
fafa26067 } catch {
fafa26068 s.stop("TLS certificates not available (non-fatal)");
fafa26069 }
fafa26070}
fafa26071
7a611b572function getSaplingConfigPath(): string {
7a611b573 if (platform() === "darwin") {
7a611b574 return join(homedir(), "Library", "Preferences", "sapling", "sapling.conf");
7a611b575 }
7a611b576 return join(homedir(), ".config", "sapling", "sapling.conf");
7a611b577}
7a611b578
7a611b579function configureSaplingUsername(username: string) {
7a611b580 try {
7a611b581 const configPath = getSaplingConfigPath();
7a611b582 mkdirSync(join(configPath, ".."), { recursive: true });
7a611b583
7a611b584 if (existsSync(configPath)) {
7a611b585 const existing = readFileSync(configPath, "utf-8");
7a611b586 if (existing.includes("username =")) return;
7a611b587 // Prepend [ui] section
7a611b588 writeFileSync(configPath, `[ui]\nusername = ${username}\n\n` + existing);
7a611b589 } else {
7a611b590 writeFileSync(configPath, `[ui]\nusername = ${username}\n`);
7a611b591 }
7a611b592 } catch {
7a611b593 // non-fatal
7a611b594 }
7a611b595}
7a611b596
4ae9b2097async function installSapling(hub: string) {
17fe7cd98 const sl = isSaplingInstalled();
17fe7cd99 if (sl.installed && !sl.outdated) return;
4ae9b20100
4ae9b20101 const s = spinner();
4ae9b20102 s.start("Installing Sapling (sl)");
4ae9b20103 try {
17fe7cd104 const result = await downloadAndInstallSapling(hub);
17fe7cd105 if (!result || !result.installed) {
17fe7cd106 s.stop(result?.message || "Sapling binary not available yet (non-fatal)");
4ae9b20107 return;
4ae9b20108 }
17fe7cd109 const pathResult = ensureGroveBinOnPath();
4ae9b20110 s.stop("Sapling (sl) installed");
17fe7cd111 if (pathResult.added) {
17fe7cd112 log.info(pathResult.message);
17fe7cd113 }
4ae9b20114 } catch {
4ae9b20115 s.stop("Could not install Sapling (non-fatal)");
4ae9b20116 }
4ae9b20117}
4ae9b20118
e93a978119export async function authLogin(args: string[]) {
e93a978120 const config = await loadConfig();
e93a978121
e93a978122 // Allow --hub flag to override hub URL
e93a978123 const hubIdx = args.indexOf("--hub");
e93a978124 if (hubIdx !== -1 && args[hubIdx + 1]) {
e93a978125 config.hub = args[hubIdx + 1];
e93a978126 }
e93a978127
59e6667128 intro("grove auth login");
e93a978129
7010ba9130 if (isHeadless()) {
7010ba9131 // Device code flow for remote/headless environments
7010ba9132 try {
7010ba9133 const token = await deviceCodeFlow(config);
7010ba9134 config.token = token;
7010ba9135 await saveConfig(config);
fafa260136 await provisionTlsCerts(config.hub, token);
7a611b5137 const payload = JSON.parse(atob(token.split(".")[1]));
7a611b5138 configureSaplingUsername(`${payload.display_name || payload.username} <${payload.username}@grove.host>`);
4ae9b20139 await installSapling(config.hub);
7010ba9140 outro("Authenticated! Token saved to ~/.grove/config.json");
7010ba9141 } catch (err: any) {
7010ba9142 log.error(err.message);
7010ba9143 process.exit(1);
7010ba9144 }
7010ba9145 return;
7010ba9146 }
7010ba9147
7010ba9148 // Browser-based flow for local environments
ff43545149 const { port, result } = await waitForAuthCallback();
e93a978150
e93a978151 const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`);
e93a978152 const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`;
e93a978153
59e6667154 log.info(`If the browser doesn't open, visit:\n${authUrl}`);
e93a978155
e93a978156 const open = (await import("open")).default;
e93a978157 await open(authUrl);
e93a978158
59e6667159 const s = spinner();
59e6667160 s.start("Waiting for authentication in browser");
e93a978161
e93a978162 try {
e93a978163 const { token } = await result;
59e6667164 s.stop("Authentication received");
e93a978165 config.token = token;
e93a978166 await saveConfig(config);
fafa260167 await provisionTlsCerts(config.hub, token);
7a611b5168 const payload = JSON.parse(atob(token.split(".")[1]));
7a611b5169 configureSaplingUsername(`${payload.display_name || payload.username} <${payload.username}@grove.host>`);
4ae9b20170 await installSapling(config.hub);
59e6667171 outro("Authenticated! Token saved to ~/.grove/config.json");
e93a978172 } catch (err: any) {
59e6667173 s.stop("Authentication failed");
59e6667174 log.error(err.message);
e93a978175 process.exit(1);
e93a978176 }
e93a978177}