Files
kuber/command/auth.ts
T
2026-10-07 05:10:59 +00:00

209 lines
5.4 KiB
TypeScript

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<void>;
render(): Promise<void>;
submit(): Promise<void>;
cancel(): Promise<void>;
run(): Promise<LoginResponse>;
}
// Enquirer's declarations omit its runtime BasicAuth factory.
const BasicAuth = (
Enquirer as typeof Enquirer & {
BasicAuth: {
create(
authenticate: (values: Credentials) => Promise<LoginResponse | false>,
): new (options: Record<string, unknown>) => 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<LoginResponse | false> | 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<LoginResponse> {
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<LoginResponse> {
const session = await apiRequest<LoginResponse>(
"/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<LoginResponse> {
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<void>(
"/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<KuberSession["user"]>("/me");
console.log(`${result.username} (${result.roles.join(", ")})`);
},
});