feat(cli): ♻️ Begin new CLI rewrite with oclif

This commit is contained in:
Jesse Wierzbinski 2024-05-07 07:41:02 +00:00
parent 7b05a34cce
commit 06c30b8af2
No known key found for this signature in database
21 changed files with 569 additions and 11 deletions

1
.gitignore vendored
View file

@ -180,3 +180,4 @@ glitch
glitch.tar.gz glitch.tar.gz
glitch-dev glitch-dev
*.pem *.pem
oclif.manifest.json

BIN
bun.lockb

Binary file not shown.

View file

@ -1,4 +1,4 @@
import { config } from "config-manager"; import { config } from "~/packages/config-manager";
import { drizzle } from "drizzle-orm/node-postgres"; import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/postgres-js/migrator"; import { migrate } from "drizzle-orm/postgres-js/migrator";
import { LogLevel, type LogManager, type MultiLogManager } from "log-manager"; import { LogLevel, type LogManager, type MultiLogManager } from "log-manager";
@ -13,7 +13,10 @@ export const client = new Client({
database: config.database.database, database: config.database.database,
}); });
export const setupDatabase = async (logger: LogManager | MultiLogManager) => { export const setupDatabase = async (
logger: LogManager | MultiLogManager,
info = true,
) => {
try { try {
await client.connect(); await client.connect();
} catch (e) { } catch (e) {
@ -28,7 +31,8 @@ export const setupDatabase = async (logger: LogManager | MultiLogManager) => {
} }
// Migrate the database // Migrate the database
await logger.log(LogLevel.INFO, "Database", "Migrating database..."); info &&
(await logger.log(LogLevel.INFO, "Database", "Migrating database..."));
try { try {
await migrate(db, { await migrate(db, {
@ -44,7 +48,7 @@ export const setupDatabase = async (logger: LogManager | MultiLogManager) => {
process.exit(1); 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 }); export const db = drizzle(client, { schema });

View file

@ -35,7 +35,7 @@
"build": "bun run build.ts", "build": "bun run build.ts",
"cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs,glitch,glitch-dev --exclude-ext sql,log,pem", "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=-", "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)'" "prune": "ts-prune | grep -v server/ | grep -v dist/ | grep -v '(used in module)'"
}, },
"trustedDependencies": [ "trustedDependencies": [

14
packages/cli/base.ts Normal file
View file

@ -0,0 +1,14 @@
import { Command } from "@oclif/core";
export abstract class BaseCommand<T extends typeof Command> extends Command {
protected async init(): Promise<void> {
await super.init();
const { setupDatabase } = await import("~drizzle/db");
const { consoleLogger } = await import("@loggers");
(async () => {
await setupDatabase(consoleLogger, false);
})();
}
}

3
packages/cli/bin/dev.cmd Normal file
View file

@ -0,0 +1,3 @@
@echo off
bun "%~dp0\dev" %*

5
packages/cli/bin/dev.ts Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env -S bun
import { execute } from "@oclif/core";
await execute({ development: true, dir: import.meta.url });

3
packages/cli/bin/run.cmd Normal file
View file

@ -0,0 +1,3 @@
@echo off
bun "%~dp0\run" %*

5
packages/cli/bin/run.ts Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bun
import { execute } from "@oclif/core";
await execute({ dir: import.meta.url });

96
packages/cli/classes.ts Normal file
View file

@ -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<T extends typeof Command> = Interfaces.InferredFlags<
(typeof BaseCommand)["baseFlags"] & T["flags"]
>;
export type ArgsType<T extends typeof Command> = Interfaces.InferredArgs<
T["args"]
>;
export abstract class UserFinderCommand<
T extends typeof BaseCommand,
> extends BaseCommand<typeof UserFinderCommand> {
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<T>;
protected args!: ArgsType<T>;
public async init(): Promise<void> {
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<T>;
this.args = args as ArgsType<T>;
}
public async findUsers(): Promise<User[]> {
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,
);
}
}

View file

@ -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<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);
}
}

View file

@ -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<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);
}
}

View file

@ -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<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);
}
}

9
packages/cli/index.ts Normal file
View file

@ -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,
};
*/

40
packages/cli/package.json Normal file
View file

@ -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
}
}

15
packages/cli/theme.json Normal file
View file

@ -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"
}

View file

@ -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<string, unknown>[],
keys: string[],
type?: "json" | "csv",
prettyDates = false,
): string => {
const output = arr.map((item) => {
const newItem = {} as Record<string, unknown>;
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"),
},
);
}
};

View file

@ -2,9 +2,8 @@
"name": "config-manager", "name": "config-manager",
"version": "0.0.0", "version": "0.0.0",
"main": "index.ts", "main": "index.ts",
"type": "module",
"dependencies": { "dependencies": {
"@iarna/toml": "^2.2.5", "c12": "^1.10.0"
"c12": "^1.10.0",
"merge-deep-ts": "^1.2.6"
} }
} }

View file

@ -144,6 +144,12 @@ export class User {
)[0].count; )[0].count;
} }
async delete() {
return (
await db.delete(Users).where(eq(Users.id, this.id)).returning()
)[0];
}
async pin(note: Note) { async pin(note: Note) {
return ( return (
await db await db

View file

@ -35,6 +35,7 @@
"*.d.ts", "*.d.ts",
"**/*.ts", "**/*.ts",
"**/*.d.ts", "**/*.d.ts",
"server/api/well-known/**/*.ts" "server/api/well-known/**/*.ts",
"packages/cli/index.mts"
] ]
} }

View file

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