Files
kuber/command/users.ts
T
2026-09-03 11:28:30 +07:00

254 lines
7.0 KiB
TypeScript

import { stdin, stdout } from "node:process";
import { createInterface } from "node:readline/promises";
import { defineCommand } from "citty";
import { apiRequest, type ApiRequestInit } from "../lib/api";
import { toTable } from "../lib/format";
import type {
CreateUserRequest,
ListUsersResponse,
UpdateUserRequest,
User,
UserResponse,
UserRole,
} from "../shared/api";
export type UsersApiRequest = <T>(
path: string,
init?: ApiRequestInit,
) => Promise<T>;
function requireUsername(value: unknown): string {
const username = String(value ?? "").trim();
if (!username) throw new Error("Username is required");
return username;
}
function parseRoles(value: unknown): UserRole[] {
const roles = String(value ?? "")
.split(",")
.map((role) => role.trim())
.filter(Boolean);
if (roles.length === 0) {
throw new Error(
"At least one role is required (for example: --roles admin)",
);
}
const invalid = roles.find(
(role) => !["viewer", "operator", "admin"].includes(role),
);
if (invalid) throw new Error(`Unknown role: ${invalid}`);
return [...new Set(roles)];
}
function renderUsers(users: User[]): string {
if (users.length === 0) return "No users";
return toTable(
users.map((user) => ({
username: user.username,
roles: user.roles.join(","),
disabled: user.disabled ? "yes" : "no",
updated: user.updatedAt,
})),
);
}
async function promptPassword(label = "Password: "): Promise<string> {
if (!stdin.isTTY || !stdin.setRawMode) {
throw new Error("Password input requires an interactive terminal");
}
stdout.write(label);
stdin.setRawMode(true);
stdin.resume();
return new Promise((resolve, reject) => {
let password = "";
const cleanup = () => {
stdin.off("data", onData);
stdin.setRawMode(false);
stdin.pause();
stdout.write("\n");
};
const finish = (error?: Error) => {
cleanup();
if (error) reject(error);
else if (!password) reject(new Error("Password is required"));
else resolve(password);
};
const onData = (chunk: Buffer | string) => {
for (const value of chunk.toString()) {
if (value === "\u0003" || value === "\u0004") {
finish(new Error("Password input cancelled"));
return;
}
if (value === "\r" || value === "\n") {
finish();
return;
}
if (value === "\u007f" || value === "\b")
password = password.slice(0, -1);
else password += value;
}
};
stdin.on("data", onData);
});
}
async function confirmDeletion(username: string): Promise<boolean> {
if (!stdin.isTTY) {
throw new Error("Confirmation requires an interactive terminal; use --yes");
}
const readline = createInterface({ input: stdin, output: stdout });
try {
const answer = await readline.question(`Delete user '${username}'? [y/N] `);
return answer.trim().toLowerCase() === "y";
} finally {
readline.close();
}
}
export async function listUsers(
request: UsersApiRequest = apiRequest,
): Promise<string> {
const response = await request<ListUsersResponse>("/users");
return renderUsers(response.items);
}
export async function addUser(
username: string,
password: string,
roles: UserRole[],
request: UsersApiRequest = apiRequest,
): Promise<string> {
const body: CreateUserRequest = { username, password, roles };
const user = await request<UserResponse>("/users", {
method: "POST",
json: body,
});
return renderUsers([user]);
}
export async function updateUser(
username: string,
update: UpdateUserRequest,
request: UsersApiRequest = apiRequest,
): Promise<string> {
if (Object.keys(update).length === 0)
throw new Error("No user updates specified");
const user = await request<UserResponse>(
`/users/${encodeURIComponent(username)}`,
{ method: "PATCH", json: update },
);
return renderUsers([user]);
}
export function setUserDisabled(
username: string,
disabled: boolean,
request: UsersApiRequest = apiRequest,
): Promise<string> {
return updateUser(username, { disabled }, request);
}
export async function deleteUser(
username: string,
confirmed: boolean,
request: UsersApiRequest = apiRequest,
): Promise<string> {
if (!confirmed) return "Deletion cancelled";
await request<void>(`/users/${encodeURIComponent(username)}`, {
method: "DELETE",
});
return `Deleted user ${username}`;
}
export async function revokeUserSessions(
username: string,
request: UsersApiRequest = apiRequest,
): Promise<string> {
const result = await request<{ username: string; revoked: number }>(
`/users/${encodeURIComponent(username)}/sessions/revoke`,
{ method: "POST" },
);
return `Revoked ${result.revoked} session${result.revoked === 1 ? "" : "s"} for ${result.username}`;
}
const list = defineCommand({
meta: { name: "ls", description: "List users" },
async run() {
console.log(await listUsers());
},
});
const add = defineCommand({
meta: { name: "add", description: "Add a user" },
args: {
roles: { type: "string", description: "Comma-separated roles" },
},
async run({ args }) {
const username = requireUsername(args._[0]);
console.log(
await addUser(username, await promptPassword(), parseRoles(args.roles)),
);
},
});
const update = defineCommand({
meta: { name: "update", description: "Update a user's roles or password" },
args: {
roles: { type: "string", description: "Comma-separated roles" },
password: { type: "boolean", description: "Prompt for a new password" },
},
async run({ args }) {
const username = requireUsername(args._[0]);
const body: UpdateUserRequest = {};
if (args.roles !== undefined) body.roles = parseRoles(args.roles);
if (args.password) body.password = await promptPassword("New password: ");
console.log(await updateUser(username, body));
},
});
function disabledCommand(name: "disable" | "enable", disabled: boolean) {
return defineCommand({
meta: { name, description: `${disabled ? "Disable" : "Enable"} a user` },
async run({ args }) {
console.log(await setUserDisabled(requireUsername(args._[0]), disabled));
},
});
}
const remove = defineCommand({
meta: { name: "delete", description: "Delete a user" },
args: {
yes: {
type: "boolean",
alias: "y",
description: "Delete without confirmation",
},
},
async run({ args }) {
const username = requireUsername(args._[0]);
const confirmed = Boolean(args.yes) || (await confirmDeletion(username));
console.log(await deleteUser(username, confirmed));
},
});
const revoke = defineCommand({
meta: { name: "revoke", description: "Revoke all sessions for a user" },
async run({ args }) {
console.log(await revokeUserSessions(requireUsername(args._[0])));
},
});
export const users = defineCommand({
meta: { name: "users", description: "Administer users" },
subCommands: {
add,
delete: remove,
disable: disabledCommand("disable", true),
enable: disabledCommand("enable", false),
ls: list,
revoke,
update,
},
});