import fs from "node:fs"; import crypto from "node:crypto"; import { Azip } from "./ziplib.js"; import got from "got"; import { Utils } from "./utils.js"; import config from "./config.js"; import { logger } from "./logger.js"; interface IMixinFile { name: string; data: string; } interface IFileInfo { filename: string; hash: string; mixins: IMixinFile[]; } interface IHashResponse { [hash: string]: { project_id: string }; } interface IProjectInfo { id: string; client_side: string; server_side: string; } export class DeEarth { private movePath: string; private modsPath: string; private files: IFileInfo[]; private utils: Utils; constructor(modsPath: string, movePath: string) { this.utils = new Utils(); this.movePath = movePath; this.modsPath = modsPath; this.files = []; logger.debug("DeEarth instance created", { modsPath, movePath }); } async Main(): Promise { logger.info("Starting DeEarth process"); if (!fs.existsSync(this.movePath)) { logger.debug("Creating target directory", { path: this.movePath }); fs.mkdirSync(this.movePath, { recursive: true }); } await this.getFilesInfo(); const clientSideMods = await this.identifyClientSideMods(); await this.moveClientSideMods(clientSideMods); logger.info("DeEarth process completed"); } private async identifyClientSideMods(): Promise { const clientMods: string[] = []; if (config.filter.hashes) { logger.info("Starting hash check for client-side mods"); clientMods.push(...await this.checkHashesForClientMods()); } if (config.filter.mixins) { logger.info("Starting mixins check for client-side mods"); clientMods.push(...await this.checkMixinsForClientMods()); } const uniqueMods = [...new Set(clientMods)]; logger.info("Client-side mods identified", { count: uniqueMods.length, mods: uniqueMods }); return uniqueMods; } private async checkHashesForClientMods(): Promise { const hashToFilename = new Map(); const hashes = this.files.map(file => { hashToFilename.set(file.hash, file.filename); return file.hash; }); logger.debug("Checking mod hashes with Modrinth API", { fileCount: this.files.length }); try { const fileInfoResponse = await got.post(`${this.utils.modrinth_url}/v2/version_files`, { headers: { "User-Agent": "DeEarth", "Content-Type": "application/json" }, json: { hashes, algorithm: "sha1" } }).json(); const projectIdToFilename = new Map(); const projectIds = Object.entries(fileInfoResponse) .map(([hash, info]) => { const filename = hashToFilename.get(hash); if (filename) projectIdToFilename.set(info.project_id, filename); return info.project_id; }); const projectsResponse = await got.get(`${this.utils.modrinth_url}/v2/projects?ids=${JSON.stringify(projectIds)}`, { headers: { "User-Agent": "DeEarth" } }).json(); const clientMods = projectsResponse .filter(p => p.client_side === "required" && p.server_side === "unsupported") .map(p => projectIdToFilename.get(p.id)) .filter(Boolean) as string[]; logger.debug("Hash check completed", { count: clientMods.length }); return clientMods; } catch (error: any) { logger.error("Hash check failed", error); return []; } } private async checkMixinsForClientMods(): Promise { const clientMods: string[] = []; for (const file of this.files) { for (const mixin of file.mixins) { try { const config = JSON.parse(mixin.data); if (!config.mixins?.length && config.client?.length > 0 && !file.filename.includes("lib")) { clientMods.push(file.filename); break; } } catch (error: any) { logger.warn("Failed to parse mixin config", { filename: file.filename, mixin: mixin.name, error: error.message }); } } } logger.debug("Mixins check completed", { count: clientMods.length }); return [...new Set(clientMods)]; } private async moveClientSideMods(clientMods: string[]): Promise { if (!clientMods.length) { logger.info("No client-side mods to move"); return; } let successCount = 0, errorCount = 0; for (const sourcePath of clientMods) { try { const targetPath = `${this.movePath}/${sourcePath.replace(this.modsPath, "").replace(/^\/+/, "")}`; logger.info("Moving file", { source: sourcePath, target: targetPath }); await fs.promises.rename(sourcePath, targetPath); successCount++; } catch (error: any) { logger.error("Failed to move file", { source: sourcePath, error: error.message }); errorCount++; } } logger.info("File movement completed", { total: clientMods.length, success: successCount, error: errorCount }); } private async getFilesInfo(): Promise { const jarFiles = this.getJarFiles(); logger.info("Getting file information", { fileCount: jarFiles.length }); for (const jarFilename of jarFiles) { const fullPath = `${this.modsPath}/${jarFilename}`; try { const fileData = fs.readFileSync(fullPath); const mixins = await this.extractMixins(fileData); this.files.push({ filename: fullPath, hash: crypto.createHash('sha1').update(fileData).digest('hex'), mixins }); logger.debug("File processed", { filename: fullPath, mixinCount: mixins.length }); } catch (error: any) { logger.error("Error processing file", { filename: fullPath, error: error.message }); } } logger.debug("File information collection completed", { processedFiles: this.files.length }); } private getJarFiles(): string[] { if (!fs.existsSync(this.modsPath)) fs.mkdirSync(this.modsPath, { recursive: true }); return fs.readdirSync(this.modsPath).filter(f => f.endsWith(".jar")); } private async extractMixins(jarData: Buffer): Promise { const mixins: IMixinFile[] = []; const zipEntries = Azip(jarData); await Promise.all(zipEntries.map(async (entry) => { if (entry.entryName.endsWith(".mixins.json") && !entry.entryName.includes("/")) { try { const data = await entry.getData(); mixins.push({ name: entry.entryName, data: data.toString() }); } catch (error: any) { logger.error(`Error extracting ${entry.entryName}`, error); } } })); return mixins; } }