diff --git a/bun.lockb b/bun.lockb index d79453ac..d40c8910 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/cli/base.ts b/cli/base.ts similarity index 100% rename from packages/cli/base.ts rename to cli/base.ts diff --git a/packages/cli/bin/dev.cmd b/cli/bin/dev.cmd similarity index 100% rename from packages/cli/bin/dev.cmd rename to cli/bin/dev.cmd diff --git a/packages/cli/bin/dev.ts b/cli/bin/dev.ts similarity index 100% rename from packages/cli/bin/dev.ts rename to cli/bin/dev.ts diff --git a/packages/cli/bin/run.cmd b/cli/bin/run.cmd similarity index 100% rename from packages/cli/bin/run.cmd rename to cli/bin/run.cmd diff --git a/packages/cli/bin/run.ts b/cli/bin/run.ts similarity index 100% rename from packages/cli/bin/run.ts rename to cli/bin/run.ts diff --git a/packages/cli/classes.ts b/cli/classes.ts similarity index 83% rename from packages/cli/classes.ts rename to cli/classes.ts index 65028645..0d55ddd8 100644 --- a/packages/cli/classes.ts +++ b/cli/classes.ts @@ -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 = Interfaces.InferredFlags< (typeof BaseCommand)["baseFlags"] & T["flags"] @@ -63,6 +64,17 @@ export abstract class UserFinderCommand< } public async findUsers(): Promise { + // 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 diff --git a/cli/commands/emoji/add.ts b/cli/commands/emoji/add.ts new file mode 100644 index 00000000..7550c4c6 --- /dev/null +++ b/cli/commands/emoji/add.ts @@ -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 { + 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 { + 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); + } +} diff --git a/cli/commands/user/create.ts b/cli/commands/user/create.ts new file mode 100644 index 00000000..5bb93ea0 --- /dev/null +++ b/cli/commands/user/create.ts @@ -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 { + 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 { + 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); + } +} diff --git a/packages/cli/commands/user/delete.ts b/cli/commands/user/delete.ts similarity index 95% rename from packages/cli/commands/user/delete.ts rename to cli/commands/user/delete.ts index c4c6eef5..2e8f851f 100644 --- a/packages/cli/commands/user/delete.ts +++ b/cli/commands/user/delete.ts @@ -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 { static override description = "Deletes users"; diff --git a/packages/cli/commands/user/list.ts b/cli/commands/user/list.ts similarity index 95% rename from packages/cli/commands/user/list.ts rename to cli/commands/user/list.ts index e988a9ff..42dac8a0 100644 --- a/packages/cli/commands/user/list.ts +++ b/cli/commands/user/list.ts @@ -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 { diff --git a/packages/cli/commands/user/reset.ts b/cli/commands/user/reset.ts similarity index 96% rename from packages/cli/commands/user/reset.ts rename to cli/commands/user/reset.ts index 02cf873e..653a42d9 100644 --- a/packages/cli/commands/user/reset.ts +++ b/cli/commands/user/reset.ts @@ -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 { static override description = "Resets users' passwords"; diff --git a/cli/index.ts b/cli/index.ts new file mode 100644 index 00000000..ee0cfd48 --- /dev/null +++ b/cli/index.ts @@ -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, +}; diff --git a/packages/cli/theme.json b/cli/theme.json similarity index 100% rename from packages/cli/theme.json rename to cli/theme.json diff --git a/packages/cli/utils/format.ts b/cli/utils/format.ts similarity index 100% rename from packages/cli/utils/format.ts rename to cli/utils/format.ts diff --git a/drizzle/db.ts b/drizzle/db.ts index 485bba4c..63a400ed 100644 --- a/drizzle/db.ts +++ b/drizzle/db.ts @@ -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({ diff --git a/package.json b/package.json index c746cfbd..c3af4a7a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/index.ts b/packages/cli/index.ts deleted file mode 100644 index ff4eec50..00000000 --- a/packages/cli/index.ts +++ /dev/null @@ -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, -}; - */ diff --git a/packages/cli/package.json b/packages/cli/package.json deleted file mode 100644 index 712e0e09..00000000 --- a/packages/cli/package.json +++ /dev/null @@ -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 - } -} diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index 0ffb5c54..bc4511fd 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -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, diff --git a/utils/loggers.ts b/utils/loggers.ts index e0f4a557..dd6b6e0a 100644 --- a/utils/loggers.ts +++ b/utils/loggers.ts @@ -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";