mirror of
https://git.bits.team/Bits/mod-manager.git
synced 2025-06-29 16:49:43 -04:00
Added downloading mods from Modrinth
This commit is contained in:
19
src/commands/install_command.ts
Normal file
19
src/commands/install_command.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Command } from "commander";
|
||||
import Subcommand from "./subcommand.js"
|
||||
import ModManager from "../mod-manager.js";
|
||||
import Mods from "../mods/mods.js";
|
||||
|
||||
export default class InstallCommand implements Subcommand {
|
||||
registerCommand(program: Command): void {
|
||||
program.command("install")
|
||||
.description("Installs the provided mods")
|
||||
.argument("<mods...>", "The mods to install")
|
||||
.action((mods) => {
|
||||
ModManager.execute(async () => {
|
||||
for (const mod of mods) {
|
||||
await Mods.install(mod);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
|
||||
export default abstract class Subcommand {
|
||||
abstract registerCommand(program: Command): void;
|
||||
abstract execute(): void;
|
||||
export default interface Subcommand {
|
||||
registerCommand(program: Command): void;
|
||||
}
|
7
src/errors/download_error.ts
Normal file
7
src/errors/download_error.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export default class DownloadError extends Error {
|
||||
constructor(message: string | undefined) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
7
src/errors/mod_not_found_error.ts
Normal file
7
src/errors/mod_not_found_error.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export default class ModNotFoundError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
8
src/io/download_task.d.ts
vendored
Normal file
8
src/io/download_task.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
declare global {
|
||||
type DownloadTask = {
|
||||
fileName: string,
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
21
src/io/file_downloder.ts
Normal file
21
src/io/file_downloder.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import path from "path";
|
||||
import * as https from "https";
|
||||
import {createWriteStream} from "fs";
|
||||
import DownloadError from "../errors/download_error.js";
|
||||
|
||||
|
||||
export default class FileDownloader {
|
||||
private static readonly MODS_FOLDER_PATH: string = path.join("mods");
|
||||
|
||||
static downloadMod(task: DownloadTask): void {
|
||||
https.get(task.url, res => {
|
||||
const filePath = path.join(this.MODS_FOLDER_PATH, task.fileName);
|
||||
const writeStream = createWriteStream(filePath);
|
||||
res.pipe(writeStream);
|
||||
writeStream.on("finish", () => writeStream.close());
|
||||
writeStream.on('error', () => {
|
||||
throw new DownloadError(`Failed to download ${task.fileName} from ${task.url}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
@ -1,25 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import InitCommand from "./commands/init_command.js";
|
||||
import InstallCommand from "./commands/install_command.js";
|
||||
import Subcommand from "./commands/subcommand.js";
|
||||
import Initialiser from "./util/initialiser.js";
|
||||
import PrintUtils from "./util/print_utils.js";
|
||||
//import PrettyError from "pretty-error";
|
||||
|
||||
export default class ModManager {
|
||||
private static program: Command = new Command();
|
||||
|
||||
private static subcommands: Array<Subcommand> = [
|
||||
new InitCommand()
|
||||
new InitCommand(),
|
||||
new InstallCommand()
|
||||
];
|
||||
|
||||
static init() {
|
||||
//const pe = new PrettyError();
|
||||
//pe.start();
|
||||
|
||||
this.program
|
||||
.name('mod-manager')
|
||||
.description('A package (mod) manager for Fabric Minecraft Servers');
|
||||
|
||||
this.subcommands.forEach(command => {
|
||||
command.registerCommand(ModManager.program);
|
||||
});
|
||||
for (const command of this.subcommands) {
|
||||
command.registerCommand(this.program);
|
||||
}
|
||||
|
||||
this.program.parse();
|
||||
}
|
||||
@ -30,10 +36,9 @@ export default class ModManager {
|
||||
} else {
|
||||
PrintUtils.error("Mod Manager is not initialised");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ModManager.init();
|
||||
|
||||
|
||||
|
51
src/mods/mods.ts
Normal file
51
src/mods/mods.ts
Normal file
@ -0,0 +1,51 @@
|
||||
|
||||
import path from "path";
|
||||
import Initialiser from "../util/initialiser.js";
|
||||
import PrintUtils from "../util/print_utils.js";
|
||||
import ModrinthSource from "./sources/modrinth_source.js";
|
||||
import ModSource from "./sources/mod_source.js";
|
||||
import ModNotFoundError from "../errors/mod_not_found_error.js";
|
||||
|
||||
|
||||
export default class Mods {
|
||||
public static readonly MOD_FILE = "mods.json";
|
||||
private static readonly MOD_SOURCES: Array<ModSource> = [
|
||||
new ModrinthSource()
|
||||
];
|
||||
|
||||
public static async install(mod: string): Promise<void> {
|
||||
let success: boolean = false;
|
||||
|
||||
// Go through each mod source
|
||||
for (const source of this.MOD_SOURCES) {
|
||||
// If we have not yet successfully installed the queried mod
|
||||
if (!success) {
|
||||
PrintUtils.info(`Searching for ${mod}...`);
|
||||
// Search for the mod
|
||||
let id;
|
||||
try {
|
||||
id = await source.search(mod);
|
||||
} catch (e) {
|
||||
if (e instanceof ModNotFoundError) {
|
||||
PrintUtils.info(`Mod not found on ${source.getName()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If a mod is found, install it
|
||||
if (id != undefined) {
|
||||
PrintUtils.info(`Installing ${mod}...`);
|
||||
try {
|
||||
await source.install(id);
|
||||
PrintUtils.success(`Successfully installed ${mod}`);
|
||||
} catch (e) {
|
||||
PrintUtils.error(`An error occurred downloading ${mod} from ${source.getName()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static getModFilePath(): string {
|
||||
return path.join(Initialiser.getModManagerFolderPath(), this.MOD_FILE);
|
||||
}
|
||||
}
|
5
src/mods/sources/mod_source.ts
Normal file
5
src/mods/sources/mod_source.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export default interface ModSource {
|
||||
search(query: string): Promise<string>;
|
||||
install(id: string): Promise<void>;
|
||||
getName(): string;
|
||||
}
|
163
src/mods/sources/modrinth_source.ts
Normal file
163
src/mods/sources/modrinth_source.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import ModSource from "./mod_source.js";
|
||||
import "string-format"
|
||||
import {format} from "util";
|
||||
import MinecraftUtils from "../../util/minecraft_utils.js";
|
||||
import axios from "axios";
|
||||
import ModNotFoundError from "../../errors/mod_not_found_error.js";
|
||||
import Util from "../../util/util.js";
|
||||
import FileDownloader from "../../io/file_downloder.js";
|
||||
import DownloadError from "../../errors/download_error.js";
|
||||
|
||||
export default class ModrinthSource implements ModSource {
|
||||
private static readonly BASE_URL: string = "https://api.modrinth.com/v2";
|
||||
private static readonly SEARCH_URL: string = ModrinthSource.BASE_URL + "/search";
|
||||
private static readonly INSTALL_URL: string = ModrinthSource.BASE_URL + "/project/%s/version";
|
||||
|
||||
/**
|
||||
* Searches Modrinth for the specified query
|
||||
* Example shape of data returned by query:
|
||||
* {
|
||||
* "hits": [
|
||||
* {
|
||||
* "project_id": "gvQqBUqZ",
|
||||
* "project_type": "mod",
|
||||
* "slug": "lithium",
|
||||
* "author": "jellysquid3",
|
||||
* "title": "Lithium",
|
||||
* "description": "No-compromises game logic/server optimization mod",
|
||||
* "categories": [
|
||||
* "optimization",
|
||||
* "fabric"
|
||||
* ],
|
||||
* "display_categories": [
|
||||
* "optimization",
|
||||
* "fabric"
|
||||
* ],
|
||||
* "versions": [
|
||||
* "1.16.2",
|
||||
* "1.16.3",
|
||||
* "1.16.4",
|
||||
* "1.16.5",
|
||||
* "1.17",
|
||||
* "1.17.1",
|
||||
* "1.18",
|
||||
* "1.18.1",
|
||||
* "1.18.2",
|
||||
* "1.19",
|
||||
* "1.19.1"
|
||||
* ],
|
||||
* "downloads": 223103,
|
||||
* "follows": 1866,
|
||||
* "icon_url": "https://cdn.modrinth.com/data/gvQqBUqZ/icon.png",
|
||||
* "date_created": "2021-01-03T00:56:52.292581Z",
|
||||
* "date_modified": "2022-07-29T22:18:05.703354Z",
|
||||
* "latest_version": "1.19.1",
|
||||
* "license": "lgpl-3",
|
||||
* "client_side": "optional",
|
||||
* "server_side": "optional",
|
||||
* "gallery": []
|
||||
* }
|
||||
* ],
|
||||
* "offset": 0,
|
||||
* "limit": 1,
|
||||
* "total_hits": 1
|
||||
* }
|
||||
* @param query the query to search for
|
||||
* @throws ModNotFoundError if the query returns no results.
|
||||
* @returns The mod id of the found mod
|
||||
*/
|
||||
async search(query: string): Promise<string> | never {
|
||||
const mcVersion = await MinecraftUtils.getCurrentMinecraftVersion();
|
||||
|
||||
const params = {
|
||||
query: query,
|
||||
limit: 1,
|
||||
facets: format('[["categories:fabric"],["versions:%s"]]', mcVersion)
|
||||
}
|
||||
|
||||
const response = await axios.get(ModrinthSource.SEARCH_URL, {params});
|
||||
const results = await response.data.hits;
|
||||
|
||||
if (Util.isArrayEmpty(results)) {
|
||||
throw new ModNotFoundError(`Mod ${query} could not be found on ${this.getName()}`);
|
||||
}
|
||||
|
||||
return results[0].project_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the mod with the provided mod id
|
||||
* Example shape of data returned by query:
|
||||
* [
|
||||
* {
|
||||
* "id": "ZRR9yqHD",
|
||||
* "project_id": "gvQqBUqZ",
|
||||
* "author_id": "uhPSqlnd",
|
||||
* "featured": false,
|
||||
* "name": "Lithium 0.8.3",
|
||||
* "version_number": "mc1.19.1-0.8.3",
|
||||
* "changelog": "Lithium 0.8.3 is the second release for 1.19.1! It includes a bugfix too!\n\n## Fixes\n- fix: update chunk serialization patch to new mappings\n\nYou can donate on patreon: https://www.patreon.com/2No2Name\n",
|
||||
* "changelog_url": null,
|
||||
* "date_published": "2022-07-29T22:18:09.072973Z",
|
||||
* "downloads": 3592,
|
||||
* "version_type": "release",
|
||||
* "files": [
|
||||
* {
|
||||
* "hashes": {
|
||||
* "sha1": "9ef9f10f62d4c19b736fe493f2a11d737fbe3d7c",
|
||||
* "sha512": "a3b623b4c14f6ba46d1486ffb3d1ba3174e3317b419b2ddfdf7bb572244e706d2e0a37bdce169c94455bec00fd107530ba78d7e611162a632cc6950e6a625433"
|
||||
* },
|
||||
* "url": "https://cdn.modrinth.com/data/gvQqBUqZ/versions/mc1.19.1-0.8.3/lithium-fabric-mc1.19.1-0.8.3.jar",
|
||||
* "filename": "lithium-fabric-mc1.19.1-0.8.3.jar",
|
||||
* "primary": true,
|
||||
* "size": 476619
|
||||
* }
|
||||
* ],
|
||||
* "dependencies": [],
|
||||
* "game_versions": [
|
||||
* "1.19.1"
|
||||
* ],
|
||||
* "loaders": [
|
||||
* "fabric"
|
||||
* ]
|
||||
* }
|
||||
* ]
|
||||
* @param id the id of the mod
|
||||
* @throws DownloadError if an error occurs when downloading
|
||||
* @throws ModNotFoundError if there are no versions available for the current Minecraft Version
|
||||
*/
|
||||
async install(id: string): Promise<void> {
|
||||
const mcVersion = await MinecraftUtils.getCurrentMinecraftVersion();
|
||||
|
||||
const params = {
|
||||
loaders: '["fabric"]',
|
||||
game_versions: format('["%s"]', mcVersion)
|
||||
}
|
||||
|
||||
const response = await axios.get(format(ModrinthSource.INSTALL_URL, id), {params});
|
||||
const results = await response.data;
|
||||
|
||||
if (Util.isArrayEmpty(results)) {
|
||||
throw new ModNotFoundError(`Mod with id ${id} has no available versions on ${this.getName()} for Minecraft version ${mcVersion}`);
|
||||
}
|
||||
|
||||
const latestFile = results[0].files[0];
|
||||
|
||||
const task: DownloadTask = {
|
||||
fileName: latestFile.filename,
|
||||
url: latestFile.url
|
||||
}
|
||||
|
||||
try {
|
||||
FileDownloader.downloadMod(task)
|
||||
} catch (e) {
|
||||
throw new DownloadError(`An error occurred downloading mod with id ${id} from ${this.getName()}`)
|
||||
}
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return "Modrinth";
|
||||
}
|
||||
|
||||
|
||||
}
|
44
src/util/minecraft_utils.ts
Normal file
44
src/util/minecraft_utils.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {readdirSync} from "fs";
|
||||
import path from "path";
|
||||
import axios from "axios";
|
||||
|
||||
export default class MinecraftUtils {
|
||||
static async getCurrentMinecraftVersion(): Promise<string> {
|
||||
// Get installed versions as strings
|
||||
const installedVersions: Array<string> = readdirSync(this.getVersionsFolderPath(), {withFileTypes: true})
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => entry.name);
|
||||
|
||||
// Get a list of Minecraft Versions
|
||||
const response = await axios.get("https://meta.fabricmc.net/v2/versions/game");
|
||||
const data = await response.data;
|
||||
|
||||
const minecraftVersions: Array<string> = [];
|
||||
for (const ele of data) {
|
||||
minecraftVersions.push(ele.version);
|
||||
}
|
||||
|
||||
// Find the latest version that is currently installed
|
||||
let index = Number.MAX_VALUE;
|
||||
for (let version of installedVersions) {
|
||||
let currentIndex = minecraftVersions.indexOf(version);
|
||||
|
||||
// If this version, is newer than the previous newest, save it's index
|
||||
if (currentIndex < index) {
|
||||
index = currentIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const latestVersion = minecraftVersions[index];
|
||||
if (latestVersion == undefined) {
|
||||
throw new Error("There are no Minecraft versions available in this server. Is this a valid server installation?");
|
||||
}
|
||||
|
||||
return latestVersion;
|
||||
}
|
||||
|
||||
static getVersionsFolderPath(): string {
|
||||
return path.join("versions")
|
||||
}
|
||||
}
|
||||
|
5
src/util/util.ts
Normal file
5
src/util/util.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export default class Util {
|
||||
static isArrayEmpty(array: Array<any> | undefined): boolean {
|
||||
return array === undefined || array.length == 0;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user