1
1
mirror of https://git.bits.team/Bits/mod-manager.git synced 2025-04-05 15:38:35 -04:00

416 lines
16 KiB
TypeScript

import path from "path";
import PrintUtils from "../util/print_utils.js";
import ModSource from "./sources/mod_source.js";
import ModNotFoundError from "../errors/mod_not_found_error.js";
import {readdirSync, readFileSync, unlinkSync, writeFileSync} from "fs";
import Util from "../util/util.js";
import ModManager from "../mod-manager.js";
import MinecraftUtils from "../util/minecraft_utils.js";
import MigrateError from "../errors/migrate_error.js";
import inquirer from "inquirer";
import { StringBuilder } from 'typescript-string-operations';
import chalk from "chalk";
export default class Mods {
private static readonly MOD_SOURCES: Array<ModSource> = [];
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> {
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) {
const spinner = new PrintUtils.Spinner(`Searching for ${mod}...`);
spinner.start();
// Search for the mod
let id: string | undefined;
try {
id = await source.search(mod);
} catch (e) {
if (e instanceof ModNotFoundError) {
spinner.stop(`Mod ${mod} not found on ${source.getSourceName()}`)
} else {
spinner.error(`An error occurred searching for ${mod} on ${source.getSourceName()}. Skipping ${source.getSourceName()}`)
// Try the next source
continue;
}
}
// If a mod is found, install it
if (id != undefined) {
const projectName = await source.getProjectName(id);
// If mod is not already installed
if (!this.isModInstalled(id)) {
spinner.updateText(`Installing ${projectName}...`)
try {
const mcVersion = await MinecraftUtils.getCurrentMinecraftVersion();
const latestVersion = await source.getLatestVersion(id, mcVersion)
await source.install(latestVersion, essential);
spinner.succeed(`Successfully installed ${projectName}`);
success = true;
} catch (e) {
// Log the error, and continue to next source
spinner.error(e);
}
} else {
spinner.error(`Mod ${projectName} is already installed.`)
return;
}
}
}
}
}
public static trackMod(mod: TrackedMod): void {
// Read current file
const mods = this.getTrackedMods();
// Add mod
mods.push(mod);
// Write list back to file
this.writeToModFile(mods);
}
public static getTrackedMods(): Array<TrackedMod> {
const file = readFileSync(ModManager.FilePaths.MOD_FILE_PATH, "utf-8");
return JSON.parse(file);
}
public static writeToModFile(mods: Array<TrackedMod>): void {
writeFileSync(ModManager.FilePaths.MOD_FILE_PATH, JSON.stringify(mods, null, 4));
}
public static isModInstalled(id: string): boolean {
const modsWithId: Array<TrackedMod> = this.getTrackedMods().filter(mod => mod.id == id);
return !Util.isArrayEmpty(modsWithId)
}
private static getDependantMods(dependency: string) {
return this.getTrackedMods().filter(mod => mod.dependencies.includes(dependency))
}
static async uninstall(mod: string) {
// Find mod to uninstall
const modToUninstall = this.findMod(mod);
const spinner = new PrintUtils.Spinner(`Uninstalling ${mod}...`)
spinner.start();
// If a matching mod is found, remove it
if (modToUninstall != undefined) {
// Ensure the user wants to delete this mod
let del = true;
if (Mods.isDependedOn(modToUninstall.id)) {
spinner.pause();
const dependantMods = Mods.getDependantMods(modToUninstall.id);
const answer = await inquirer.prompt([{
type: "input",
name: "delete",
message: chalk.yellowBright(`${modToUninstall.name} is depended on by ${Mods.modListToSting(dependantMods)}. Are you sure you would like to uninstall? (y/n)`),
async validate(input: any): Promise<string | boolean> {
const lowerInput = input.toLowerCase();
const valid = lowerInput === "y" || lowerInput === "n" ;
if (!valid) {
return "Please answer either y or n"
}
return valid
},
}])
del = Util.getBoolFromYesNo(answer.delete);
}
// If we are deleting this mod, uninstall it
if (del) {
spinner.start()
this.silentUninstall(modToUninstall);
// Remove any left over dependencies that are not depended on by any other mod
for (let dependency of modToUninstall.dependencies) {
if (!this.isDependedOn(dependency)) {
const dependencyMod = this.findMod(dependency);
if (dependencyMod != undefined) {
this.silentUninstall(dependencyMod)
}
}
}
spinner.succeed(`${modToUninstall.name} successfully uninstalled!`)
}
} else {
spinner.error(`${mod} was not found.`)
}
}
static silentUninstall(mod: TrackedMod) {
let mods: Array<TrackedMod> = this.getTrackedMods();
// Remove mod from list and uninstall it
unlinkSync(path.join(ModManager.FilePaths.MODS_FOLDER_PATH, mod.fileName));
mods = mods.filter(item => !Mods.areModsEqual(item, mod));
this.writeToModFile(mods);
}
static areModsEqual(mod1: TrackedMod, mod2: TrackedMod): boolean {
return mod1.id === mod2.id;
}
static toggleEssential(mod: string) {
const modToMark = this.findMod(mod);
if (modToMark != undefined) {
for (let dependency of modToMark.dependencies) {
this.toggleEssential(dependency)
}
let mods = this.getTrackedMods();
// Remove mod from list
mods = mods.filter(item => !Mods.areModsEqual(item, modToMark));
// Toggle essential status, and write back to file
modToMark.essential = !modToMark.essential;
mods.push(modToMark)
this.writeToModFile(mods);
if (modToMark.essential) {
PrintUtils.success(`Marked ${modToMark.name} as essential`)
} else {
PrintUtils.success(`Marked ${modToMark.name} as inessential`)
}
} else {
PrintUtils.error(`${mod} not found.`)
}
}
static async update() {
const trackedMods = this.getTrackedMods();
if (Util.isArrayEmpty(trackedMods)) {
PrintUtils.error("There are no mods currently installed. Try `mod-manager install -h` to learn more!")
return;
}
const mcVersion = await MinecraftUtils.getCurrentMinecraftVersion();
// For every tracked mod
for (let mod of trackedMods) {
const spinner = new PrintUtils.Spinner(`Checking for newer version of ${mod.name}`)
spinner.start();
// Get the latest version
const source = this.getSourceFromName(mod.source);
let latestVersion: Version | undefined = undefined;
try {
latestVersion = await source.getLatestVersion(mod.id, mcVersion);
// If the latest version has a different version number, it must be newer, install it.
if (latestVersion.versionNumber != mod.version) {
spinner.updateText(`Newer version for ${mod.name} found. Installing...`)
this.silentUninstall(mod);
await source.install(latestVersion, mod.essential);
spinner.succeed(`Successfully updated ${mod.name}`)
// Else, the latest version is already installed, do nothing.
} else {
throw new ModNotFoundError("There is no newer version available.");
}
} catch (e) {
spinner.info(`${mod.name} already has the latest version installed!`)
}
}
}
static getSourceFromName(name: string): ModSource {
const source = this.MOD_SOURCES.find(src => src.getSourceName() === name);
if (source == undefined) {
throw new Error(`There is no source registered with the name ${name}`)
}
return source
}
static async isMigratePossible(version: string, force: boolean): Promise<boolean> {
const mods = !force ? this.getTrackedMods() : this.getEssentialMods();
if (Util.isArrayEmpty(mods)) {
const msg = !force ? "" +
"There are no mods installed, try `mod-manager install -h` to learn more!" : "" +
"There are no mods installed that are marked essential. Try `mod-manager essential -h` to learn more!";
throw new Error(msg)
}
if (!await MinecraftUtils.isValidVersion(version)) {
throw new Error(`${version} is not a valid Minecraft version`)
}
let availableList = [];
// For every tracked mod
for (let mod of mods) {
// Get the latest version for each mod on the provided minecraft version
const spinner = new PrintUtils.Spinner(`Checking ${mod.name}...`);
spinner.start();
const source = this.getSourceFromName(mod.source);
try {
await source.getLatestVersion(mod.id, version)
// Report and record that this mod is available
spinner.succeed(`${mod.name} is available on Minecraft ${version}`)
availableList.push(true)
} catch (e) {
// Report and record that this mod is not available
spinner.error(`${mod.name} is not available on Minecraft ${version}`)
availableList.push(false);
}
}
// Filter out all the true's from the list
availableList = availableList.filter(available => !available)
// If the array is empty, all the mods reported as available, and a migration is possible
const possible = Util.isArrayEmpty(availableList);
// Report and return whether it is possible
if (possible) {
PrintUtils.success(`It is possible to migrate to version ${version}`)
} else {
PrintUtils.error(`It is not possible to migrate to version ${version}`)
}
return possible;
}
/**
* Migrates to the provided version of minecraft
* @param version the Minecraft version to migrate to
* @param force true if this is a force migration, false otherwise
*/
static async migrate(version: string, force: boolean) {
const mods = this.getTrackedMods();
if (Util.isArrayEmpty(mods)) {
throw new MigrateError("There are no mods installed right now. Try `mod-manager install -h` to learn more!")
}
if (!await MinecraftUtils.isValidVersion(version)) {
throw new MigrateError(`${version} is not a valid version of Minecraft`)
}
if (!await Mods.isMigratePossible(version, force)) {
throw new MigrateError(`It is not possible to migrate to ${version}.`)
}
// For every tracked mod
for (let mod of mods) {
const source = this.getSourceFromName(mod.source);
const spinner = new PrintUtils.Spinner(`Uninstalling ${mod.name} ${mod.version}...`);
spinner.start();
try {
// Uninstall it
this.silentUninstall(mod);
// Get the latest version
const latestVersion = await source.getLatestVersion(mod.id, version)
// Install the new mod
spinner.updateText(`Installing ${mod.name} ${latestVersion.versionNumber}..`)
await source.install(latestVersion, mod.essential)
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) {
throw new Error("Attempted to migrate a mod that is not available for migration")
} else {
spinner.warn(`${mod.name} is not available. Discarding...`)
}
}
}
await MinecraftUtils.updateCurrentMinecraftVersion(version)
PrintUtils.success(`Successfully migrated to ${version}`)
const untrackedMods = Mods.getUntrackedMods();
if (!Util.isArrayEmpty(untrackedMods)) {
PrintUtils.warn(`The following mods are untracked and will need manual migration:`)
for (let untrackedMod of untrackedMods) {
PrintUtils.warn(untrackedMod);
}
}
}
static getUntrackedMods(): string[] {
let allMods = readdirSync(ModManager.FilePaths.MODS_FOLDER_PATH);
const trackedMods = Mods.getTrackedMods();
const untrackedMods = [];
for (let mod of allMods) {
if (Util.isArrayEmpty(trackedMods.filter(trackedModObj => trackedModObj.fileName == mod))) {
untrackedMods.push(mod);
}
}
return untrackedMods;
}
/**
* Finds the mod based on the provided id or name
* @param mod the id or mod name
* @return the found Mod, or undefined if no mod was found
*/
private static findMod(mod: string): TrackedMod | undefined {
// Replace underscores and dashes with spaces
mod = mod.replaceAll("_", " ");
mod = mod.replaceAll("-", " ")
let mods: Array<TrackedMod> = this.getTrackedMods();
for (let modEle of mods) {
const id = modEle.id.toLowerCase();
const name = modEle.name.toLowerCase();
const query = mod.toLowerCase();
if (id == query || Util.areStringsSimilar(mod, name)) {
return modEle;
}
}
return undefined;
}
private static getEssentialMods() {
return this.getTrackedMods().filter(mod => mod.essential);
}
private static isDependedOn(dependency: string) {
return Mods.getDependantMods(dependency).length != 0
}
private static modListToSting(dependantMods: TrackedMod[]) {
const builder = new StringBuilder();
for (let dependantMod of dependantMods) {
builder.Append(dependantMod.name)
}
return builder.ToString();
}
}