2.9 KB95 lines
Blame
1import { intro, outro, spinner, log } from "@clack/prompts";
2import { waitForAuthCallback } from "../auth-server.js";
3import { loadConfig, saveConfig } from "../config.js";
4
5function isHeadless(): boolean {
6 // SSH session, no display, or explicitly requested
7 return !!(process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION);
8}
9
10async function deviceCodeFlow(config: { hub: string; token?: string }) {
11 // Request a device code from the hub
12 const res = await fetch(`${config.hub}/api/auth/device-code`, { method: "POST" });
13 if (!res.ok) {
14 throw new Error(`Failed to start device code flow: ${res.statusText}`);
15 }
16 const { code, url } = await res.json() as { code: string; url: string; expires_in: number };
17
18 log.info(`Open this URL in your browser:\n\n ${url}\n\nYour code: ${code}`);
19
20 const s = spinner();
21 s.start("Waiting for approval");
22
23 // Poll for approval
24 for (let i = 0; i < 120; i++) {
25 await new Promise((r) => setTimeout(r, 5000));
26
27 const pollRes = await fetch(`${config.hub}/api/auth/device-code/${code}`);
28 if (!pollRes.ok) {
29 s.stop("Authentication failed");
30 throw new Error("Code expired or invalid");
31 }
32
33 const data = await pollRes.json() as { status: string; token?: string };
34 if (data.status === "complete" && data.token) {
35 s.stop("Authentication received");
36 return data.token;
37 }
38 }
39
40 s.stop("Authentication timed out");
41 throw new Error("Authentication timed out (10 minutes)");
42}
43
44export async function authLogin(args: string[]) {
45 const config = await loadConfig();
46
47 // Allow --hub flag to override hub URL
48 const hubIdx = args.indexOf("--hub");
49 if (hubIdx !== -1 && args[hubIdx + 1]) {
50 config.hub = args[hubIdx + 1];
51 }
52
53 intro("grove auth login");
54
55 if (isHeadless()) {
56 // Device code flow for remote/headless environments
57 try {
58 const token = await deviceCodeFlow(config);
59 config.token = token;
60 await saveConfig(config);
61 outro("Authenticated! Token saved to ~/.grove/config.json");
62 } catch (err: any) {
63 log.error(err.message);
64 process.exit(1);
65 }
66 return;
67 }
68
69 // Browser-based flow for local environments
70 const { port, result } = await waitForAuthCallback();
71
72 const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`);
73 const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`;
74
75 log.info(`If the browser doesn't open, visit:\n${authUrl}`);
76
77 const open = (await import("open")).default;
78 await open(authUrl);
79
80 const s = spinner();
81 s.start("Waiting for authentication in browser");
82
83 try {
84 const { token } = await result;
85 s.stop("Authentication received");
86 config.token = token;
87 await saveConfig(config);
88 outro("Authenticated! Token saved to ~/.grove/config.json");
89 } catch (err: any) {
90 s.stop("Authentication failed");
91 log.error(err.message);
92 process.exit(1);
93 }
94}
95