Download dependencies as well as the requested mod and track them

This commit is contained in:
Kallum Jones 2022-08-08 17:28:07 +01:00
parent 7ad2992149
commit a820b9ce57
No known key found for this signature in database
GPG Key ID: D7F4589C4D7F81A9
7 changed files with 183 additions and 33 deletions

8
README.md Normal file
View File

@ -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**"
```

View File

@ -14,7 +14,8 @@ import {readFileSync, unlinkSync} from "fs";
import UpdateCommand from "./commands/upgrade_command.js"; import UpdateCommand from "./commands/upgrade_command.js";
import MigratePossibleCommand from "./commands/migrate_possible.js"; import MigratePossibleCommand from "./commands/migrate_possible.js";
import MigrateCommand from "./commands/migrate_command.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 { export default class ModManager {
public static logger: Logger | null = null; public static logger: Logger | null = null;
@ -41,7 +42,7 @@ export default class ModManager {
public static readonly MODS_FOLDER_PATH = path.join("mods") public static readonly MODS_FOLDER_PATH = path.join("mods")
} }
static init() { static async init() {
if (Initialiser.isInitialised()) { if (Initialiser.isInitialised()) {
this.logger = ModManager.createLogger(); this.logger = ModManager.createLogger();
} }
@ -54,6 +55,12 @@ export default class ModManager {
command.registerCommand(this.program); 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.showSuggestionAfterError();
this.program.showHelpAfterError(); this.program.showHelpAfterError();
this.program.parse(); this.program.parse();

7
src/mods/mod.d.ts vendored
View File

@ -6,14 +6,15 @@ declare global {
fileName: string, fileName: string,
version: string version: string
source: string, source: string,
essential: boolean essential: boolean,
} }
type Version = { type Version = {
id: string modId: string
fileName: string, fileName: string,
url: string url: string
version_number: string versionNumber: string,
dependencies: Array<Version>
} }
} }

View File

@ -1,6 +1,5 @@
import path from "path"; import path from "path";
import PrintUtils from "../util/print_utils.js"; import PrintUtils from "../util/print_utils.js";
import ModrinthSource from "./sources/modrinth_source.js";
import ModSource from "./sources/mod_source.js"; import ModSource from "./sources/mod_source.js";
import ModNotFoundError from "../errors/mod_not_found_error.js"; import ModNotFoundError from "../errors/mod_not_found_error.js";
import {readFileSync, unlinkSync, writeFileSync} from "fs"; import {readFileSync, unlinkSync, writeFileSync} from "fs";
@ -10,9 +9,17 @@ import MinecraftUtils from "../util/minecraft_utils.js";
import MigrateError from "../errors/migrate_error.js"; import MigrateError from "../errors/migrate_error.js";
export default class Mods { export default class Mods {
private static readonly MOD_SOURCES: Array<ModSource> = [ private static readonly MOD_SOURCES: Array<ModSource> = [];
new ModrinthSource()
]; 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<void> { public static async install(mod: string, essential: boolean): Promise<void> {
let success: boolean = false; let success: boolean = false;
@ -48,8 +55,7 @@ export default class Mods {
try { try {
const mcVersion = await MinecraftUtils.getCurrentMinecraftVersion(); const mcVersion = await MinecraftUtils.getCurrentMinecraftVersion();
const latestVersion = await source.getLatestVersion(id, mcVersion) const latestVersion = await source.getLatestVersion(id, mcVersion)
const modObj: Mod = await source.install(latestVersion, essential); await source.install(latestVersion, essential);
this.trackMod(modObj);
spinner.succeed(`Successfully installed ${projectName}`); spinner.succeed(`Successfully installed ${projectName}`);
success = true; 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 // Read current file
const mods = this.getTrackedMods(); const mods = this.getTrackedMods();
@ -181,14 +187,13 @@ export default class Mods {
latestVersion = await source.getLatestVersion(mod.id, mcVersion); latestVersion = await source.getLatestVersion(mod.id, mcVersion);
// If the latest version has a different version number, it must be newer, install it. // 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...`) spinner.updateText(`Newer version for ${mod.name} found. Installing...`)
this.silentUninstall(mod); this.silentUninstall(mod);
const newMod = await source.install(latestVersion, mod.essential); await source.install(latestVersion, mod.essential);
this.trackMod(newMod);
spinner.succeed(`Successfully updated ${newMod.name}`) spinner.succeed(`Successfully updated ${mod.name}`)
// Else, the latest version is already installed, do nothing. // Else, the latest version is already installed, do nothing.
} else { } else {
@ -300,11 +305,10 @@ export default class Mods {
const latestVersion = await source.getLatestVersion(mod.id, version) const latestVersion = await source.getLatestVersion(mod.id, version)
// Install the new mod // Install the new mod
spinner.updateText(`Installing ${mod.name} ${latestVersion.version_number}..`) spinner.updateText(`Installing ${mod.name} ${latestVersion.versionNumber}..`)
const newMod = await source.install(latestVersion, mod.essential) await source.install(latestVersion, mod.essential)
this.trackMod(newMod)
spinner.succeed(`Successfully installed ${newMod.name} ${newMod.version}`) spinner.succeed(`Successfully installed ${mod.name} ${mod.version}`)
} catch (e) { } catch (e) {
// If a mod is not available, but is essential, throw error, else, warn user, and continue. // If a mod is not available, but is essential, throw error, else, warn user, and continue.
if (mod.essential) { if (mod.essential) {

View File

@ -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<Version> {
const response = await
return Promise.resolve(undefined);
}
getProjectName(id: string): Promise<string> {
return Promise.resolve("");
}
getSourceName(): string {
return "Curseforge";
}
install(version: Version, essential: boolean): Promise<Mod> {
return Promise.resolve(undefined);
}
async search(query: string): Promise<string> {
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;
}
}
*/

View File

@ -1,7 +1,7 @@
export default interface ModSource { export default interface ModSource {
search(query: string): Promise<string>; search(query: string): Promise<string>;
install(version: Version, essential: boolean): Promise<Mod>; install(version: Version, essential: boolean): Promise<void>;
getSourceName(): string; getSourceName(): string;

View File

@ -7,12 +7,14 @@ import ModNotFoundError from "../../errors/mod_not_found_error.js";
import Util from "../../util/util.js"; import Util from "../../util/util.js";
import FileDownloader from "../../io/file_downloder.js"; import FileDownloader from "../../io/file_downloder.js";
import DownloadError from "../../errors/download_error.js"; import DownloadError from "../../errors/download_error.js";
import Mods from "../mods.js";
export default class ModrinthSource implements ModSource { export default class ModrinthSource implements ModSource {
private static readonly BASE_URL: string = "https://api.modrinth.com/v2"; private static readonly BASE_URL: string = "https://api.modrinth.com/v2";
private static readonly SEARCH_URL: string = ModrinthSource.BASE_URL + "/search"; 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 LIST_VERSIONS_URL: string = ModrinthSource.BASE_URL + "/project/%s/version";
private static readonly PROJECT_URL: string = ModrinthSource.BASE_URL + "/project/%s"; 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 * 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 * @param essential whether this mod is essential or not
* @throws DownloadError if an error occurs when downloading * @throws DownloadError if an error occurs when downloading
*/ */
async install(version: Version, essential: boolean): Promise<Mod> { async install(version: Version, essential: boolean): Promise<void> {
try { try {
if (!Util.isArrayEmpty(version.dependencies)) {
for (let dependency of version.dependencies) {
await this.install(dependency, essential);
}
}
FileDownloader.downloadMod(version) FileDownloader.downloadMod(version)
return { const mod = {
name: await this.getProjectName(version.id), name: await this.getProjectName(version.modId),
id: version.id, id: version.modId,
fileName: version.fileName, fileName: version.fileName,
version: version.version_number, version: version.versionNumber,
source: this.getSourceName(), source: this.getSourceName(),
essential: essential essential: essential,
}; }
Mods.trackMod(mod);
} catch (e) { } 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}`); throw new ModNotFoundError(`Mod with id ${id} has no available versions on ${this.getSourceName()} for Minecraft version ${mcVersion}`);
} }
const latestVersion = results[0]; 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<Version> {
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 { return {
id: latestVersion.project_id, modId: latestVersion.project_id,
version_number: latestVersion.version_number, versionNumber: latestVersion.version_number,
fileName: latestFile.filename, fileName: latestFile.filename,
url: latestFile.url url: latestFile.url,
dependencies: dependencies
}; };
} }
} }