cli/src/commands/auth-login.tsblame
View source
59e66671import { intro, outro, spinner, log } from "@clack/prompts";
e93a9782import { waitForAuthCallback } from "../auth-server.js";
e93a9783import { loadConfig, saveConfig } from "../config.js";
e93a9784
7010ba95function isHeadless(): boolean {
7010ba96 // SSH session, no display, or explicitly requested
7010ba97 return !!(process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION);
7010ba98}
7010ba99
7010ba910async function deviceCodeFlow(config: { hub: string; token?: string }) {
7010ba911 // Request a device code from the hub
7010ba912 const res = await fetch(`${config.hub}/api/auth/device-code`, { method: "POST" });
7010ba913 if (!res.ok) {
7010ba914 throw new Error(`Failed to start device code flow: ${res.statusText}`);
7010ba915 }
7010ba916 const { code, url } = await res.json() as { code: string; url: string; expires_in: number };
7010ba917
7010ba918 log.info(`Open this URL in your browser:\n\n ${url}\n\nYour code: ${code}`);
7010ba919
7010ba920 const s = spinner();
7010ba921 s.start("Waiting for approval");
7010ba922
7010ba923 // Poll for approval
7010ba924 for (let i = 0; i < 120; i++) {
7010ba925 await new Promise((r) => setTimeout(r, 5000));
7010ba926
7010ba927 const pollRes = await fetch(`${config.hub}/api/auth/device-code/${code}`);
7010ba928 if (!pollRes.ok) {
7010ba929 s.stop("Authentication failed");
7010ba930 throw new Error("Code expired or invalid");
7010ba931 }
7010ba932
7010ba933 const data = await pollRes.json() as { status: string; token?: string };
7010ba934 if (data.status === "complete" && data.token) {
7010ba935 s.stop("Authentication received");
7010ba936 return data.token;
7010ba937 }
7010ba938 }
7010ba939
7010ba940 s.stop("Authentication timed out");
7010ba941 throw new Error("Authentication timed out (10 minutes)");
7010ba942}
7010ba943
e93a97844export async function authLogin(args: string[]) {
e93a97845 const config = await loadConfig();
e93a97846
e93a97847 // Allow --hub flag to override hub URL
e93a97848 const hubIdx = args.indexOf("--hub");
e93a97849 if (hubIdx !== -1 && args[hubIdx + 1]) {
e93a97850 config.hub = args[hubIdx + 1];
e93a97851 }
e93a97852
59e666753 intro("grove auth login");
e93a97854
7010ba955 if (isHeadless()) {
7010ba956 // Device code flow for remote/headless environments
7010ba957 try {
7010ba958 const token = await deviceCodeFlow(config);
7010ba959 config.token = token;
7010ba960 await saveConfig(config);
7010ba961 outro("Authenticated! Token saved to ~/.grove/config.json");
7010ba962 } catch (err: any) {
7010ba963 log.error(err.message);
7010ba964 process.exit(1);
7010ba965 }
7010ba966 return;
7010ba967 }
7010ba968
7010ba969 // Browser-based flow for local environments
ff4354570 const { port, result } = await waitForAuthCallback();
e93a97871
e93a97872 const callbackUrl = encodeURIComponent(`http://localhost:${port}/callback`);
e93a97873 const authUrl = `${config.hub}/cli-auth?callback=${callbackUrl}`;
e93a97874
59e666775 log.info(`If the browser doesn't open, visit:\n${authUrl}`);
e93a97876
e93a97877 const open = (await import("open")).default;
e93a97878 await open(authUrl);
e93a97879
59e666780 const s = spinner();
59e666781 s.start("Waiting for authentication in browser");
e93a97882
e93a97883 try {
e93a97884 const { token } = await result;
59e666785 s.stop("Authentication received");
e93a97886 config.token = token;
e93a97887 await saveConfig(config);
59e666788 outro("Authenticated! Token saved to ~/.grove/config.json");
e93a97889 } catch (err: any) {
59e666790 s.stop("Authentication failed");
59e666791 log.error(err.message);
e93a97892 process.exit(1);
e93a97893 }
e93a97894}