209 lines
5.4 KiB
TypeScript
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(", ")})`);
|
|
},
|
|
});
|