import { existsSync, mkdirSync, writeFileSync } from "fs"; import { join } from "path"; import { MODS_DIR, CLIENT_MODS_DIR } from "./constants"; import { getModDetails } from "./mods"; const API = "https://api.modrinth.com/v2"; const GAME_VERSION = "1.20.1"; const LOADER = "forge"; export type ModSide = "client" | "server" | "both"; export type SearchResult = { slug: string; title: string; description: string; icon_url: string; downloads: number; project_id: string; author: string; date_modified: string; follows: number; }; export type ModDownload = { projectId: string; versionId: string; title: string; filename: string; url: string; isDependency: boolean; alreadyInstalled: boolean; side: ModSide; }; export type ResolveResult = { toInstall: ModDownload[]; skipped: ModDownload[]; conflicts: string[]; }; // ── Search ────────────────────────────────────────────────── export async function searchMods(query: string): Promise { const facets = JSON.stringify([ [`categories:${LOADER}`], [`versions:${GAME_VERSION}`], ["project_type:mod"], ]); const url = `${API}/search?query=${encodeURIComponent(query)}&facets=${encodeURIComponent(facets)}&limit=12`; const res = await fetch(url, { headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" }, }); if (!res.ok) throw new Error(`Modrinth search failed: ${res.status}`); const data = await res.json(); return data.hits.map((h: Record) => ({ slug: h.slug as string, title: h.title as string, description: h.description as string, icon_url: (h.icon_url as string) || "", downloads: (h.downloads as number) ?? 0, project_id: h.project_id as string, author: (h.author as string) || "", date_modified: (h.date_modified as string) || "", follows: (h.follows as number) ?? 0, })); } // ── Get project details (title + side info) ───────────────── type ProjectDetails = { title: string; client_side: string; server_side: string; }; async function getProjectDetails( projectId: string ): Promise { try { const res = await fetch(`${API}/project/${projectId}`, { headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)", }, }); if (!res.ok) return null; const data = await res.json(); return { title: data.title || projectId, client_side: data.client_side || "optional", server_side: data.server_side || "optional", }; } catch { return null; } } export function classifySide( client_side: string, server_side: string ): ModSide { if (server_side === "unsupported") return "client"; if (client_side === "unsupported") return "server"; return "both"; } // ── Get latest version for a project ──────────────────────── type VersionFile = { url: string; filename: string }; type VersionDep = { project_id: string | null; dependency_type: string; }; type VersionData = { id: string; name: string; files: VersionFile[]; dependencies: VersionDep[]; }; export async function getLatestVersion( projectId: string ): Promise { const url = `${API}/project/${projectId}/version?game_versions=["${GAME_VERSION}"]&loaders=["${LOADER}"]`; const res = await fetch(url, { headers: { "User-Agent": "HurkiCorgiMC/1.0 (minecraft.hurkicorgi.com)" }, }); if (!res.ok) return null; const versions: VersionData[] = await res.json(); return versions.length > 0 ? versions[0] : null; } // ── Resolve dependencies ──────────────────────────────────── export async function resolveDependencies( projectIds: string[], titles: Record ): Promise { // Cache installed mods once for the entire resolution const installed = getModDetails(); const toInstall: ModDownload[] = []; const skipped: ModDownload[] = []; const conflicts: string[] = []; const visited = new Set(); const queue = projectIds.map((id) => ({ id, isDep: false })); while (queue.length > 0) { const item = queue.shift()!; if (visited.has(item.id)) continue; visited.add(item.id); const [version, details] = await Promise.all([ getLatestVersion(item.id), getProjectDetails(item.id), ]); if (!version || version.files.length === 0) { if (!item.isDep) { conflicts.push( `"${titles[item.id] || item.id}" has no Forge 1.20.1 version available` ); } continue; } const title = titles[item.id] || details?.title || item.id; const side = details ? classifySide(details.client_side, details.server_side) : "both"; const file = version.files[0]; const mod: ModDownload = { projectId: item.id, versionId: version.id, title, filename: file.filename, url: file.url, isDependency: item.isDep, alreadyInstalled: false, side, }; // Check if already installed (by filename as rough proxy) const filenameBase = file.filename.replace(/-[\d.]+.*\.jar$/, "").toLowerCase(); const isInstalled = installed.some( (m) => m.filename === file.filename || m.filename.toLowerCase().startsWith(filenameBase) ); if (isInstalled) { mod.alreadyInstalled = true; skipped.push(mod); } else { toInstall.push(mod); } // Queue required dependencies for (const dep of version.dependencies) { if ( dep.dependency_type === "required" && dep.project_id && !visited.has(dep.project_id) ) { queue.push({ id: dep.project_id, isDep: true }); } } } return { toInstall, skipped, conflicts }; } // ── Download a mod ────────────────────────────────────────── export async function downloadMod( url: string, filename: string, side: ModSide = "both" ): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`Failed to download ${filename}: ${res.status}`); const targetDir = side === "client" ? CLIENT_MODS_DIR : MODS_DIR; if (side === "client" && !existsSync(CLIENT_MODS_DIR)) { mkdirSync(CLIENT_MODS_DIR, { recursive: true }); } const buffer = Buffer.from(await res.arrayBuffer()); writeFileSync(join(targetDir, filename), buffer); }