import { defineCommand } from "citty"; import Enquirer from "enquirer"; import { stdin } from "node:process"; import { apiRequest } from "../lib/api"; import { readSession, removeSessions, writeSession, type KuberSession, } from "../lib/session"; type LoginResponse = KuberSession; type Credentials = { username: string; password: string }; type AuthChoice = { name: string; input: string; value: string; initial?: string; cursor?: number; }; export interface LoginPrompt { choices: AuthChoice[]; values: Credentials; state: { submitted: boolean; cancelled: boolean; closed: boolean }; initialize(): Promise; render(): Promise; submit(): Promise; cancel(): Promise; run(): Promise; } // Enquirer's declarations omit its runtime BasicAuth factory. const BasicAuth = ( Enquirer as typeof Enquirer & { BasicAuth: { create( authenticate: (values: Credentials) => Promise, ): new (options: Record) => LoginPrompt; }; } ).BasicAuth; /** Actual BasicAuth prompt; injectable authentication keeps tests offline. */ export function createLoginPrompt( username = "", persistent = false, authenticate: typeof loginUser = loginUser, options: { show?: boolean; stdout?: NodeJS.WriteStream } = {}, ): LoginPrompt { let failure: unknown; let authentication: Promise | undefined; const Auth = BasicAuth.create((values) => { // Repeated Enter presses must never issue a second login or session write. authentication ??= (async () => { try { const name = values.username.trim(); if (!name) throw new Error("Username is required"); return await authenticate(name, values.password, persistent); } catch (error) { // BasicAuth doesn't forward rejected authenticate callbacks to run(). failure = error; return false as const; } })(); return authentication; }); class KuberBasicAuth extends Auth { private submitting = false; override async initialize() { await super.initialize(); const choice = this.choices.find((item) => item.name === "username"); if (choice) { choice.input = choice.value = username; choice.cursor = username.length; } await this.render(); } override async submit() { if (this.submitting || this.state.closed) return; this.submitting = true; await super.submit(); } override async render() { if (this.state.submitted) { // FormPrompt skips choice.format on submission and cancellation. const password = this.choices.find( (choice) => choice.name === "password", ); if (password) password.input = password.value = password.initial = ""; this.values.password = ""; } await super.render(); } override async run(): Promise { let session: LoginResponse; try { session = await super.run(); } catch { throw new Error("Login cancelled"); } if (failure !== undefined) throw failure; return session; } } return new KuberBasicAuth({ name: "login", message: "Log in to kuber.astrxl.dev", initial: { username }, showPassword: false, ...options, }); } export async function loginUser( username: string, password: string, persistent: boolean, ): Promise { const session = await apiRequest( "/login", { method: "POST", body: JSON.stringify({ username, password, persistent }), }, { authenticated: false }, ); await writeSession(session, persistent); return session; } /** Share the CLI's credential prompt with onboarding without exposing passwords. */ export async function interactiveLogin( username = "", persistent = false, dependencies: { isTTY?: boolean; prompt?: typeof createLoginPrompt; } = {}, ): Promise { if (!(dependencies.isTTY ?? stdin.isTTY)) throw new Error( "Login requires an interactive terminal; run kuber login first", ); const session = await (dependencies.prompt ?? createLoginPrompt)( username.trim(), persistent, ).run(); console.log(`Logged in as ${session.user.username}`); return session; } export const login = defineCommand({ meta: { name: "login", description: "Log in to kuber.astrxl.dev", }, args: { persist: { type: "boolean", description: "Keep the login across reboots", }, }, async run({ args }) { await interactiveLogin(String(args._[0] ?? ""), Boolean(args.persist)); }, }); export const logout = defineCommand({ meta: { name: "logout", description: "Log out of kuber.astrxl.dev", }, async run() { const session = await readSession(); if (session) { try { await apiRequest( "/logout", { method: "POST", }, { session }, ); } finally { await removeSessions(); } } else { await removeSessions(); } console.log("Logged out"); }, }); export const whoami = defineCommand({ meta: { name: "whoami", description: "Show the current kuber user", }, async run() { const result = await apiRequest("/me"); console.log(`${result.username} (${result.roles.join(", ")})`); }, });