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 = ( path: string, init?: ApiRequestInit, ) => Promise; 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 { 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 { 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 { const response = await request("/users"); return renderUsers(response.items); } export async function addUser( username: string, password: string, roles: UserRole[], request: UsersApiRequest = apiRequest, ): Promise { const body: CreateUserRequest = { username, password, roles }; const user = await request("/users", { method: "POST", json: body, }); return renderUsers([user]); } export async function updateUser( username: string, update: UpdateUserRequest, request: UsersApiRequest = apiRequest, ): Promise { if (Object.keys(update).length === 0) throw new Error("No user updates specified"); const user = await request( `/users/${encodeURIComponent(username)}`, { method: "PATCH", json: update }, ); return renderUsers([user]); } export function setUserDisabled( username: string, disabled: boolean, request: UsersApiRequest = apiRequest, ): Promise { return updateUser(username, { disabled }, request); } export async function deleteUser( username: string, confirmed: boolean, request: UsersApiRequest = apiRequest, ): Promise { if (!confirmed) return "Deletion cancelled"; await request(`/users/${encodeURIComponent(username)}`, { method: "DELETE", }); return `Deleted user ${username}`; } export async function revokeUserSessions( username: string, request: UsersApiRequest = apiRequest, ): Promise { 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, }, });