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