diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f8247c --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# Mod Manager +A package manager-like CLI utility for installing, upgrading and migrating mods on Fabric Minecraft Servers + +## Envrionment Variables +The list of required variables are as follows: +``` +CURSEFORGE_API_KEY="**api key goes here**" +``` \ No newline at end of file diff --git a/src/mod-manager.ts b/src/mod-manager.ts index 120fd72..8db2155 100644 --- a/src/mod-manager.ts +++ b/src/mod-manager.ts @@ -14,7 +14,8 @@ import {readFileSync, unlinkSync} from "fs"; import UpdateCommand from "./commands/upgrade_command.js"; import MigratePossibleCommand from "./commands/migrate_possible.js"; import MigrateCommand from "./commands/migrate_command.js"; - +import ModrinthSource from "./mods/sources/modrinth_source.js"; +import Mods from "./mods/mods.js"; export default class ModManager { public static logger: Logger | null = null; @@ -41,7 +42,7 @@ export default class ModManager { public static readonly MODS_FOLDER_PATH = path.join("mods") } - static init() { + static async init() { if (Initialiser.isInitialised()) { this.logger = ModManager.createLogger(); } @@ -54,6 +55,12 @@ export default class ModManager { command.registerCommand(this.program); } + /* const source = new CurseforgeSource(); + console.log(await source.search("lithium"))*/ + + Mods.registerSource(new ModrinthSource()) + //Mods.registerSource(new CurseforgeSource(), "CURSEFORGE_API_KEY") + this.program.showSuggestionAfterError(); this.program.showHelpAfterError(); this.program.parse(); diff --git a/src/mods/mod.d.ts b/src/mods/mod.d.ts index 8539d4f..5d32ef4 100644 --- a/src/mods/mod.d.ts +++ b/src/mods/mod.d.ts @@ -6,14 +6,15 @@ declare global { fileName: string, version: string source: string, - essential: boolean + essential: boolean, } type Version = { - id: string + modId: string fileName: string, url: string - version_number: string + versionNumber: string, + dependencies: Array } } diff --git a/src/mods/mods.ts b/src/mods/mods.ts index 6ece4a9..2d44a0b 100644 --- a/src/mods/mods.ts +++ b/src/mods/mods.ts @@ -1,6 +1,5 @@ import path from "path"; 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"; import {readFileSync, unlinkSync, writeFileSync} from "fs"; @@ -10,9 +9,17 @@ import MinecraftUtils from "../util/minecraft_utils.js"; import MigrateError from "../errors/migrate_error.js"; export default class Mods { - private static readonly MOD_SOURCES: Array = [ - new ModrinthSource() - ]; + private static readonly MOD_SOURCES: Array = []; + + public static registerSource(source: ModSource, envVar?: string) { + if (envVar != undefined) { + if (!process.env.hasOwnProperty(envVar)) { + PrintUtils.warn(`${source.getSourceName()} could not be registered as a mod source, as the required environment variable ${envVar} was not detected. Functionality related to ${source.getSourceName()} will be skipped.`) + return; + } + } + this.MOD_SOURCES.push(source); + } public static async install(mod: string, essential: boolean): Promise { let success: boolean = false; @@ -48,8 +55,7 @@ export default class Mods { try { const mcVersion = await MinecraftUtils.getCurrentMinecraftVersion(); const latestVersion = await source.getLatestVersion(id, mcVersion) - const modObj: Mod = await source.install(latestVersion, essential); - this.trackMod(modObj); + await source.install(latestVersion, essential); spinner.succeed(`Successfully installed ${projectName}`); success = true; @@ -65,7 +71,7 @@ export default class Mods { } } - private static trackMod(mod: Mod): void { + public static trackMod(mod: Mod): void { // Read current file const mods = this.getTrackedMods(); @@ -181,14 +187,13 @@ export default class Mods { latestVersion = await source.getLatestVersion(mod.id, mcVersion); // If the latest version has a different version number, it must be newer, install it. - if (latestVersion.version_number != mod.version) { + if (latestVersion.versionNumber != mod.version) { spinner.updateText(`Newer version for ${mod.name} found. Installing...`) this.silentUninstall(mod); - const newMod = await source.install(latestVersion, mod.essential); - this.trackMod(newMod); + await source.install(latestVersion, mod.essential); - spinner.succeed(`Successfully updated ${newMod.name}`) + spinner.succeed(`Successfully updated ${mod.name}`) // Else, the latest version is already installed, do nothing. } else { @@ -300,11 +305,10 @@ export default class Mods { const latestVersion = await source.getLatestVersion(mod.id, version) // Install the new mod - spinner.updateText(`Installing ${mod.name} ${latestVersion.version_number}..`) - const newMod = await source.install(latestVersion, mod.essential) - this.trackMod(newMod) + spinner.updateText(`Installing ${mod.name} ${latestVersion.versionNumber}..`) + await source.install(latestVersion, mod.essential) - spinner.succeed(`Successfully installed ${newMod.name} ${newMod.version}`) + spinner.succeed(`Successfully installed ${mod.name} ${mod.version}`) } catch (e) { // If a mod is not available, but is essential, throw error, else, warn user, and continue. if (mod.essential) { diff --git a/src/mods/sources/curseforge_source.ts b/src/mods/sources/curseforge_source.ts new file mode 100644 index 0000000..76a03cb --- /dev/null +++ b/src/mods/sources/curseforge_source.ts @@ -0,0 +1,68 @@ +/*import ModSource from "./mod_source.js"; +import axios from "axios"; +import MinecraftUtils from "../../util/minecraft_utils.js"; +import ModNotFoundError from "../../errors/mod_not_found_error.js"; + +export class CurseforgeSource implements ModSource { + private static readonly BASE_URL: string = "https://api.curseforge.com"; + private static readonly SEARCH_URL: string = `${CurseforgeSource.BASE_URL}/v1/mods/search`; + + private static readonly MINECRAFT_ID: number = 432; + private static readonly FABRIC_TYPE: number = 4; + + getLatestVersion(id: string, mcVersion: string): Promise { + const response = await + + return Promise.resolve(undefined); + } + + getProjectName(id: string): Promise { + return Promise.resolve(""); + } + + getSourceName(): string { + return "Curseforge"; + } + + install(version: Version, essential: boolean): Promise { + return Promise.resolve(undefined); + } + + async search(query: string): Promise { + const mcVersion = await MinecraftUtils.getCurrentMinecraftVersion(); + + const params = { + gameId: CurseforgeSource.MINECRAFT_ID, + gameVersion: mcVersion, + modLoaderType: CurseforgeSource.FABRIC_TYPE, + pageSize: 1, + searchFilter: query + } + + const response = await this.makeRequest(CurseforgeSource.SEARCH_URL, params); + + const id = response.data[0].id; + + if (id == undefined) { + throw new ModNotFoundError(`Mod ${query} could not be found on ${this.getSourceName()}`); + } + + return id; + } + + private async makeRequest(url: string, params: object) { + if (process.env.CURSEFORGE_API_KEY == undefined) { + throw new Error("Attempted Curseforge api calls with undefined api key environment variable (CURSEFORGE_API_KEY)") + } + + const response = await axios.get(url, { + headers: { + "x-api-key": process.env.CURSEFORGE_API_KEY + }, + params + }) + return await response.data; + } + +} +*/ diff --git a/src/mods/sources/mod_source.ts b/src/mods/sources/mod_source.ts index 9402803..8431427 100644 --- a/src/mods/sources/mod_source.ts +++ b/src/mods/sources/mod_source.ts @@ -1,7 +1,7 @@ export default interface ModSource { search(query: string): Promise; - install(version: Version, essential: boolean): Promise; + install(version: Version, essential: boolean): Promise; getSourceName(): string; diff --git a/src/mods/sources/modrinth_source.ts b/src/mods/sources/modrinth_source.ts index e0f26c0..392f760 100644 --- a/src/mods/sources/modrinth_source.ts +++ b/src/mods/sources/modrinth_source.ts @@ -7,12 +7,14 @@ 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"; +import Mods from "../mods.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 LIST_VERSIONS_URL: string = ModrinthSource.BASE_URL + "/project/%s/version"; private static readonly PROJECT_URL: string = ModrinthSource.BASE_URL + "/project/%s"; + private static readonly SINGLE_VERSION_URL: string = `${ModrinthSource.BASE_URL}/version/%s`; /** * Searches Modrinth for the specified query @@ -92,21 +94,27 @@ export default class ModrinthSource implements ModSource { * @param essential whether this mod is essential or not * @throws DownloadError if an error occurs when downloading */ - async install(version: Version, essential: boolean): Promise { + async install(version: Version, essential: boolean): Promise { try { + if (!Util.isArrayEmpty(version.dependencies)) { + for (let dependency of version.dependencies) { + await this.install(dependency, essential); + } + } FileDownloader.downloadMod(version) - return { - name: await this.getProjectName(version.id), - id: version.id, + const mod = { + name: await this.getProjectName(version.modId), + id: version.modId, fileName: version.fileName, - version: version.version_number, + version: version.versionNumber, source: this.getSourceName(), - essential: essential - }; + essential: essential, + } + Mods.trackMod(mod); } catch (e) { - throw new DownloadError(`An error occurred downloading mod with id ${version.id} from ${this.getSourceName()}`) + throw new DownloadError(`An error occurred downloading mod with id ${version.modId} from ${this.getSourceName()}`) } } @@ -237,13 +245,67 @@ export default class ModrinthSource implements ModSource { throw new ModNotFoundError(`Mod with id ${id} has no available versions on ${this.getSourceName()} for Minecraft version ${mcVersion}`); } const latestVersion = results[0]; - const latestFile = results[0].files[0]; + + return this.getVersionFromId(latestVersion.id); + } + + /** + * Gets a version object from the provided version id + * Example response from query: + * { + * "id": "3KmOcp6b", + * "project_id": "P7dR8mSH", + * "author_id": "JZA4dW8o", + * "featured": false, + * "name": "[1.19] Fabric API 0.58.0+1.19", + * "version_number": "0.58.0+1.19", + * "changelog": "- Bump version (modmuss50)\n- Enable parallel builds by default. Update remotesign to a parallel capable version. Set org.gradle.parallel.threads in actions as we are IO bound. (modmuss50)\n- fix custom dimension not loaded on world preset other than default (#2387) (deirn)\n- Fix inconsistent ordering of item attribute modifiers by using a linked hashmap (#2380) (Technici4n)\n- Fix incorrect check in GlobalReceiverRegistry (#2363) (apple502j)\n- Make disconnected screen reason text scrollable (#2349) (deirn, modmuss50)\n- Fix Indigo AO calculation (#2344) (PepperCode1)\n", + * "changelog_url": null, + * "date_published": "2022-07-21T20:10:41.654884Z", + * "downloads": 16745, + * "version_type": "release", + * "files": [ + * { + * "hashes": { + * "sha512": "9c948488852e3bcf7a84fef26465bf0bcfbba17fb03e6b56ae11cf82d1ae6abbfb4c569bf3f1d088c6c3c5219d37c2699afc9013926f588263210a19f8d6e235", + * "sha1": "6d29acc99b293b2be7060df6d7c887812bd54e46" + * }, + * "url": "https://cdn.modrinth.com/data/P7dR8mSH/versions/0.58.0+1.19/fabric-api-0.58.0%2B1.19.jar", + * "filename": "fabric-api-0.58.0+1.19.jar", + * "primary": false, + * "size": 1496048 + * } + * ], + * "dependencies": [], + * "game_versions": [ + * "1.19" + * ], + * "loaders": [ + * "fabric" + * ] + * } + * @param versionId the version id to transform into an object + * @return the Version object + */ + async getVersionFromId(versionId: string): Promise { + const response = await axios.get(format(ModrinthSource.SINGLE_VERSION_URL, versionId)); + const latestVersion = await response.data; + + const latestFile = latestVersion.files[0]; + + const dependencies = []; + if (!Util.isArrayEmpty(latestVersion.dependencies)) { + for (let dependency of latestVersion.dependencies) { + dependencies.push(await this.getVersionFromId(dependency.version_id)) + } + } return { - id: latestVersion.project_id, - version_number: latestVersion.version_number, + modId: latestVersion.project_id, + versionNumber: latestVersion.version_number, fileName: latestFile.filename, - url: latestFile.url + url: latestFile.url, + dependencies: dependencies }; } } \ No newline at end of file