refactor(cli): ♻️ Rewrite CLI with Clerk. Removes a bunch of commands now covered by API.

This commit is contained in:
Jesse Wierzbinski 2025-02-26 00:00:21 +01:00
parent 28577d017a
commit 5b756ea2dd
No known key found for this signature in database
32 changed files with 536 additions and 2721 deletions

View file

@ -25,7 +25,7 @@ await Bun.build({
target: "bun",
splitting: true,
minify: false,
external: ["unzipit", "acorn", "@bull-board/ui"],
external: ["acorn", "@bull-board/ui"],
});
buildSpinner.text = "Transforming";
@ -47,10 +47,6 @@ await $`mkdir -p dist/node_modules/@img`;
await $`cp -r node_modules/@img/sharp-libvips-linuxmusl-* dist/node_modules/@img`;
await $`cp -r node_modules/@img/sharp-linuxmusl-* dist/node_modules/@img`;
// Copy unzipit and uzip-module to dist
await $`cp -r node_modules/unzipit dist/node_modules/unzipit`;
await $`cp -r node_modules/uzip-module dist/node_modules/uzip-module`;
// Copy acorn to dist
await $`cp -r node_modules/acorn dist/node_modules/acorn`;
@ -63,7 +59,5 @@ await $`cp beemovie.txt dist/beemovie.txt`;
// Copy package.json
await $`cp package.json dist/package.json`;
// Copy cli/theme.json
await $`cp cli/theme.json dist/cli/theme.json`;
buildSpinner.stop();

852
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -1,12 +0,0 @@
import { Command } from "@oclif/core";
import { searchManager } from "~/classes/search/search-manager";
import { setupDatabase } from "~/drizzle/db";
export abstract class BaseCommand<_T extends typeof Command> extends Command {
protected async init(): Promise<void> {
await super.init();
await setupDatabase(false);
await searchManager.connect(true);
}
}

View file

@ -1,246 +0,0 @@
import { parseUserAddress, userAddressValidator } from "@/api";
import { Args, type Command, Flags, type Interfaces } from "@oclif/core";
import { Emoji, Instance, User } from "@versia/kit/db";
import { Emojis, Instances, Users } from "@versia/kit/tables";
import chalk from "chalk";
import { and, eq, inArray, like } from "drizzle-orm";
import { BaseCommand } from "./base.ts";
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> {
public 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",
"address",
],
default: "address",
}),
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",
}),
};
public static baseArgs = {
identifier: Args.string({
description:
"Identifier of the user (by default this must be an address, i.e. name@host.com)",
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[]> {
// 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: string = this.flags.pattern
? this.args.identifier.replace(/\*/g, "%")
: this.args.identifier;
if (this.flags.type === "address") {
// Check if the address is valid
if (!userAddressValidator.exec(identifier)) {
this.log(
"Invalid address. Please check the address format and try again. For example: name@host.com",
);
this.exit(1);
}
// Check instance exists, if not, create it
await Instance.resolve(
new URL(`https://${parseUserAddress(identifier).domain}`),
);
}
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,
this.flags.type === "address"
? and(
operator(
Users.username,
parseUserAddress(identifier).username,
),
operator(
Users.instanceId,
(
await Instance.fromSql(
eq(
Instances.baseUrl,
new URL(
`https://${
parseUserAddress(identifier)
.domain
}`,
).host,
),
)
)?.id ?? "",
),
)
: undefined,
),
undefined,
this.flags.limit,
);
}
}
export abstract class EmojiFinderCommand<
T extends typeof BaseCommand,
> extends BaseCommand<typeof EmojiFinderCommand> {
public 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: ["shortcode", "instance"],
default: "shortcode",
}),
limit: Flags.integer({
char: "n",
description: "Limit the number of emojis",
default: 100,
}),
print: Flags.boolean({
allowNo: true,
default: true,
char: "P",
description: "Print emoji(s) found before processing",
}),
};
public static baseArgs = {
identifier: Args.string({
description: "Identifier of the emoji (defaults to shortcode)",
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 findEmojis(): Promise<Emoji[]> {
// 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
? this.args.identifier.replace(/\*/g, "%")
: this.args.identifier;
const instanceIds =
this.flags.type === "instance"
? (
await Instance.manyFromSql(
operator(Instances.baseUrl, identifier),
)
).map((instance) => instance.id)
: undefined;
return await Emoji.manyFromSql(
and(
this.flags.type === "shortcode"
? operator(Emojis.shortcode, identifier)
: undefined,
instanceIds && instanceIds.length > 0
? inArray(Emojis.instanceId, instanceIds)
: undefined,
),
undefined,
this.flags.limit,
);
}
}

View file

@ -1,119 +0,0 @@
import { Args } from "@oclif/core";
import { Emoji, Media } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import chalk from "chalk";
import { and, eq, isNull } from "drizzle-orm";
import ora from "ora";
import { BaseCommand } from "~/cli/base";
import { config } from "~/config.ts";
export default class EmojiAdd extends BaseCommand<typeof EmojiAdd> {
public 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,
}),
};
public static override description = "Adds a new emoji";
public static override examples = [
"<%= config.bin %> <%= command.id %> baba_yassie ./emojis/baba_yassie.png",
"<%= config.bin %> <%= command.id %> baba_yassie https://example.com/emojis/baba_yassie.png",
];
public static override flags = {};
public async run(): Promise<void> {
const { args } = await this.parse(EmojiAdd);
// Check if emoji already exists
const existingEmoji = await Emoji.fromSql(
and(
eq(Emojis.shortcode, args.shortcode),
isNull(Emojis.instanceId),
),
);
if (existingEmoji) {
this.log(
`${chalk.red("✗")} Emoji with shortcode ${chalk.red(
args.shortcode,
)} already exists`,
);
this.exit(1);
}
let file: File | null = null;
if (URL.canParse(args.file)) {
const spinner = ora(
`Downloading emoji from ${chalk.blue(
chalk.underline(args.file),
)}`,
).start();
const response = await fetch(args.file, {
headers: {
"Accept-Encoding": "identity",
},
// @ts-expect-error Proxy is a Bun-specific feature
proxy: config.http.proxy_address,
});
if (!response.ok) {
spinner.fail();
this.log(
`${chalk.red("✗")} Request returned status code ${chalk.red(
response.status,
)}`,
);
this.exit(1);
}
const filename =
new URL(args.file).pathname.split("/").pop() ?? "emoji";
file = new File([await response.blob()], filename, {
type:
response.headers.get("Content-Type") ??
"application/octet-stream",
});
spinner.succeed();
} else {
const bunFile = Bun.file(args.file);
file = new File(
[await bunFile.arrayBuffer()],
args.file.split("/").pop() ?? "emoji",
{
type: bunFile.type,
},
);
}
const spinner = ora("Uploading emoji").start();
const media = await Media.fromFile(file);
spinner.succeed();
await Emoji.insert({
shortcode: args.shortcode,
mediaId: media.id,
visibleInPicker: true,
});
this.log(
`${chalk.green("✓")} Created emoji ${chalk.green(
args.shortcode,
)} with url ${chalk.blue(chalk.underline(media.getUrl()))}`,
);
this.exit(0);
}
}

View file

@ -1,86 +0,0 @@
import confirm from "@inquirer/confirm";
import { Flags } from "@oclif/core";
import chalk from "chalk";
import ora from "ora";
import { EmojiFinderCommand } from "~/cli/classes";
import { formatArray } from "~/cli/utils/format";
export default class EmojiDelete extends EmojiFinderCommand<
typeof EmojiDelete
> {
public static override args = {
identifier: EmojiFinderCommand.baseArgs.identifier,
};
public static override description = "Deletes an emoji";
public static override examples = [
"<%= config.bin %> <%= command.id %> baba_yassie",
'<%= config.bin %> <%= command.id %> "baba\\*" --pattern',
];
public static override flags = {
confirm: Flags.boolean({
description:
"Ask for confirmation before deleting the emoji (default yes)",
allowNo: true,
default: true,
}),
};
public async run(): Promise<void> {
const { flags } = await this.parse(EmojiDelete);
const emojis = await this.findEmojis();
if (!emojis || emojis.length === 0) {
this.log(chalk.bold(`${chalk.red("✗")} No emojis found`));
this.exit(1);
}
// Display user
flags.print &&
this.log(
chalk.bold(
`${chalk.green("✓")} Found ${chalk.green(
emojis.length,
)} emoji(s)`,
),
);
flags.print &&
this.log(
formatArray(
emojis.map((e) => e.data),
["id", "shortcode", "alt", "contentType", "instanceUrl"],
),
);
if (flags.confirm) {
const choice = await confirm({
message: `Are you sure you want to delete these emojis? ${chalk.red(
"This is irreversible.",
)}`,
});
if (!choice) {
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
return this.exit(1);
}
}
const spinner = ora("Deleting emoji(s)").start();
for (const emoji of emojis) {
spinner.text = `Deleting emoji ${chalk.gray(emoji.data.shortcode)} (${
emojis.findIndex((e) => e.id === emoji.id) + 1
}/${emojis.length})`;
await emoji.delete();
}
spinner.succeed("Emoji(s) deleted");
this.exit(0);
}
}

View file

@ -1,235 +0,0 @@
import { Args, Flags } from "@oclif/core";
import { Emoji, Media } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import chalk from "chalk";
import { and, inArray, isNull } from "drizzle-orm";
import { lookup } from "mime-types";
import ora from "ora";
import { unzip } from "unzipit";
import { BaseCommand } from "~/cli/base";
import { config } from "~/config.ts";
type MetaType = {
emojis: {
fileName: string;
emoji: {
name: string;
};
}[];
};
export default class EmojiImport extends BaseCommand<typeof EmojiImport> {
public static override args = {
path: Args.string({
description: "Path to the emoji archive (can be an URL)",
required: true,
}),
};
public static override description =
"Imports emojis from a zip file (which can be fetched from a zip URL, e.g. for Pleroma emoji packs)";
public static override examples = [
"<%= config.bin %> <%= command.id %> https://volpeon.ink/emojis/neocat/neocat.zip",
"<%= config.bin %> <%= command.id %> export.zip",
];
public static override flags = {
confirm: Flags.boolean({
description:
"Ask for confirmation before deleting the emoji (default yes)",
allowNo: true,
default: true,
}),
};
public async run(): Promise<void> {
const { args } = await this.parse(EmojiImport);
// Check if path ends in .zip, warn the user if it doesn't
if (!args.path.endsWith(".zip")) {
this.log(
`${chalk.yellow(
"⚠",
)} The path you provided does not end in .zip, this may not be a zip file. Proceeding anyway.`,
);
}
let file: File | null = null;
if (URL.canParse(args.path)) {
const spinner = ora(
`Downloading pack from ${chalk.blue(
chalk.underline(args.path),
)}`,
).start();
const response = await fetch(args.path, {
headers: {
"Accept-Encoding": "identity",
},
// @ts-expect-error Proxy is a Bun-specific feature
proxy: config.http.proxy_address,
});
if (!response.ok) {
spinner.fail();
this.log(
`${chalk.red("✗")} Request returned status code ${chalk.red(
response.status,
)}`,
);
this.exit(1);
}
const filename =
new URL(args.path).pathname.split("/").pop() ?? "archive";
file = new File([await response.blob()], filename, {
type:
response.headers.get("Content-Type") ??
"application/octet-stream",
});
spinner.succeed();
} else {
const bunFile = Bun.file(args.path);
file = new File(
[await bunFile.arrayBuffer()],
args.path.split("/").pop() ?? "archive",
{
type: bunFile.type,
},
);
}
const unzipSpinner = ora("Unzipping pack").start();
const { entries: unzipped } = await unzip(file);
unzipSpinner.succeed();
const entries = Object.entries(unzipped);
// Check if a meta.json file exists
const metaExists = entries.find(([name]) => name === "meta.json");
if (metaExists) {
this.log(`${chalk.green("✓")} Detected Pleroma meta.json, parsing`);
}
const meta = metaExists
? ((await metaExists[1].json()) as MetaType)
: ({
emojis: entries.map(([name]) => ({
fileName: name,
emoji: {
name: name.split(".")[0],
},
})),
} as MetaType);
// Get all emojis that already exist
const existingEmojis = await Emoji.manyFromSql(
and(
isNull(Emojis.instanceId),
inArray(
Emojis.shortcode,
meta.emojis.map((e) => e.emoji.name),
),
),
);
// Filter out existing emojis
const newEmojis = meta.emojis.filter(
(e) =>
!existingEmojis.find(
(ee) => ee.data.shortcode === e.emoji.name,
),
);
existingEmojis.length > 0 &&
this.log(
`${chalk.yellow("⚠")} Emojis with shortcode ${chalk.yellow(
existingEmojis.map((e) => e.data.shortcode).join(", "),
)} already exist in the database and will not be imported`,
);
if (newEmojis.length === 0) {
this.log(`${chalk.red("✗")} No new emojis to import`);
this.exit(1);
}
this.log(
`${chalk.green("✓")} Found ${chalk.green(
newEmojis.length,
)} new emoji(s)`,
);
const importSpinner = ora("Importing emojis").start();
const successfullyImported: MetaType["emojis"] = [];
for (const emoji of newEmojis) {
importSpinner.text = `Uploading ${chalk.gray(emoji.emoji.name)} (${
newEmojis.indexOf(emoji) + 1
}/${newEmojis.length})`;
const zipEntry = unzipped[emoji.fileName];
if (!zipEntry) {
this.log(
`${chalk.red(
"✗",
)} Could not find file for emoji ${chalk.red(
emoji.emoji.name,
)}`,
);
continue;
}
const fileName = emoji.fileName.split("/").pop() ?? "emoji";
const contentType = lookup(fileName) || "application/octet-stream";
const newFile = new File([await zipEntry.arrayBuffer()], fileName, {
type: contentType,
});
const media = await Media.fromFile(newFile);
await Emoji.insert({
shortcode: emoji.emoji.name,
mediaId: media.id,
visibleInPicker: true,
});
successfullyImported.push(emoji);
}
importSpinner.succeed("Imported emojis");
successfullyImported.length > 0 &&
this.log(
`${chalk.green("✓")} Successfully imported ${chalk.green(
successfullyImported.length,
)} emoji(s)`,
);
newEmojis.length - successfullyImported.length > 0 &&
this.log(
`${chalk.yellow("⚠")} Failed to import ${chalk.yellow(
newEmojis.length - successfullyImported.length,
)} emoji(s): ${chalk.yellow(
newEmojis
.filter((e) => !successfullyImported.includes(e))
.map((e) => e.emoji.name)
.join(", "),
)}`,
);
if (successfullyImported.length === 0) {
this.exit(1);
}
this.exit(0);
}
}

View file

@ -1,86 +0,0 @@
import { Flags } from "@oclif/core";
import { db } from "@versia/kit/db";
import { Emojis, Instances, Users } from "@versia/kit/tables";
import { and, eq, getTableColumns, isNotNull, isNull } from "drizzle-orm";
import { BaseCommand } from "~/cli/base";
import { formatArray } from "~/cli/utils/format";
export default class EmojiList extends BaseCommand<typeof EmojiList> {
public static override args = {};
public static override description = "List all emojis";
public static override examples = [
"<%= config.bin %> <%= command.id %> --format json --local",
"<%= config.bin %> <%= command.id %>",
];
public static override flags = {
format: Flags.string({
char: "f",
description: "Output format",
options: ["json", "csv"],
}),
local: Flags.boolean({
char: "l",
description: "Local emojis only",
exclusive: ["remote"],
}),
remote: Flags.boolean({
char: "r",
description: "Remote emojis only",
exclusive: ["local"],
}),
limit: Flags.integer({
char: "n",
description: "Limit the number of emojis",
default: 200,
}),
username: Flags.string({
char: "u",
description: "Filter by username",
}),
};
public async run(): Promise<void> {
const { flags } = await this.parse(EmojiList);
const emojis = await db
.select({
...getTableColumns(Emojis),
instanceUrl: Instances.baseUrl,
owner: Users.username,
})
.from(Emojis)
.leftJoin(Instances, eq(Emojis.instanceId, Instances.id))
.leftJoin(Users, eq(Emojis.ownerId, Users.id))
.where(
and(
flags.local ? isNull(Emojis.instanceId) : undefined,
flags.remote ? isNotNull(Emojis.instanceId) : undefined,
flags.username
? eq(Users.username, flags.username)
: undefined,
),
);
const keys = [
"id",
"shortcode",
"alt",
"contentType",
"instanceUrl",
"owner",
];
this.log(
formatArray(
emojis,
keys,
flags.format as "json" | "csv" | undefined,
),
);
this.exit(0);
}
}

View file

@ -1,47 +0,0 @@
import { Args } from "@oclif/core";
import { Instance } from "@versia/kit/db";
import { Instances } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
import ora from "ora";
import { FetchJobType, fetchQueue } from "~/classes/queues/fetch";
import { BaseCommand } from "~/cli/base";
export default class FederationInstanceRefetch extends BaseCommand<
typeof FederationInstanceRefetch
> {
public static override args = {
url: Args.string({
description: "URL of the remote instance",
required: true,
}),
};
public static override description =
"Refetches metadata from remote instances";
public static override examples = ["<%= config.bin %> <%= command.id %>"];
public static override flags = {};
public async run(): Promise<void> {
const { args } = await this.parse(FederationInstanceRefetch);
const spinner = ora("Refetching instance metadata").start();
const host = new URL(args.url).host;
const instance = await Instance.fromSql(eq(Instances.baseUrl, host));
if (!instance) {
throw new Error("Instance not found");
}
await fetchQueue.add(FetchJobType.Instance, {
uri: args.url,
});
spinner.succeed("Task added to queue");
this.exit(0);
}
}

View file

@ -1,70 +0,0 @@
import { parseUserAddress, userAddressValidator } from "@/api";
import { Args } from "@oclif/core";
import { Instance, User } from "@versia/kit/db";
import chalk from "chalk";
import ora from "ora";
import { BaseCommand } from "~/cli/base";
export default class FederationUserFetch extends BaseCommand<
typeof FederationUserFetch
> {
public static override args = {
address: Args.string({
description: "Address of remote user (name@host.com)",
required: true,
}),
};
public static override description = "Fetch remote users";
public static override examples = ["<%= config.bin %> <%= command.id %>"];
public static override flags = {};
public async run(): Promise<void> {
const { args } = await this.parse(FederationUserFetch);
// Check if the address is valid
if (!args.address.match(userAddressValidator)) {
this.log(
"Invalid address. Please check the address format and try again. For example: name@host.com",
);
this.exit(1);
}
const spinner = ora("Fetching user").start();
const { username, domain: host } = parseUserAddress(args.address);
if (!host) {
this.log("Address must contain a domain.");
this.exit(1);
}
// Check instance exists, if not, create it
await Instance.resolve(new URL(`https://${host}`));
const manager = await User.getFederationRequester();
const uri = await User.webFinger(manager, username, host);
if (!uri) {
spinner.fail();
this.log(chalk.red("User not found"));
this.exit(1);
}
const newUser = await User.resolve(uri);
if (newUser) {
spinner.succeed();
this.log(chalk.green(`User found: ${newUser.getUri()}`));
} else {
spinner.fail();
this.log(chalk.red("User not found"));
}
this.exit(0);
}
}

View file

@ -1,59 +0,0 @@
import { parseUserAddress, userAddressValidator } from "@/api";
import { Args } from "@oclif/core";
import { Instance, User } from "@versia/kit/db";
import chalk from "chalk";
import ora from "ora";
import { BaseCommand } from "~/cli/base";
export default class FederationUserFinger extends BaseCommand<
typeof FederationUserFinger
> {
public static override args = {
address: Args.string({
description: "Address of remote user (name@host.com)",
required: true,
}),
};
public static override description =
"Fetch the URL of remote users via WebFinger";
public static override examples = ["<%= config.bin %> <%= command.id %>"];
public static override flags = {};
public async run(): Promise<void> {
const { args } = await this.parse(FederationUserFinger);
// Check if the address is valid
if (!args.address.match(userAddressValidator)) {
this.log(
"Invalid address. Please check the address format and try again. For example: name@host.com",
);
this.exit(1);
}
const spinner = ora("Fetching user URI").start();
const { username, domain: host } = parseUserAddress(args.address);
if (!host) {
this.log("Address must contain a domain.");
this.exit(1);
}
// Check instance exists, if not, create it
await Instance.resolve(new URL(`https://${host}`));
const manager = await User.getFederationRequester();
const uri = await User.webFinger(manager, username, host);
spinner.succeed("Fetched user URI");
this.log(`URI: ${chalk.blueBright(uri)}`);
this.exit(0);
}
}

View file

@ -1,19 +0,0 @@
import { User } from "@versia/kit/db";
import chalk from "chalk";
import { BaseCommand } from "~/cli/base";
export default class GenerateKeys extends BaseCommand<typeof GenerateKeys> {
public static override args = {};
public static override description =
"Generates keys to use in Versia Server";
public static override flags = {};
public async run(): Promise<void> {
const { public_key, private_key } = await User.generateKeys();
this.log(`Generated public key: ${chalk.gray(public_key)}`);
this.log(`Generated private key: ${chalk.gray(private_key)}`);
}
}

View file

@ -1,66 +0,0 @@
import { Args, Flags } from "@oclif/core";
import ora from "ora";
import { SonicIndexType, searchManager } from "~/classes/search/search-manager";
import { BaseCommand } from "~/cli/base";
import { config } from "~/config.ts";
export default class IndexRebuild extends BaseCommand<typeof IndexRebuild> {
public static override args = {
type: Args.string({
description: "Index category to rebuild",
options: ["accounts", "statuses"],
required: true,
}),
};
public static override description = "Rebuild search indexes";
public static override examples = ["<%= config.bin %> <%= command.id %>"];
public static override flags = {
"batch-size": Flags.integer({
char: "b",
description: "Number of items to process in each batch",
default: 100,
}),
};
public async run(): Promise<void> {
const { flags, args } = await this.parse(IndexRebuild);
if (!config.search.enabled) {
this.error("Search is disabled");
this.exit(1);
}
const spinner = ora("Rebuilding search indexes").start();
switch (args.type) {
case "accounts":
await searchManager.rebuildSearchIndexes(
[SonicIndexType.Accounts],
flags["batch-size"],
(progress) => {
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
},
);
break;
case "statuses":
await searchManager.rebuildSearchIndexes(
[SonicIndexType.Statuses],
flags["batch-size"],
(progress) => {
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
},
);
break;
default: {
this.error("Invalid index type");
}
}
spinner.succeed("Search indexes rebuilt");
this.exit(0);
}
}

View file

@ -1,86 +0,0 @@
import confirm from "@inquirer/confirm";
import { Flags } from "@oclif/core";
import chalk from "chalk";
import { sql } from "drizzle-orm";
import ora from "ora";
import { Note } from "~/classes/database/note";
import { BaseCommand } from "~/cli/base";
import { Notes } from "~/drizzle/schema";
export default class NoteRecalculate extends BaseCommand<
typeof NoteRecalculate
> {
public static override description = "Recalculate all notes";
public static override examples = ["<%= config.bin %> <%= command.id %>"];
public static override flags = {
confirm: Flags.boolean({
description:
"Ask for confirmation before the recalculation (default yes)",
allowNo: true,
default: true,
}),
};
public async run(): Promise<void> {
const { flags } = await this.parse(NoteRecalculate);
const noteCount = await Note.getCount();
if (flags.confirm) {
const choice = await confirm({
message: `Recalculate ${chalk.gray(noteCount)} notes? ${chalk.red(
"This might take a while.",
)}`,
});
if (!choice) {
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
return this.exit(1);
}
}
const spinner = ora("Recalculating notes").start();
let done = false;
let count = 0;
const pageSize = 100;
while (done === false) {
spinner.text = `Fetching next ${chalk.gray(pageSize)} notes`;
const notes = await Note.manyFromSql(
sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)`,
undefined,
pageSize,
count,
);
for (const note of notes) {
spinner.text = `Recalculating note ${chalk.gray(
count,
)}/${chalk.gray(noteCount)}`;
await note.updateFromData({
author: note.author,
content: {
[note.data.contentType]: {
content: note.data.content,
remote: false,
},
},
});
count++;
}
if (notes.length < pageSize) {
done = true;
}
}
spinner.succeed("Recalculated all notes");
this.exit(0);
}
}

View file

@ -1,43 +0,0 @@
import os from "node:os";
import { Flags } from "@oclif/core";
import { BaseCommand } from "~/cli/base";
export default class Start extends BaseCommand<typeof Start> {
public static override args = {};
public static override description = "Starts Versia Server";
public static override examples = [
"<%= config.bin %> <%= command.id %> --threads 4",
"<%= config.bin %> <%= command.id %> --all-threads",
];
public static override flags = {
threads: Flags.integer({
char: "t",
description: "Number of threads to use",
default: 1,
exclusive: ["all-threads"],
}),
"all-threads": Flags.boolean({
description: "Use all available threads",
default: false,
exclusive: ["threads"],
}),
silent: Flags.boolean({
description: "Don't show logs in console",
default: false,
}),
};
public async run(): Promise<void> {
const { flags } = await this.parse(Start);
const numCpus = flags["all-threads"] ? os.cpus().length : flags.threads;
process.env.NUM_CPUS = String(numCpus);
process.env.SILENT = flags.silent ? "true" : "false";
await import("~/index");
}
}

View file

@ -1,171 +0,0 @@
import input from "@inquirer/input";
import { Args, Flags } from "@oclif/core";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import { renderUnicodeCompact } from "uqr";
import { BaseCommand } from "~/cli/base";
import { formatArray } from "~/cli/utils/format";
export default class UserCreate extends BaseCommand<typeof UserCreate> {
public static override args = {
username: Args.string({
description: "Username",
required: true,
}),
};
public static override description = "Creates a new user";
public static override examples = [
"<%= config.bin %> <%= command.id %> johngastron --email joe@gamer.com",
"<%= config.bin %> <%= command.id %> bimbobaggins",
];
public 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"],
}),
password: Flags.string({
description:
"Password. Make sure this isn't saved in the shell history",
exclusive: ["set-password"],
}),
};
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: string | null = 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): string => "*".repeat(value.length),
});
const password2 = await input({
message: "Please confirm the user's password:",
// Set whatever the user types to stars
transformer: (value): string => "*".repeat(value.length),
});
if (password1 !== password2) {
this.log(
`${chalk.red(
"✗",
)} Passwords do not match. Please try again.`,
);
this.exit(1);
}
password = password1;
}
if (flags.password) {
password = flags.password;
}
// 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.data.username,
)} with id ${chalk.green(user.id)}`,
);
this.log(
formatArray(
[user.data],
[
"id",
"username",
"displayName",
"createdAt",
"updatedAt",
"isAdmin",
],
flags.format as "json" | "csv" | undefined,
),
);
if (!(flags.format || flags["set-password"] || flags.password)) {
const link = "";
this.log(
flags.format
? link
: `\nPassword reset link for ${chalk.bold(
`@${user.data.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,90 +0,0 @@
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> {
public static override description = "Deletes users";
public 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',
];
public static override flags = {
confirm: Flags.boolean({
description:
"Ask for confirmation before deleting the user (default yes)",
allowNo: true,
default: true,
}),
};
public static override args = {
identifier: UserFinderCommand.baseArgs.identifier,
};
public async run(): Promise<void> {
const { flags } = 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.data),
[
"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.succeed();
this.log(chalk.bold(`${chalk.green("✓")} User(s) deleted`));
this.exit(0);
}
}

View file

@ -1,84 +0,0 @@
import { Flags } from "@oclif/core";
import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables";
import { and, eq, isNotNull, isNull } from "drizzle-orm";
import { BaseCommand } from "~/cli/base";
import { formatArray } from "~/cli/utils/format";
export default class UserList extends BaseCommand<typeof UserList> {
public static override args = {};
public static override description = "List all users";
public static override examples = [
"<%= config.bin %> <%= command.id %> --format json --local",
"<%= config.bin %> <%= command.id %>",
];
public static override flags = {
format: Flags.string({
char: "f",
description: "Output format",
options: ["json", "csv"],
exclusive: ["pretty-dates"],
}),
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.data),
keys,
flags.format as "json" | "csv" | undefined,
flags["pretty-dates"],
),
);
this.exit(0);
}
}

View file

@ -1,104 +0,0 @@
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 UserRefetch extends UserFinderCommand<typeof UserRefetch> {
public static override description = "Refetch remote users";
public static override examples = [
"<%= config.bin %> <%= command.id %> johngastron --type username",
"<%= config.bin %> <%= command.id %> 018ec11c-c6cb-7a67-bd20-a4c81bf42912",
];
public static override flags = {
confirm: Flags.boolean({
description:
"Ask for confirmation before refetching the user (default yes)",
allowNo: true,
default: true,
}),
limit: Flags.integer({
char: "n",
description: "Limit the number of users",
default: 1,
}),
};
public static override args = {
identifier: UserFinderCommand.baseArgs.identifier,
};
public async run(): Promise<void> {
const { flags } = await this.parse(UserRefetch);
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.data),
[
"id",
"username",
"displayName",
"createdAt",
"updatedAt",
"isAdmin",
],
),
);
if (flags.confirm && !flags.print) {
const choice = await confirm({
message: `Refetch these users? ${chalk.red(
"This is irreversible.",
)}`,
});
if (!choice) {
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
return this.exit(1);
}
}
const spinner = ora("Refetching users").start();
for (const user of users) {
try {
spinner.text = `Refetching user ${user.data.username}`;
await user.updateFromRemote();
} catch (error) {
this.log(
chalk.bold(
`${chalk.red("✗")} Failed to refetch user ${
user.data.username
}`,
),
);
this.log(chalk.red((error as Error).message));
}
}
spinner.succeed("Refetched users");
this.exit(0);
}
}

View file

@ -1,126 +0,0 @@
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";
import { config } from "~/config.ts";
export default class UserReset extends UserFinderCommand<typeof UserReset> {
public static override description = "Resets users' passwords";
public static override examples = [
"<%= config.bin %> <%= command.id %> johngastron --type username",
"<%= config.bin %> <%= command.id %> 018ec11c-c6cb-7a67-bd20-a4c81bf42912",
];
public 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)",
}),
};
public static override args = {
identifier: UserFinderCommand.baseArgs.identifier,
};
public async run(): Promise<void> {
const { flags } = 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.data),
[
"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);
}
}
for (const user of users) {
const token = await user.resetPassword();
const link = new URL(
`${config.frontend.routes.password_reset}?${new URLSearchParams(
{
token,
},
).toString()}`,
config.http.base_url,
).toString();
!flags.raw &&
this.log(
`${chalk.green("✓")} Password reset for ${
users.length
} user(s)`,
);
this.log(
flags.raw
? link
: `\nPassword reset link for ${chalk.bold(
`@${user.data.username}`,
)}: ${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);
}
}

View file

@ -1,111 +0,0 @@
import { randomString } from "@/math";
import { Flags } from "@oclif/core";
import chalk from "chalk";
import ora from "ora";
import { Token } from "~/classes/database/token";
import { UserFinderCommand } from "~/cli/classes";
import { formatArray } from "~/cli/utils/format";
export default class UserToken extends UserFinderCommand<typeof UserToken> {
public static override description = "Generates access tokens for users";
public 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',
];
public static override flags = {
format: Flags.string({
char: "f",
description: "Output format",
options: ["json", "csv"],
exclusive: ["pretty-dates"],
}),
limit: Flags.integer({
char: "n",
description: "Limit the number of users",
default: 200,
}),
};
public static override args = {
identifier: UserFinderCommand.baseArgs.identifier,
};
public async run(): Promise<void> {
const { flags } = await this.parse(UserToken);
const foundUsers = await this.findUsers();
const users = foundUsers.filter((u) => u.isLocal());
if (!users || users.length === 0) {
this.log(chalk.bold(`${chalk.red("✗")} No users found`));
if (foundUsers.length > 0) {
this.log(
chalk.bold(
`${chalk.yellow("✗")} Found ${chalk.yellow(
foundUsers.length,
)} user(s) but none are local`,
),
);
}
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.data),
[
"id",
"username",
"displayName",
"createdAt",
"updatedAt",
"isAdmin",
],
),
);
const spinner = ora("Generating access tokens").start();
const tokens = await Promise.all(
users.map(
async (u) =>
await Token.insert({
accessToken: randomString(64, "base64url"),
code: null,
scope: "read write follow",
tokenType: "Bearer",
userId: u.id,
}),
),
);
spinner.succeed();
this.log(chalk.bold(`${chalk.green("✓")} Tokens generated`));
this.log(
formatArray(
tokens.map((t) => t.data),
["accessToken", "userId"],
flags.format as "json" | "csv" | undefined,
),
);
this.exit(0);
}
}

View file

@ -1,37 +1,36 @@
import { configureLoggers } from "@/loggers";
import { execute } from "@oclif/core";
import Start from "./commands/start.ts";
import { completionsPlugin } from "@clerc/plugin-completions";
import { friendlyErrorPlugin } from "@clerc/plugin-friendly-error";
import { helpPlugin } from "@clerc/plugin-help";
import { notFoundPlugin } from "@clerc/plugin-not-found";
import { versionPlugin } from "@clerc/plugin-version";
import { Clerc } from "clerc";
import { searchManager } from "~/classes/search/search-manager.ts";
import { setupDatabase } from "~/drizzle/db.ts";
import pkg from "~/package.json";
import { rebuildIndexCommand } from "./index/rebuild.ts";
import { refetchInstanceCommand } from "./instance/refetch.ts";
import { createUserCommand } from "./user/create.ts";
import { deleteUserCommand } from "./user/delete.ts";
import { refetchUserCommand } from "./user/refetch.ts";
import { generateTokenCommand } from "./user/token.ts";
await configureLoggers();
await setupDatabase(false);
await searchManager.connect(true);
// Use "explicit" oclif strategy to avoid issues with oclif's module resolver and bundling
export const commands = {
"user:list": (await import("./commands/user/list.ts")).default,
"user:delete": (await import("./commands/user/delete.ts")).default,
"user:create": (await import("./commands/user/create.ts")).default,
"user:reset": (await import("./commands/user/reset.ts")).default,
"user:refetch": (await import("./commands/user/refetch.ts")).default,
"user:token": (await import("./commands/user/token.ts")).default,
"emoji:add": (await import("./commands/emoji/add.ts")).default,
"emoji:delete": (await import("./commands/emoji/delete.ts")).default,
"emoji:list": (await import("./commands/emoji/list.ts")).default,
"emoji:import": (await import("./commands/emoji/import.ts")).default,
"index:rebuild": (await import("./commands/index/rebuild.ts")).default,
"federation:instance:refetch": (
await import("./commands/federation/instance/refetch.ts")
).default,
"federation:user:finger": (
await import("./commands/federation/user/finger.ts")
).default,
"federation:user:fetch": (
await import("./commands/federation/user/fetch.ts")
).default,
"generate-keys": (await import("./commands/generate-keys.ts")).default,
start: Start,
"notes:recalculate": (await import("./commands/notes/recalculate.ts"))
.default,
};
if (import.meta.path === Bun.main) {
await execute({ dir: import.meta.url });
}
Clerc.create()
.scriptName("cli")
.name("Versia Server CLI")
.description("CLI interface for Versia Server")
.version(pkg.version)
.use(helpPlugin())
.use(versionPlugin())
.use(completionsPlugin())
.use(notFoundPlugin())
.use(friendlyErrorPlugin())
.command(createUserCommand)
.command(deleteUserCommand)
.command(generateTokenCommand)
.command(refetchUserCommand)
.command(rebuildIndexCommand)
.command(refetchInstanceCommand)
.parse();

65
cli/index/rebuild.ts Normal file
View file

@ -0,0 +1,65 @@
// @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { type Root, defineCommand } from "clerc";
import ora from "ora";
import {
SonicIndexType,
searchManager,
} from "~/classes/search/search-manager.ts";
import { config } from "~/config.ts";
export const rebuildIndexCommand = defineCommand(
{
name: "index rebuild",
description: "Rebuild the search index.",
parameters: ["<type>"],
flags: {
"batch-size": {
description: "Number of records to process at once",
type: Number,
alias: "b",
default: 100,
},
},
},
async (context) => {
const { "batch-size": batchSize } = context.flags;
const { type } = context.parameters;
if (!config.search.enabled) {
throw new Error(
"Search is not enabled in the instance configuration.",
);
}
const spinner = ora("Rebuilding search indexes").start();
switch (type) {
case "accounts":
await searchManager.rebuildSearchIndexes(
[SonicIndexType.Accounts],
batchSize,
(progress) => {
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
},
);
break;
case "statuses":
await searchManager.rebuildSearchIndexes(
[SonicIndexType.Statuses],
batchSize,
(progress) => {
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
},
);
break;
default: {
throw new Error(
"Invalid index type. Can be 'accounts' or 'statuses'.",
);
}
}
spinner.succeed("Search indexes rebuilt");
},
);

37
cli/instance/refetch.ts Normal file
View file

@ -0,0 +1,37 @@
import chalk from "chalk";
// @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { type Root, defineCommand } from "clerc";
import { eq } from "drizzle-orm";
import { Instance } from "~/classes/database/instance.ts";
import { FetchJobType, fetchQueue } from "~/classes/queues/fetch.ts";
import { Instances } from "~/drizzle/schema.ts";
export const refetchInstanceCommand = defineCommand(
{
name: "instance refetch",
description: "Refetches metadata from remote instances.",
parameters: ["<url_or_host>"],
},
async (context) => {
const { urlOrHost } = context.parameters;
const host = URL.canParse(urlOrHost)
? new URL(urlOrHost).host
: urlOrHost;
const instance = await Instance.fromSql(eq(Instances.baseUrl, host));
if (!instance) {
throw new Error(`Instance ${chalk.gray(host)} not found.`);
}
await fetchQueue.add(FetchJobType.Instance, {
uri: new URL(`https://${instance.data.baseUrl}`).origin,
});
console.info(
`Refresh job enqueued for ${chalk.gray(instance.data.baseUrl)}.`,
);
},
);

View file

@ -1,15 +0,0 @@
{
"bin": "white",
"command": "cyan",
"commandSummary": "white",
"dollarSign": "white",
"flag": "white",
"flagDefaultValue": "blue",
"flagOptions": "green",
"flagRequired": "red",
"flagSeparator": "white",
"sectionDescription": "white",
"sectionHeader": "underline",
"topic": "white",
"version": "green"
}

87
cli/user/create.ts Normal file
View file

@ -0,0 +1,87 @@
import chalk from "chalk";
// @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { type Root, defineCommand } from "clerc";
import { and, eq, isNull } from "drizzle-orm";
import { renderUnicodeCompact } from "uqr";
import { User } from "~/classes/database/user";
import { config } from "~/config";
import { Users } from "~/drizzle/schema";
export const createUserCommand = defineCommand(
{
name: "user create",
description: "Create a new user.",
parameters: ["<username>"],
flags: {
password: {
description: "Password for the new user",
type: String,
alias: "p",
},
email: {
description: "Email for the new user",
type: String,
alias: "e",
},
admin: {
description: "Make the new user an admin",
type: Boolean,
alias: "a",
},
},
},
async (context) => {
const { admin, email, password } = context.flags;
const { username } = context.parameters;
if (!username.match(/^[a-z0-9_-]+$/)) {
throw new Error("Username must be alphanumeric and lowercase.");
}
// Check if user already exists
const existingUser = await User.fromSql(
and(eq(Users.username, username), isNull(Users.instanceId)),
);
if (existingUser) {
throw new Error(`User ${chalk.gray(username)} is taken.`);
}
const user = await User.fromDataLocal({
email,
password,
username,
admin,
});
if (!user) {
throw new Error("Failed to create user.");
}
console.info(`User ${chalk.gray(username)} created.`);
if (!password) {
const token = await user.resetPassword();
const link = new URL(
`${config.frontend.routes.password_reset}?${new URLSearchParams(
{
token,
},
)}`,
config.http.base_url,
);
console.info(`Password reset link for ${chalk.gray(username)}:`);
console.info(chalk.blue(link.href));
const qrcode = renderUnicodeCompact(link.href, {
border: 2,
});
// Pad all lines of QR code with spaces
console.info(`\n ${qrcode.replaceAll("\n", "\n ")}`);
}
},
);

60
cli/user/delete.ts Normal file
View file

@ -0,0 +1,60 @@
import confirm from "@inquirer/confirm";
import chalk from "chalk";
// @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { type Root, defineCommand } from "clerc";
import { retrieveUser } from "../utils.ts";
export const deleteUserCommand = defineCommand(
{
name: "user delete",
alias: "user rm",
description:
"Delete a user from the database. Can use username or handle.",
parameters: ["<username_or_handle>"],
flags: {
confirm: {
description: "Ask for confirmation before deleting the user",
type: Boolean,
alias: "c",
default: true,
},
},
},
async (context) => {
const { confirm: confirmFlag } = context.flags;
const { usernameOrHandle } = context.parameters;
const user = await retrieveUser(usernameOrHandle);
if (!user) {
throw new Error(`User ${chalk.gray(usernameOrHandle)} not found.`);
}
console.info(`About to delete user ${chalk.gray(user.data.username)}!`);
console.info(`Username: ${chalk.blue(user.data.username)}`);
console.info(`Display Name: ${chalk.blue(user.data.displayName)}`);
console.info(`Created At: ${chalk.blue(user.data.createdAt)}`);
console.info(
`Instance: ${chalk.blue(user.data.instance?.baseUrl || "Local")}`,
);
if (confirmFlag) {
const choice = await confirm({
message: `Are you sure you want to delete this user? ${chalk.red(
"This is irreversible.",
)}`,
});
if (!choice) {
throw new Error("Operation aborted.");
}
}
await user.delete();
console.info(
`User ${chalk.gray(user.data.username)} has been deleted.`,
);
},
);

42
cli/user/refetch.ts Normal file
View file

@ -0,0 +1,42 @@
import chalk from "chalk";
// @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { type Root, defineCommand } from "clerc";
import ora from "ora";
import { retrieveUser } from "../utils.ts";
export const refetchUserCommand = defineCommand(
{
name: "user refetch",
description: "Refetches user data from their remote instance.",
parameters: ["<handle>"],
},
async (context) => {
const { handle } = context.parameters;
const user = await retrieveUser(handle);
if (!user) {
throw new Error(`User ${chalk.gray(handle)} not found.`);
}
if (user.isLocal()) {
throw new Error(
"This user is local and as such cannot be refetched.",
);
}
const spinner = ora("Refetching user").start();
try {
await user.updateFromRemote();
} catch (error) {
spinner.fail(
`Failed to refetch user ${chalk.gray(user.data.username)}`,
);
throw error;
}
spinner.succeed(`User ${chalk.gray(user.data.username)} refetched.`);
},
);

37
cli/user/token.ts Normal file
View file

@ -0,0 +1,37 @@
import { randomString } from "@/math.ts";
import chalk from "chalk";
// @ts-expect-error - Root import is required or the Clec type definitions won't work
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
import { type Root, defineCommand } from "clerc";
import { Token } from "~/classes/database/token.ts";
import { retrieveUser } from "../utils.ts";
export const generateTokenCommand = defineCommand(
{
name: "user token",
description: "Generates a new access token for a user.",
parameters: ["<username>"],
},
async (context) => {
const { username } = context.parameters;
const user = await retrieveUser(username);
if (!user) {
throw new Error(`User ${chalk.gray(username)} not found.`);
}
const token = await Token.insert({
accessToken: randomString(64, "base64url"),
code: null,
scope: "read write follow",
tokenType: "Bearer",
userId: user.id,
});
console.info(
`Token generated for user ${chalk.gray(user.data.username)}.`,
);
console.info(`Access Token: ${chalk.blue(token.data.accessToken)}`);
},
);

24
cli/utils.ts Normal file
View file

@ -0,0 +1,24 @@
import { parseUserAddress } from "@/api";
import { and, eq, isNull } from "drizzle-orm";
import { Instance } from "~/classes/database/instance";
import { User } from "~/classes/database/user";
import { Users } from "~/drizzle/schema";
export const retrieveUser = async (
usernameOrHandle: string,
): Promise<User | null> => {
const { username, domain } = parseUserAddress(usernameOrHandle);
const instance = domain ? await Instance.resolveFromHost(domain) : null;
const user = await User.fromSql(
and(
eq(Users.username, username),
instance
? eq(Users.instanceId, instance.data.id)
: isNull(Users.instanceId),
),
);
return user;
};

View file

@ -1,70 +0,0 @@
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

@ -52,27 +52,6 @@
"msgpackr-extract",
"sharp"
],
"oclif": {
"bin": "cli",
"dirname": "cli",
"commands": {
"strategy": "explicit",
"target": "./cli/index",
"identifier": "commands"
},
"additionalHelpFlags": ["-h"],
"additionalVersionFlags": ["-v"],
"plugins": [],
"description": "CLI to interface with the Versia project",
"topicSeparator": " ",
"topics": {
"user": {
"description": "Manage users"
}
},
"theme": "./cli/theme.json",
"flexibleTaxonomy": true
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "^1.2.2",
@ -87,7 +66,6 @@
"drizzle-kit": "^0.30.4",
"markdown-it-image-figures": "^2.1.1",
"markdown-it-mathjax3": "^4.3.2",
"oclif": "^4.17.30",
"ts-prune": "^0.10.3",
"typescript": "^5.7.3",
"vitepress": "^1.6.3",
@ -102,6 +80,11 @@
"dependencies": {
"@bull-board/api": "^6.7.7",
"@bull-board/hono": "^6.7.7",
"@clerc/plugin-completions": "^0.44.0",
"@clerc/plugin-friendly-error": "^0.44.0",
"@clerc/plugin-help": "^0.44.0",
"@clerc/plugin-not-found": "^0.44.0",
"@clerc/plugin-version": "^0.44.0",
"@hackmd/markdown-it-task-lists": "^2.1.4",
"@hono/prometheus": "^1.0.1",
"@hono/swagger-ui": "^0.5.0",
@ -110,7 +93,6 @@
"@inquirer/confirm": "^5.1.6",
"@inquirer/input": "^4.1.6",
"@logtape/logtape": "npm:@jsr/logtape__logtape@0.9.0-dev.123+1d41fba8",
"@oclif/core": "^4.2.7",
"@sentry/bun": "^9.1.0",
"@versia/client": "^0.1.5",
"@versia/federation": "^0.2.0",
@ -120,11 +102,11 @@
"bullmq": "^5.41.2",
"c12": "^2.0.2",
"chalk": "^5.4.1",
"clerc": "^0.44.0",
"cli-progress": "^3.12.0",
"cli-table": "^0.3.11",
"confbox": "^0.1.8",
"drizzle-orm": "^0.39.3",
"extract-zip": "^2.0.1",
"hono": "^4.7.1",
"html-to-text": "^9.0.5",
"ioredis": "^5.5.0",
@ -151,7 +133,6 @@
"stringify-entities": "^4.0.4",
"strip-ansi": "^7.1.0",
"table": "^6.9.0",
"unzipit": "^1.4.3",
"uqr": "^0.1.2",
"web-push": "^3.6.7",
"xss": "^1.0.15",