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; private cancelling = 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 cancel() { // Bun may close readline while processing Ctrl+C before Enquirer emits // cancel. Enquirer's close handler must therefore run only once. if (this.cancelling || this.state.closed) return; this.cancelling = true; const stop = (this as unknown as { stop?: () => void }).stop; if (stop) { ( this as unknown as { removeListener(event: string, listener: () => void): void; } ).removeListener("close", stop); try { // Bun's readline may already be closed by Ctrl+C. Enquirer's stop // restores raw mode and removes its keypress handler before calling // pause/close, which would otherwise throw ERR_USE_AFTER_CLOSE. stop(); } catch (error) { // The terminal cleanup above has run; Bun can report // ERR_USE_AFTER_CLOSE because readline was closed by Ctrl+C. if ((error as { code?: string }).code !== "ERR_USE_AFTER_CLOSE") throw error; } } await super.cancel(); } 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 (error) { if (this.state.cancelled) throw new Error("Login cancelled"); throw error; } 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: boolean | undefined = true, 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 ?? true, ).run(); console.log(`Logged in as ${session.user.username}`); return session; } export const login = defineCommand({ meta: { name: "login", description: "Log in to the cluster", }, args: { session: { type: "boolean", description: "Use a temporary session for this runtime", }, }, async run({ args }) { await interactiveLogin(String(args._[0] ?? ""), !Boolean(args.session)); }, }); 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(", ")})`); }, });