diff --git a/.gitignore b/.gitignore index 0f8796c3..3d9df006 100644 --- a/.gitignore +++ b/.gitignore @@ -179,4 +179,5 @@ glitch-old glitch glitch.tar.gz glitch-dev -*.pem \ No newline at end of file +*.pem +oclif.manifest.json \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index bcb09639..d79453ac 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle/db.ts b/drizzle/db.ts index 2de45337..485bba4c 100644 --- a/drizzle/db.ts +++ b/drizzle/db.ts @@ -1,4 +1,4 @@ -import { config } from "config-manager"; +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"; @@ -13,7 +13,10 @@ export const client = new Client({ database: config.database.database, }); -export const setupDatabase = async (logger: LogManager | MultiLogManager) => { +export const setupDatabase = async ( + logger: LogManager | MultiLogManager, + info = true, +) => { try { await client.connect(); } catch (e) { @@ -28,7 +31,8 @@ export const setupDatabase = async (logger: LogManager | MultiLogManager) => { } // Migrate the database - await logger.log(LogLevel.INFO, "Database", "Migrating database..."); + info && + (await logger.log(LogLevel.INFO, "Database", "Migrating database...")); try { await migrate(db, { @@ -44,7 +48,7 @@ export const setupDatabase = async (logger: LogManager | MultiLogManager) => { process.exit(1); } - await logger.log(LogLevel.INFO, "Database", "Database migrated"); + info && (await logger.log(LogLevel.INFO, "Database", "Database migrated")); }; export const db = drizzle(client, { schema }); diff --git a/package.json b/package.json index 752e6767..c746cfbd 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 cli.ts", + "cli": "bun run packages/cli/bin/run.ts", "prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'" }, "trustedDependencies": [ diff --git a/packages/cli/base.ts b/packages/cli/base.ts new file mode 100644 index 00000000..b01bd5be --- /dev/null +++ b/packages/cli/base.ts @@ -0,0 +1,14 @@ +import { Command } from "@oclif/core"; + +export abstract class BaseCommand extends Command { + protected async init(): Promise { + await super.init(); + + const { setupDatabase } = await import("~drizzle/db"); + const { consoleLogger } = await import("@loggers"); + + (async () => { + await setupDatabase(consoleLogger, false); + })(); + } +} diff --git a/packages/cli/bin/dev.cmd b/packages/cli/bin/dev.cmd new file mode 100644 index 00000000..af5277ed --- /dev/null +++ b/packages/cli/bin/dev.cmd @@ -0,0 +1,3 @@ +@echo off + +bun "%~dp0\dev" %* diff --git a/packages/cli/bin/dev.ts b/packages/cli/bin/dev.ts new file mode 100755 index 00000000..9f0c6d5d --- /dev/null +++ b/packages/cli/bin/dev.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env -S bun + +import { execute } from "@oclif/core"; + +await execute({ development: true, dir: import.meta.url }); diff --git a/packages/cli/bin/run.cmd b/packages/cli/bin/run.cmd new file mode 100644 index 00000000..fbb3ae58 --- /dev/null +++ b/packages/cli/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +bun "%~dp0\run" %* diff --git a/packages/cli/bin/run.ts b/packages/cli/bin/run.ts new file mode 100755 index 00000000..bce9512c --- /dev/null +++ b/packages/cli/bin/run.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env bun + +import { execute } from "@oclif/core"; + +await execute({ dir: import.meta.url }); diff --git a/packages/cli/classes.ts b/packages/cli/classes.ts new file mode 100644 index 00000000..65028645 --- /dev/null +++ b/packages/cli/classes.ts @@ -0,0 +1,96 @@ +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"] +>; +export type ArgsType = Interfaces.InferredArgs< + T["args"] +>; + +export abstract class UserFinderCommand< + T extends typeof BaseCommand, +> extends BaseCommand { + static baseFlags = { + pattern: Flags.boolean({ + char: "p", + description: + "Process as a wildcard pattern (don't forget to escape)", + }), + type: Flags.string({ + char: "t", + description: "Type of identifier", + options: ["id", "username", "note", "display-name", "email"], + default: "id", + }), + limit: Flags.integer({ + char: "n", + description: "Limit the number of users", + default: 100, + }), + print: Flags.boolean({ + allowNo: true, + default: true, + char: "P", + description: "Print user(s) found before processing", + }), + }; + + static baseArgs = { + identifier: Args.string({ + description: + "Identifier of the user (by default this must be an ID)", + required: true, + }), + }; + + protected flags!: FlagsType; + protected args!: ArgsType; + + public async init(): Promise { + await super.init(); + const { args, flags } = await this.parse({ + flags: this.ctor.flags, + baseFlags: (super.ctor as typeof BaseCommand).baseFlags, + args: this.ctor.args, + strict: this.ctor.strict, + }); + this.flags = flags as FlagsType; + this.args = args as ArgsType; + } + + public async findUsers(): Promise { + const operator = this.flags.pattern ? like : eq; + // Replace wildcards with an SQL LIKE pattern + const identifier = this.flags.pattern + ? this.args.identifier.replace(/\*/g, "%") + : this.args.identifier; + + const { User } = await import("~packages/database-interface/user"); + + return await User.manyFromSql( + and( + this.flags.type === "id" + ? operator(Users.id, identifier) + : undefined, + this.flags.type === "username" + ? operator(Users.username, identifier) + : undefined, + this.flags.type === "note" + ? operator(Users.note, identifier) + : undefined, + this.flags.type === "display-name" + ? operator(Users.displayName, identifier) + : undefined, + this.flags.type === "email" + ? operator(Users.email, identifier) + : undefined, + ), + undefined, + this.flags.limit, + ); + } +} diff --git a/packages/cli/commands/user/delete.ts b/packages/cli/commands/user/delete.ts new file mode 100644 index 00000000..c4c6eef5 --- /dev/null +++ b/packages/cli/commands/user/delete.ts @@ -0,0 +1,90 @@ +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"; + +export default class UserDelete extends UserFinderCommand { + 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 { + 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); + } +} diff --git a/packages/cli/commands/user/list.ts b/packages/cli/commands/user/list.ts new file mode 100644 index 00000000..e988a9ff --- /dev/null +++ b/packages/cli/commands/user/list.ts @@ -0,0 +1,83 @@ +import { Flags } from "@oclif/core"; +import { and, eq, isNotNull, isNull } from "drizzle-orm"; +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 { + 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 { + 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); + } +} diff --git a/packages/cli/commands/user/reset.ts b/packages/cli/commands/user/reset.ts new file mode 100644 index 00000000..02cf873e --- /dev/null +++ b/packages/cli/commands/user/reset.ts @@ -0,0 +1,114 @@ +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"; + +export default class UserReset extends UserFinderCommand { + 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 { + 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); + } +} diff --git a/packages/cli/index.ts b/packages/cli/index.ts new file mode 100644 index 00000000..ff4eec50 --- /dev/null +++ b/packages/cli/index.ts @@ -0,0 +1,9 @@ +/* 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 new file mode 100644 index 00000000..712e0e09 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,40 @@ +{ + "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/cli/theme.json b/packages/cli/theme.json new file mode 100644 index 00000000..4d77c5ec --- /dev/null +++ b/packages/cli/theme.json @@ -0,0 +1,15 @@ +{ + "bin": "white", + "command": "cyan", + "commandSummary": "white", + "dollarSign": "white", + "flag": "white", + "flagDefaultValue": "blue", + "flagOptions": "white", + "flagRequired": "red", + "flagSeparator": "white", + "sectionDescription": "white", + "sectionHeader": "underline", + "topic": "white", + "version": "green" +} diff --git a/packages/cli/utils/format.ts b/packages/cli/utils/format.ts new file mode 100644 index 00000000..c329c988 --- /dev/null +++ b/packages/cli/utils/format.ts @@ -0,0 +1,70 @@ +import chalk from "chalk"; +import { getBorderCharacters, table } from "table"; + +/** + * Given a JS array, return a string output to be passed to console.log + * @param arr The array to be formatted + * @param keys The keys to be displayed (removes all other keys from the output) + * @param type Either "json", "csv" or nothing for a table + * @returns The formatted string + */ +export const formatArray = ( + arr: Record[], + keys: string[], + type?: "json" | "csv", + prettyDates = false, +): string => { + const output = arr.map((item) => { + const newItem = {} as Record; + + for (const key of keys) { + newItem[key] = item[key]; + } + + return newItem; + }); + + if (prettyDates) { + for (const item of output) { + for (const key of keys) { + const value = item[key]; + // If this is an ISO string, convert it to a nice date + if ( + typeof value === "string" && + value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}$/) + ) { + item[key] = Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(new Date(value)); + // Format using Chalk + item[key] = chalk.underline(item[key]); + } + } + } + } + + switch (type) { + case "json": + return JSON.stringify(output, null, 2); + case "csv": + return `${keys.join(",")}\n${output + .map((item) => keys.map((key) => item[key]).join(",")) + .join("\n")}`; + default: + // Convert output to array of arrays for table + return table( + [ + keys.map((k) => chalk.bold(k)), + ...output.map((item) => keys.map((key) => item[key])), + ], + { + border: getBorderCharacters("norc"), + }, + ); + } +}; diff --git a/packages/config-manager/package.json b/packages/config-manager/package.json index 97c2dac6..28fbe8c5 100644 --- a/packages/config-manager/package.json +++ b/packages/config-manager/package.json @@ -2,9 +2,8 @@ "name": "config-manager", "version": "0.0.0", "main": "index.ts", + "type": "module", "dependencies": { - "@iarna/toml": "^2.2.5", - "c12": "^1.10.0", - "merge-deep-ts": "^1.2.6" + "c12": "^1.10.0" } } diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index 264c857c..0ffb5c54 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -144,6 +144,12 @@ export class User { )[0].count; } + async delete() { + return ( + await db.delete(Users).where(eq(Users.id, this.id)).returning() + )[0]; + } + async pin(note: Note) { return ( await db diff --git a/tsconfig.json b/tsconfig.json index ae93c9d7..f180cbe8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,6 +35,7 @@ "*.d.ts", "**/*.ts", "**/*.d.ts", - "server/api/well-known/**/*.ts" + "server/api/well-known/**/*.ts", + "packages/cli/index.mts" ] } diff --git a/utils/loggers.ts b/utils/loggers.ts index af76f102..e0f4a557 100644 --- a/utils/loggers.ts +++ b/utils/loggers.ts @@ -1,4 +1,4 @@ -import { config } from "config-manager"; +import { config } from "~packages/config-manager"; import { LogManager, MultiLogManager } from "log-manager"; const noColors = process.env.NO_COLORS === "true";