feat: v2
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user