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