5.0 KB158 lines
Blame
1import { intro, outro, spinner, log } from "@clack/prompts";
2import { mkdirSync, writeFileSync, 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
71async function installSapling(hub: string) {
72 // Only install on Linux — macOS users build from source
73 if (platform() !== "linux") return;
74
75 // Check if sl is already installed and working
76 try {
77 const version = execSync("sl --version 2>&1", { stdio: "pipe" }).toString();
78 // Our builds are version 4.x+, old official release is 0.2
79 if (!version.includes("0.2.")) return;
80 } catch {
81 // sl not found — need to install
82 }
83
84 const s = spinner();
85 s.start("Installing Sapling (sl)");
86 try {
87 const binary = `sl-linux-${arch() === "x64" ? "x86_64" : arch()}`;
88 const res = await fetch(`${hub}/downloads/${binary}`);
89 if (!res.ok) {
90 s.stop("Sapling binary not available yet (non-fatal)");
91 return;
92 }
93 const buf = Buffer.from(await res.arrayBuffer());
94 const dest = "/usr/local/bin/sl";
95 writeFileSync(dest, buf);
96 chmodSync(dest, 0o755);
97 s.stop("Sapling (sl) installed");
98 } catch {
99 s.stop("Could not install Sapling (non-fatal)");
100 }
101}
102
103export async function authLogin(args: string[]) {
104 const config = await loadConfig();
105
106 // Allow --hub flag to override hub URL
107 const hubIdx = args.indexOf("--hub");
108 if (hubIdx !== -1 && args[hubIdx + 1]) {
109 config.hub = args[hubIdx + 1];
110 }
111
112 intro("grove auth login");
113
114 if (isHeadless()) {
115 // Device code flow for remote/headless environments
116 try {
117 const token = await deviceCodeFlow(config);
118 config.token = token;
119 await saveConfig(config);
120 await provisionTlsCerts(config.hub, token);
121 await installSapling(config.hub);
122 outro("Authenticated! Token saved to ~/.grove/config.json");
123 } catch (err: any) {
124 log.error(err.message);
125 process.exit(1);
126 }
127 return;
128 }
129
130 // Browser-based flow for local environments
131 const { port, result } = await waitForAuthCallback();
132
133 const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`);
134 const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`;
135
136 log.info(`If the browser doesn't open, visit:\n${authUrl}`);
137
138 const open = (await import("open")).default;
139 await open(authUrl);
140
141 const s = spinner();
142 s.start("Waiting for authentication in browser");
143
144 try {
145 const { token } = await result;
146 s.stop("Authentication received");
147 config.token = token;
148 await saveConfig(config);
149 await provisionTlsCerts(config.hub, token);
150 await installSapling(config.hub);
151 outro("Authenticated! Token saved to ~/.grove/config.json");
152 } catch (err: any) {
153 s.stop("Authentication failed");
154 log.error(err.message);
155 process.exit(1);
156 }
157}
158