import { execSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { ModLoadOrder } from "@spt/loaders/ModLoadOrder"; import { ModTypeCheck } from "@spt/loaders/ModTypeCheck"; import { ModDetails } from "@spt/models/eft/profile/ISptProfile"; import { ConfigTypes } from "@spt/models/enums/ConfigTypes"; import { IPreSptLoadMod } from "@spt/models/external/IPreSptLoadMod"; import { IPreSptLoadModAsync } from "@spt/models/external/IPreSptLoadModAsync"; import { ICoreConfig } from "@spt/models/spt/config/ICoreConfig"; import { IModLoader } from "@spt/models/spt/mod/IModLoader"; import { IPackageJsonData } from "@spt/models/spt/mod/IPackageJsonData"; import { ILogger } from "@spt/models/spt/utils/ILogger"; import { ConfigServer } from "@spt/servers/ConfigServer"; import { LocalisationService } from "@spt/services/LocalisationService"; import { ModCompilerService } from "@spt/services/ModCompilerService"; import { JsonUtil } from "@spt/utils/JsonUtil"; import { VFS } from "@spt/utils/VFS"; import { maxSatisfying, satisfies, valid, validRange } from "semver"; import { DependencyContainer, inject, injectable } from "tsyringe"; @injectable() export class PreSptModLoader implements IModLoader { protected container: DependencyContainer; protected readonly basepath = "user/mods/"; protected readonly modOrderPath = "user/mods/order.json"; protected order: Record = {}; protected imported: Record = {}; protected sptConfig: ICoreConfig; protected serverDependencies: Record; protected skippedMods: Set; constructor( @inject("PrimaryLogger") protected logger: ILogger, @inject("VFS") protected vfs: VFS, @inject("JsonUtil") protected jsonUtil: JsonUtil, @inject("ModCompilerService") protected modCompilerService: ModCompilerService, @inject("LocalisationService") protected localisationService: LocalisationService, @inject("ConfigServer") protected configServer: ConfigServer, @inject("ModLoadOrder") protected modLoadOrder: ModLoadOrder, @inject("ModTypeCheck") protected modTypeCheck: ModTypeCheck, ) { this.sptConfig = this.configServer.getConfig(ConfigTypes.CORE); const packageJsonPath: string = path.join(__dirname, "../../package.json"); this.serverDependencies = JSON.parse(this.vfs.readFile(packageJsonPath)).dependencies; this.skippedMods = new Set(); } public async load(container: DependencyContainer): Promise { if (globalThis.G_MODS_ENABLED) { this.container = container; await this.importModsAsync(); await this.executeModsAsync(); } } /** * Returns a list of mods with preserved load order * @returns Array of mod names in load order */ public getImportedModsNames(): string[] { return Object.keys(this.imported); } public getImportedModDetails(): Record { return this.imported; } public getProfileModsGroupedByModName(profileMods: ModDetails[]): ModDetails[] { // Group all mods used by profile by name const modsGroupedByName: Record = {}; for (const mod of profileMods) { if (!modsGroupedByName[mod.name]) { modsGroupedByName[mod.name] = []; } modsGroupedByName[mod.name].push(mod); } // Find the highest versioned mod and add to results array const result = []; for (const modName in modsGroupedByName) { const modDatas = modsGroupedByName[modName]; const modVersions = modDatas.map((x) => x.version); const highestVersion = maxSatisfying(modVersions, "*"); const chosenVersion = modDatas.find((x) => x.name === modName && x.version === highestVersion); if (!chosenVersion) { continue; } result.push(chosenVersion); } return result; } public getModPath(mod: string): string { return `${this.basepath}${mod}/`; } protected async importModsAsync(): Promise { if (!this.vfs.exists(this.basepath)) { // no mods folder found this.logger.info(this.localisationService.getText("modloader-user_mod_folder_missing")); this.vfs.createDir(this.basepath); return; } /** * array of mod folder names */ const mods: string[] = this.vfs.getDirs(this.basepath); this.logger.info(this.localisationService.getText("modloader-loading_mods", mods.length)); // Mod order if (!this.vfs.exists(this.modOrderPath)) { this.logger.info(this.localisationService.getText("modloader-mod_order_missing")); // Write file with empty order array to disk this.vfs.writeFile(this.modOrderPath, this.jsonUtil.serializeAdvanced({ order: [] }, undefined, 4)); } else { const modOrder = this.vfs.readFile(this.modOrderPath, { encoding: "utf8" }); try { const modOrderArray = this.jsonUtil.deserialize(modOrder, this.modOrderPath).order; for (const [index, mod] of modOrderArray.entries()) { this.order[mod] = index; } } catch (error) { this.logger.error(this.localisationService.getText("modloader-mod_order_error")); } } // Validate and remove broken mods from mod list const validMods = this.getValidMods(mods); const modPackageData = this.getModsPackageData(validMods); this.checkForDuplicateMods(modPackageData); // Used to check all errors before stopping the load execution let errorsFound = false; for (const [modFolderName, modToValidate] of modPackageData) { if (this.shouldSkipMod(modToValidate)) { // skip error checking and dependency install for mods already marked as skipped. continue; } // if the mod has library dependencies check if these dependencies are bundled in the server, if not install them if ( modToValidate.dependencies && Object.keys(modToValidate.dependencies).length > 0 && !this.vfs.exists(`${this.basepath}${modFolderName}/node_modules`) ) { this.autoInstallDependencies(`${this.basepath}${modFolderName}`, modToValidate); } // Returns if any mod dependency is not satisfied if (!this.areModDependenciesFulfilled(modToValidate, modPackageData)) { errorsFound = true; } // Returns if at least two incompatible mods are found if (!this.isModCompatible(modToValidate, modPackageData)) { errorsFound = true; } // Returns if mod isnt compatible with this verison of spt if (!this.isModCombatibleWithSpt(modToValidate)) { errorsFound = true; } } if (errorsFound) { this.logger.error(this.localisationService.getText("modloader-no_mods_loaded")); return; } // sort mod order const missingFromOrderJSON = {}; validMods.sort((prev, next) => this.sortMods(prev, next, missingFromOrderJSON)); // log the missing mods from order.json for (const missingMod of Object.keys(missingFromOrderJSON)) { this.logger.debug(this.localisationService.getText("modloader-mod_order_missing_from_json", missingMod)); } // add mods for (const mod of validMods) { const pkg = modPackageData.get(mod); if (this.shouldSkipMod(pkg)) { this.logger.warning(this.localisationService.getText("modloader-skipped_mod", { mod: mod })); continue; } await this.addModAsync(mod, pkg); } this.modLoadOrder.setModList(this.imported); } protected sortMods(prev: string, next: string, missingFromOrderJSON: Record): number { const previndex = this.order[prev]; const nextindex = this.order[next]; // mod is not on the list, move the mod to last if (previndex === undefined) { missingFromOrderJSON[prev] = true; return 1; } if (nextindex === undefined) { missingFromOrderJSON[next] = true; return -1; } return previndex - nextindex; } /** * Check for duplicate mods loaded, show error if any * @param modPackageData map of mod package.json data */ protected checkForDuplicateMods(modPackageData: Map): void { const grouppedMods: Map = new Map(); for (const mod of modPackageData.values()) { const name = `${mod.author}-${mod.name}`; grouppedMods.set(name, [...(grouppedMods.get(name) ?? []), mod]); // if there's more than one entry for a given mod it means there's at least 2 mods with the same author and name trying to load. if (grouppedMods.get(name).length > 1 && !this.skippedMods.has(name)) { this.skippedMods.add(name); } } // at this point this.skippedMods only contains mods that are duplicated, so we can just go through every single entry and log it for (const modName of this.skippedMods) { this.logger.error(this.localisationService.getText("modloader-x_duplicates_found", modName)); } } /** * Returns an array of valid mods. * * @param mods mods to validate * @returns array of mod folder names */ protected getValidMods(mods: string[]): string[] { const validMods: string[] = []; for (const mod of mods) { if (this.validMod(mod)) { validMods.push(mod); } } return validMods; } /** * Get packageJson data for mods * @param mods mods to get packageJson for * @returns map */ protected getModsPackageData(mods: string[]): Map { const loadedMods = new Map(); for (const mod of mods) { loadedMods.set(mod, this.jsonUtil.deserialize(this.vfs.readFile(`${this.getModPath(mod)}/package.json`))); } return loadedMods; } /** * Is the passed in mod compatible with the running server version * @param mod Mod to check compatibiltiy with SPT * @returns True if compatible */ protected isModCombatibleWithSpt(mod: IPackageJsonData): boolean { const sptVersion = globalThis.G_SPTVERSION || this.sptConfig.sptVersion; const modName = `${mod.author}-${mod.name}`; // Error and prevent loading If no sptVersion property exists if (!mod.sptVersion) { this.logger.error(this.localisationService.getText("modloader-missing_sptversion_field", modName)); return false; } // Error and prevent loading if sptVersion property is not a valid semver string if (!(valid(mod.sptVersion) || validRange(mod.sptVersion))) { this.logger.error(this.localisationService.getText("modloader-invalid_sptversion_field", modName)); return false; } // Warning and allow loading if semver is not satisfied if (!satisfies(sptVersion, mod.sptVersion)) { this.logger.warning( this.localisationService.getText("modloader-outdated_sptversion_field", { modName: modName, modVersion: mod.version, desiredSptVersion: mod.sptVersion, }), ); return true; } return true; } /** * Execute each mod found in this.imported * @returns void promise */ protected async executeModsAsync(): Promise { // Sort mods load order const source = this.sortModsLoadOrder(); // Import mod classes for (const mod of source) { if (!this.imported[mod].main) { this.logger.error(this.localisationService.getText("modloader-mod_has_no_main_property", mod)); continue; } const filepath = `${this.getModPath(mod)}${this.imported[mod].main}`; // Import class const modFilePath = `${process.cwd()}/${filepath}`; const requiredMod = require(modFilePath); if (!this.modTypeCheck.isPostV3Compatible(requiredMod.mod)) { this.logger.error(this.localisationService.getText("modloader-mod_incompatible", mod)); delete this.imported[mod]; return; } // Perform async load of mod if (this.modTypeCheck.isPreSptLoadAsync(requiredMod.mod)) { try { await (requiredMod.mod as IPreSptLoadModAsync).preSptLoadAsync(this.container); globalThis[mod] = requiredMod; } catch (err) { this.logger.error( this.localisationService.getText( "modloader-async_mod_error", `${err?.message ?? ""}\n${err.stack ?? ""}`, ), ); } continue; } // Perform sync load of mod if (this.modTypeCheck.isPreSptLoad(requiredMod.mod)) { (requiredMod.mod as IPreSptLoadMod).preSptLoad(this.container); globalThis[mod] = requiredMod; } } } /** * Read loadorder.json (create if doesnt exist) and return sorted list of mods * @returns string array of sorted mod names */ public sortModsLoadOrder(): string[] { // if loadorder.json exists: load it, otherwise generate load order const loadOrderPath = `${this.basepath}loadorder.json`; if (this.vfs.exists(loadOrderPath)) { return this.jsonUtil.deserialize(this.vfs.readFile(loadOrderPath), loadOrderPath); } return this.modLoadOrder.getLoadOrder(); } /** * Compile mod and add into class property "imported" * @param mod Name of mod to compile/add */ protected async addModAsync(mod: string, pkg: IPackageJsonData): Promise { const modPath = this.getModPath(mod); const typeScriptFiles = this.vfs.getFilesOfType(`${modPath}src`, ".ts"); if (typeScriptFiles.length > 0) { if (globalThis.G_MODS_TRANSPILE_TS) { // compile ts into js if ts files exist and globalThis.G_MODS_TRANSPILE_TS is set to true await this.modCompilerService.compileMod(mod, modPath, typeScriptFiles); } else { // rename the mod entry point to .ts if it's set to .js because G_MODS_TRANSPILE_TS is set to false pkg.main = pkg.main.replace(".js", ".ts"); } } // Purge scripts data from package object pkg.scripts = {}; // Add mod to imported list this.imported[mod] = { ...pkg, dependencies: pkg.modDependencies }; this.logger.info( this.localisationService.getText("modloader-loaded_mod", { name: pkg.name, version: pkg.version, author: pkg.author, }), ); } /** * Checks if a given mod should be loaded or skipped. * * @param pkg mod package.json data * @returns */ protected shouldSkipMod(pkg: IPackageJsonData): boolean { return this.skippedMods.has(`${pkg.author}-${pkg.name}`); } protected autoInstallDependencies(modPath: string, pkg: IPackageJsonData): void { const dependenciesToInstall = new Map(); for (const [depName, depVersion] of Object.entries(pkg.dependencies)) { // currently not checking for version mismatches, but we could check it, just don't know what we would do afterwards, some options would be: // 1 - throw an error // 2 - use the server's version (which is what's currently happening by not checking the version) // 3 - use the mod's version (don't know the reprecursions this would have, or if it would even work) // if a mod's dependency does not exist in the server's dependencies we can add it to the list of dependencies to install. if (!this.serverDependencies[depName]) { dependenciesToInstall.set(depName, depVersion); } } // If the mod has no extra dependencies return as there's nothing that needs to be done. if (dependenciesToInstall.size === 0) { return; } // If this feature flag is set to false, we warn the user he has a mod that requires extra dependencies and might not work, point them in the right direction on how to enable this feature. if (!this.sptConfig.features.autoInstallModDependencies) { this.logger.warning( this.localisationService.getText("modloader-installing_external_dependencies_disabled", { name: pkg.name, author: pkg.author, configPath: path.join( globalThis.G_RELEASE_CONFIGURATION ? "SPT_Data/Server/configs" : "assets/configs", "core.json", ), configOption: "autoInstallModDependencies", }), ); this.skippedMods.add(`${pkg.author}-${pkg.name}`); return; } // Temporarily rename package.json because otherwise npm, pnpm and any other package manager will forcefully download all packages in dependencies without any way of disabling this behavior this.vfs.rename(`${modPath}/package.json`, `${modPath}/package.json.bak`); this.vfs.writeFile(`${modPath}/package.json`, "{}"); this.logger.info( this.localisationService.getText("modloader-installing_external_dependencies", { name: pkg.name, author: pkg.author, }), ); const pnpmPath = path.join( process.cwd(), globalThis.G_RELEASE_CONFIGURATION ? "SPT_Data/Server/@pnpm/exe" : "node_modules/@pnpm/exe", os.platform() === "win32" ? "pnpm.exe" : "pnpm", ); let command = `"${pnpmPath}" install `; for (const [depName, depVersion] of dependenciesToInstall) { command += `${depName}@${depVersion} `; } this.logger.debug(`Running command: ${command}`); execSync(command, { cwd: modPath }); // Delete the new blank package.json then rename the backup back to the original name this.vfs.removeFile(`${modPath}/package.json`); this.vfs.rename(`${modPath}/package.json.bak`, `${modPath}/package.json`); } protected areModDependenciesFulfilled(pkg: IPackageJsonData, loadedMods: Map): boolean { if (!pkg.modDependencies) { return true; } const modName = `${pkg.author}-${pkg.name}`; for (const [modDependency, requiredVersion] of Object.entries(pkg.modDependencies)) { // Raise dependency version incompatible if the dependency is not found in the mod list if (!loadedMods.has(modDependency)) { this.logger.error( this.localisationService.getText("modloader-missing_dependency", { mod: modName, modDependency: modDependency, }), ); return false; } if (!satisfies(loadedMods.get(modDependency).version, requiredVersion)) { this.logger.error( this.localisationService.getText("modloader-outdated_dependency", { mod: modName, modDependency: modDependency, currentVersion: loadedMods.get(modDependency).version, requiredVersion: requiredVersion, }), ); return false; } } return true; } protected isModCompatible(mod: IPackageJsonData, loadedMods: Map): boolean { const incompatbileModsList = mod.incompatibilities; if (!incompatbileModsList) { return true; } for (const incompatibleModName of incompatbileModsList) { // Raise dependency version incompatible if any incompatible mod is found if (loadedMods.has(incompatibleModName)) { this.logger.error( this.localisationService.getText("modloader-incompatible_mod_found", { author: mod.author, name: mod.name, incompatibleModName: incompatibleModName, }), ); return false; } } return true; } /** * Validate a mod passes a number of checks * @param modName name of mod in /mods/ to validate * @returns true if valid */ protected validMod(modName: string): boolean { const modPath = this.getModPath(modName); const modIsCalledBepinEx = modName.toLowerCase() === "bepinex"; const modIsCalledUser = modName.toLowerCase() === "user"; const modIsCalledSrc = modName.toLowerCase() === "src"; const modIsCalledDb = modName.toLowerCase() === "db"; const hasBepinExFolderStructure = this.vfs.exists(`${modPath}/plugins`); const containsDll = this.vfs.getFiles(`${modPath}`).find((x) => x.includes(".dll")); if (modIsCalledSrc || modIsCalledDb || modIsCalledUser) { this.logger.error(this.localisationService.getText("modloader-not_correct_mod_folder", modName)); return false; } if (modIsCalledBepinEx || hasBepinExFolderStructure || containsDll) { this.logger.error(this.localisationService.getText("modloader-is_client_mod", modName)); return false; } // Check if config exists const modPackagePath = `${modPath}/package.json`; if (!this.vfs.exists(modPackagePath)) { this.logger.error(this.localisationService.getText("modloader-missing_package_json", modName)); return false; } // Validate mod const config = this.jsonUtil.deserialize(this.vfs.readFile(modPackagePath), modPackagePath); const checks = ["name", "author", "version", "license"]; let issue = false; for (const check of checks) { if (!(check in config)) { this.logger.error( this.localisationService.getText("modloader-missing_package_json_property", { modName: modName, prop: check, }), ); issue = true; } } if (!valid(config.version)) { this.logger.error(this.localisationService.getText("modloader-invalid_version_property", modName)); issue = true; } if ("main" in config) { if (config.main.split(".").pop() !== "js") { // expects js file as entry this.logger.error(this.localisationService.getText("modloader-main_property_not_js", modName)); issue = true; } if (!this.vfs.exists(`${modPath}/${config.main}`)) { // If TS file exists with same name, dont perform check as we'll generate JS from TS file const tsFileName = config.main.replace(".js", ".ts"); const tsFileExists = this.vfs.exists(`${modPath}/${tsFileName}`); if (!tsFileExists) { this.logger.error( this.localisationService.getText("modloader-main_property_points_to_nothing", modName), ); issue = true; } } } if (config.incompatibilities && !Array.isArray(config.incompatibilities)) { this.logger.error( this.localisationService.getText("modloader-incompatibilities_not_string_array", modName), ); issue = true; } return !issue; } public getContainer(): DependencyContainer { if (this.container) { return this.container; } throw new Error(this.localisationService.getText("modloader-dependency_container_not_initalized")); } }