feat(cli): Add new CLI commands, move to project root

This commit is contained in:
Jesse Wierzbinski 2024-05-08 00:10:14 +00:00
parent 68f16f9101
commit fc06b35c09
No known key found for this signature in database
21 changed files with 332 additions and 67 deletions

162
cli/commands/user/create.ts Normal file
View file

@ -0,0 +1,162 @@
import input from "@inquirer/input";
import { Args, Flags } from "@oclif/core";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import { renderUnicodeCompact } from "uqr";
import { BaseCommand } from "~cli/base";
import { formatArray } from "~cli/utils/format";
import { Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export default class UserCreate extends BaseCommand<typeof UserCreate> {
static override args = {
username: Args.string({
description: "Username",
required: true,
}),
};
static override description = "Creates a new user";
static override examples = [
"<%= config.bin %> <%= command.id %> johngastron --email joe@gamer.com",
"<%= config.bin %> <%= command.id %> bimbobaggins",
];
static override flags = {
format: Flags.string({
char: "f",
description:
"Output format (when set, no password reset link is generated)",
options: ["json", "csv"],
}),
admin: Flags.boolean({
char: "a",
description: "Admin user",
allowNo: true,
default: false,
}),
email: Flags.string({
char: "e",
description: "Email",
}),
"verify-email": Flags.boolean({
description: "Send email verification",
default: true,
allowNo: true,
}),
"set-password": Flags.boolean({
description: "Type password instead of getting a reset link",
default: false,
exclusive: ["format"],
}),
};
public async run(): Promise<void> {
const { flags, args } = await this.parse(UserCreate);
// Check if user already exists
const existingUser = await User.fromSql(
eq(Users.username, args.username),
);
if (existingUser) {
this.log(
`${chalk.red("✗")} User ${chalk.red(
args.username,
)} already exists`,
);
this.exit(1);
}
let password = null;
if (flags["set-password"]) {
const password1 = await input({
message: "Please enter the user's password:",
// Set whatever the user types to stars
transformer: (value) => "*".repeat(value.length),
});
const password2 = await input({
message: "Please confirm the user's password:",
// Set whatever the user types to stars
transformer: (value) => "*".repeat(value.length),
});
if (password1 !== password2) {
this.log(
`${chalk.red(
"✗",
)} Passwords do not match. Please try again.`,
);
this.exit(1);
}
password = password1;
}
// TODO: Add password resets
const user = await User.fromDataLocal({
email: flags.email ?? undefined,
password: password ?? undefined,
username: args.username,
admin: flags.admin,
skipPasswordHash: !!password,
});
if (!user) {
this.log(
`${chalk.red("✗")} Failed to create user ${chalk.red(
args.username,
)}`,
);
this.exit(1);
}
!flags.format &&
this.log(
`${chalk.green("✓")} Created user ${chalk.green(
user.getUser().username,
)} with id ${chalk.green(user.id)}`,
);
this.log(
formatArray(
[user.getUser()],
[
"id",
"username",
"displayName",
"createdAt",
"updatedAt",
"isAdmin",
],
flags.format as "json" | "csv" | undefined,
),
);
if (!flags.format && !flags["set-password"]) {
const link = "";
this.log(
flags.format
? link
: `\nPassword reset link for ${chalk.bold(
`@${user.getUser().username}`,
)}: ${chalk.underline(chalk.blue(link))}\n`,
);
const qrcode = renderUnicodeCompact(link, {
border: 2,
});
// Pad all lines of QR code with spaces
this.log(` ${qrcode.replaceAll("\n", "\n ")}`);
}
this.exit(0);
}
}

View file

@ -0,0 +1,90 @@
import confirm from "@inquirer/confirm";
import { Flags } from "@oclif/core";
import chalk from "chalk";
import ora from "ora";
import { UserFinderCommand } from "~cli/classes";
import { formatArray } from "~cli/utils/format";
export default class UserDelete extends UserFinderCommand<typeof UserDelete> {
static override description = "Deletes users";
static override examples = [
"<%= config.bin %> <%= command.id %> johngastron --type username",
"<%= config.bin %> <%= command.id %> 018ec11c-c6cb-7a67-bd20-a4c81bf42912",
'<%= config.bin %> <%= command.id %> "*badword*" --pattern --type username',
];
static override flags = {
confirm: Flags.boolean({
description:
"Ask for confirmation before deleting the user (default yes)",
allowNo: true,
default: true,
}),
};
static override args = {
identifier: UserFinderCommand.baseArgs.identifier,
};
public async run(): Promise<void> {
const { flags, args } = await this.parse(UserDelete);
const users = await this.findUsers();
if (!users || users.length === 0) {
this.log(chalk.bold(`${chalk.red("✗")} No users found`));
this.exit(1);
}
// Display user
flags.print &&
this.log(
chalk.bold(
`${chalk.green("✓")} Found ${chalk.green(
users.length,
)} user(s)`,
),
);
flags.print &&
this.log(
formatArray(
users.map((u) => u.getUser()),
[
"id",
"username",
"displayName",
"createdAt",
"updatedAt",
"isAdmin",
],
),
);
if (flags.confirm) {
const choice = await confirm({
message: `Are you sure you want to delete these users? ${chalk.red(
"This is irreversible.",
)}`,
});
if (!choice) {
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
return this.exit(1);
}
}
const spinner = ora("Deleting user(s)").start();
for (const user of users) {
await user.delete();
}
spinner.stop();
this.log(chalk.bold(`${chalk.green("✓")} User(s) deleted`));
this.exit(0);
}
}

83
cli/commands/user/list.ts Normal file
View file

@ -0,0 +1,83 @@
import { Flags } from "@oclif/core";
import { and, eq, isNotNull, isNull } from "drizzle-orm";
import { BaseCommand } from "~cli/base";
import { formatArray } from "~cli/utils/format";
import { Users } from "~drizzle/schema";
import { User } from "~packages/database-interface/user";
export default class UserList extends BaseCommand<typeof UserList> {
static override args = {};
static override description = "List all users";
static override examples = [
"<%= config.bin %> <%= command.id %> --format json --local",
"<%= config.bin %> <%= command.id %>",
];
static override flags = {
format: Flags.string({
char: "f",
description: "Output format",
options: ["json", "csv"],
}),
local: Flags.boolean({
char: "l",
description: "Local users only",
exclusive: ["remote"],
}),
remote: Flags.boolean({
char: "r",
description: "Remote users only",
exclusive: ["local"],
}),
limit: Flags.integer({
char: "n",
description: "Limit the number of users",
default: 200,
}),
admin: Flags.boolean({
char: "a",
description: "Admin users only",
allowNo: true,
}),
"pretty-dates": Flags.boolean({
char: "p",
description: "Pretty print dates",
}),
};
public async run(): Promise<void> {
const { flags } = await this.parse(UserList);
const users = await User.manyFromSql(
and(
flags.local ? isNull(Users.instanceId) : undefined,
flags.remote ? isNotNull(Users.instanceId) : undefined,
flags.admin ? eq(Users.isAdmin, flags.admin) : undefined,
),
undefined,
flags.limit,
);
const keys = [
"id",
"username",
"displayName",
"createdAt",
"updatedAt",
"isAdmin",
];
this.log(
formatArray(
users.map((u) => u.getUser()),
keys,
flags.format as "json" | "csv" | undefined,
flags["pretty-dates"],
),
);
this.exit(0);
}
}

114
cli/commands/user/reset.ts Normal file
View file

@ -0,0 +1,114 @@
import confirm from "@inquirer/confirm";
import { Flags } from "@oclif/core";
import chalk from "chalk";
import { renderUnicodeCompact } from "uqr";
import { UserFinderCommand } from "~cli/classes";
import { formatArray } from "~cli/utils/format";
export default class UserReset extends UserFinderCommand<typeof UserReset> {
static override description = "Resets users' passwords";
static override examples = [
"<%= config.bin %> <%= command.id %> johngastron --type username",
"<%= config.bin %> <%= command.id %> 018ec11c-c6cb-7a67-bd20-a4c81bf42912",
];
static override flags = {
confirm: Flags.boolean({
description:
"Ask for confirmation before deleting the user (default yes)",
allowNo: true,
default: true,
}),
limit: Flags.integer({
char: "n",
description: "Limit the number of users",
default: 1,
}),
raw: Flags.boolean({
description:
"Only output the password reset link (implies --no-print and --no-confirm)",
}),
};
static override args = {
identifier: UserFinderCommand.baseArgs.identifier,
};
public async run(): Promise<void> {
const { flags, args } = await this.parse(UserReset);
const users = await this.findUsers();
if (!users || users.length === 0) {
this.log(chalk.bold(`${chalk.red("✗")} No users found`));
this.exit(1);
}
// Display user
!flags.raw &&
this.log(
chalk.bold(
`${chalk.green("✓")} Found ${chalk.green(
users.length,
)} user(s)`,
),
);
!flags.raw &&
flags.print &&
this.log(
formatArray(
users.map((u) => u.getUser()),
[
"id",
"username",
"displayName",
"createdAt",
"updatedAt",
"isAdmin",
],
),
);
if (flags.confirm && !flags.raw) {
const choice = await confirm({
message: `Reset these users's passwords? ${chalk.red(
"This is irreversible.",
)}`,
});
if (!choice) {
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
return this.exit(1);
}
}
const link = "https://example.com/reset-password";
!flags.raw &&
this.log(
`${chalk.green("✓")} Password reset for ${
users.length
} user(s)`,
);
this.log(
flags.raw
? link
: `\nPassword reset link for ${chalk.bold(
"@testuser",
)}: ${chalk.underline(chalk.blue(link))}\n`,
);
const qrcode = renderUnicodeCompact(link, {
border: 2,
});
// Pad all lines of QR code with spaces
!flags.raw && this.log(` ${qrcode.replaceAll("\n", "\n ")}`);
this.exit(0);
}
}