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

BIN
bun.lockb

Binary file not shown.

View file

@ -1,8 +1,9 @@
import { Args, type Command, Flags, type Interfaces } from "@oclif/core";
import chalk from "chalk";
import { and, eq, like } from "drizzle-orm";
import { Users } from "~drizzle/schema";
import type { User } from "~packages/database-interface/user";
import { BaseCommand } from "./base";
import { Args, Flags, type Command, type Interfaces } from "@oclif/core";
export type FlagsType<T extends typeof Command> = Interfaces.InferredFlags<
(typeof BaseCommand)["baseFlags"] & T["flags"]
@ -63,6 +64,17 @@ export abstract class UserFinderCommand<
}
public async findUsers(): Promise<User[]> {
// Check if there are asterisks in the identifier but no pattern flag, warn the user if so
if (this.args.identifier.includes("*") && !this.flags.pattern) {
this.log(
chalk.bold(
`${chalk.yellow(
"⚠",
)} Your identifier has asterisks but the --pattern flag is not set. This will match a literal string. If you want to use wildcards, set the --pattern flag.`,
),
);
}
const operator = this.flags.pattern ? like : eq;
// Replace wildcards with an SQL LIKE pattern
const identifier = this.flags.pattern

94
cli/commands/emoji/add.ts Normal file
View file

@ -0,0 +1,94 @@
import { Args } from "@oclif/core";
import chalk from "chalk";
import { BaseCommand } from "~/cli/base";
import { db } from "~drizzle/db";
export default class EmojiAdd extends BaseCommand<typeof EmojiAdd> {
static override args = {
shortcode: Args.string({
description: "Shortcode of the emoji",
required: true,
}),
file: Args.string({
description: "Path to the image file (can be an URL)",
required: true,
}),
};
static override description = "Adds a new emoji";
static override examples = ["<%= config.bin %> <%= command.id %>"];
static override flags = {};
public async run(): Promise<void> {
const { flags, args } = await this.parse(EmojiAdd);
// Check if emoji already exists
const existingEmoji = await db.query.Emojis.findFirst({
where: (Emojis, { eq }) => eq(Emojis.shortcode, args.shortcode),
});
if (existingEmoji) {
this.log(
`${chalk.red("✗")} Emoji with shortcode ${chalk.red(
args.shortcode,
)} already exists`,
);
this.exit(1);
}
/* 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);
}
}

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

@ -1,9 +1,9 @@
import confirm from "@inquirer/confirm";
import { Flags } from "@oclif/core";
import chalk from "chalk";
import { formatArray } from "~packages/cli/utils/format";
import confirm from "@inquirer/confirm";
import ora from "ora";
import { UserFinderCommand } from "~packages/cli/classes";
import { UserFinderCommand } from "~cli/classes";
import { formatArray } from "~cli/utils/format";
export default class UserDelete extends UserFinderCommand<typeof UserDelete> {
static override description = "Deletes users";

View file

@ -1,8 +1,8 @@
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 { BaseCommand } from "~packages/cli/base";
import { formatArray } from "~packages/cli/utils/format";
import { User } from "~packages/database-interface/user";
export default class UserList extends BaseCommand<typeof UserList> {

View file

@ -1,9 +1,9 @@
import confirm from "@inquirer/confirm";
import { Flags } from "@oclif/core";
import chalk from "chalk";
import { formatArray } from "~packages/cli/utils/format";
import confirm from "@inquirer/confirm";
import { renderUnicodeCompact } from "uqr";
import { UserFinderCommand } from "~packages/cli/classes";
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";

13
cli/index.ts Normal file
View file

@ -0,0 +1,13 @@
import EmojiAdd from "./commands/emoji/add";
import UserCreate from "./commands/user/create";
import UserDelete from "./commands/user/delete";
import UserList from "./commands/user/list";
import UserReset from "./commands/user/reset";
export const commands = {
"user list": UserList,
"user delete": UserDelete,
"user create": UserCreate,
"user reset": UserReset,
"emoji add": EmojiAdd,
};

View file

@ -1,8 +1,8 @@
import { config } from "~/packages/config-manager";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
import { Client } from "pg";
import { config } from "~/packages/config-manager";
import * as schema from "./schema";
export const client = new Client({

View file

@ -35,7 +35,7 @@
"build": "bun run build.ts",
"cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs,glitch,glitch-dev --exclude-ext sql,log,pem",
"wc": "find server database *.ts docs packages types utils drizzle tests -type f -print0 | wc -m --files0-from=-",
"cli": "bun run packages/cli/bin/run.ts",
"cli": "bun run cli/bin/run.ts",
"prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'"
},
"trustedDependencies": [
@ -51,6 +51,27 @@
"sharp",
"vue-demi"
],
"oclif": {
"bin": "cli",
"dirname": "cli",
"commands": {
"strategy": "explicit",
"target": "./cli/index.ts",
"identifier": "commands"
},
"additionalHelpFlags": ["-h"],
"additionalVersionFlags": ["-v"],
"plugins": ["@oclif/plugin-help"],
"description": "CLI to interface with the Lysand project",
"topicSeparator": " ",
"topics": {
"user": {
"description": "Manage users"
}
},
"theme": "./cli/theme.json",
"flexibleTaxonomy": true
},
"devDependencies": {
"@biomejs/biome": "^1.7.0",
"@types/cli-table": "^0.3.4",
@ -63,12 +84,23 @@
"bun-types": "latest",
"drizzle-kit": "^0.20.14",
"ts-prune": "^0.10.3",
"typescript": "latest"
"typescript": "latest",
"@types/cli-progress": "^3.11.5",
"oclif": "^4.10.4"
},
"peerDependencies": {
"typescript": "^5.3.2"
},
"dependencies": {
"@inquirer/confirm": "^3.1.6",
"@inquirer/input": "^2.1.6",
"@oclif/core": "^3.26.6",
"@oclif/plugin-help": "^6.0.21",
"@oclif/plugin-plugins": "^5.0.19",
"cli-progress": "^3.12.0",
"ora": "^8.0.1",
"table": "^6.8.2",
"uqr": "^0.1.2",
"@hackmd/markdown-it-task-lists": "^2.1.4",
"@hono/zod-validator": "^0.2.1",
"@json2csv/plainjs": "^7.0.6",

View file

@ -1,9 +0,0 @@
/* import { Command } from "@oclif/core";
import UserList from "./commands/user/list";
import UserDelete from "./commands/user/delete";
export const commands = {
"user list": UserList,
"user delete": UserDelete,
};
*/

View file

@ -1,40 +0,0 @@
{
"name": "cli",
"version": "0.0.0",
"type": "module",
"dependencies": {
"@inquirer/confirm": "^3.1.6",
"@oclif/core": "^3.26.6",
"@oclif/plugin-help": "^6.0.21",
"@oclif/plugin-plugins": "^5.0.19",
"chalk": "^5.3.0",
"cli-progress": "^3.12.0",
"ora": "^8.0.1",
"table": "^6.8.2",
"uqr": "^0.1.2"
},
"devDependencies": {
"@types/cli-progress": "^3.11.5",
"oclif": "^4.10.4"
},
"oclif": {
"bin": "cli",
"dirname": "cli",
"commands": {
"strategy": "pattern",
"target": "./commands"
},
"additionalHelpFlags": ["-h"],
"additionalVersionFlags": ["-v"],
"plugins": ["@oclif/plugin-help"],
"description": "CLI to interface with the Lysand project",
"topicSeparator": " ",
"topics": {
"user": {
"description": "Manage users"
}
},
"theme": "./theme.json",
"flexibleTaxonomy": true
}
}

View file

@ -337,8 +337,8 @@ export class User {
static async fromDataLocal(data: {
username: string;
display_name?: string;
password: string;
email: string;
password: string | undefined;
email: string | undefined;
bio?: string;
avatar?: string;
header?: string;
@ -353,9 +353,10 @@ export class User {
.values({
username: data.username,
displayName: data.display_name ?? data.username,
password: data.skipPasswordHash
? data.password
: await Bun.password.hash(data.password),
password:
data.skipPasswordHash || !data.password
? data.password
: await Bun.password.hash(data.password),
email: data.email,
note: data.bio ?? "",
avatar: data.avatar ?? config.defaults.avatar,

View file

@ -1,5 +1,5 @@
import { config } from "~packages/config-manager";
import { LogManager, MultiLogManager } from "log-manager";
import { config } from "~packages/config-manager";
const noColors = process.env.NO_COLORS === "true";
const noFancyDates = process.env.NO_FANCY_DATES === "true";