6.6 KB198 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";
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 tarball = `sl-${os}-${archName}.tar.gz`;
114 const res = await fetch(`${hub}/downloads/${tarball}`);
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 installDir = "/usr/local/lib/sapling";
121 const symlink = "/usr/local/bin/sl";
122
123 // Write tarball to temp file and extract
124 const tmpTar = "/tmp/sl-install.tar.gz";
125 writeFileSync(tmpTar, buf);
126 execSync(`rm -rf ${installDir} && mkdir -p ${installDir} && tar xzf ${tmpTar} -C ${installDir}`, { stdio: "pipe" });
127 execSync(`rm -f ${tmpTar}`, { stdio: "pipe" });
128
129 // Create symlink at /usr/local/bin/sl -> wrapper script
130 try { unlinkSync(symlink); } catch {}
131 symlinkSync(join(installDir, "sl"), symlink);
132
133 s.stop("Sapling (sl) installed");
134 } catch {
135 s.stop("Could not install Sapling (non-fatal)");
136 }
137}
138
139export async function authLogin(args: string[]) {
140 const config = await loadConfig();
141
142 // Allow --hub flag to override hub URL
143 const hubIdx = args.indexOf("--hub");
144 if (hubIdx !== -1 && args[hubIdx + 1]) {
145 config.hub = args[hubIdx + 1];
146 }
147
148 intro("grove auth login");
149
150 if (isHeadless()) {
151 // Device code flow for remote/headless environments
152 try {
153 const token = await deviceCodeFlow(config);
154 config.token = token;
155 await saveConfig(config);
156 await provisionTlsCerts(config.hub, token);
157 const payload = JSON.parse(atob(token.split(".")[1]));
158 configureSaplingUsername(`${payload.display_name || payload.username} <${payload.username}@grove.host>`);
159 await installSapling(config.hub);
160 outro("Authenticated! Token saved to ~/.grove/config.json");
161 } catch (err: any) {
162 log.error(err.message);
163 process.exit(1);
164 }
165 return;
166 }
167
168 // Browser-based flow for local environments
169 const { port, result } = await waitForAuthCallback();
170
171 const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`);
172 const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`;
173
174 log.info(`If the browser doesn't open, visit:\n${authUrl}`);
175
176 const open = (await import("open")).default;
177 await open(authUrl);
178
179 const s = spinner();
180 s.start("Waiting for authentication in browser");
181
182 try {
183 const { token } = await result;
184 s.stop("Authentication received");
185 config.token = token;
186 await saveConfig(config);
187 await provisionTlsCerts(config.hub, token);
188 const payload = JSON.parse(atob(token.split(".")[1]));
189 configureSaplingUsername(`${payload.display_name || payload.username} <${payload.username}@grove.host>`);
190 await installSapling(config.hub);
191 outro("Authenticated! Token saved to ~/.grove/config.json");
192 } catch (err: any) {
193 s.stop("Authentication failed");
194 log.error(err.message);
195 process.exit(1);
196 }
197}
198