feat: add support for installing modpacks from a link or a file in modrinth format

This commit is contained in:
HerozDotExe 2026-03-27 12:31:37 +01:00
parent 01ba9f44bf
commit 2cf9f73ce9
14 changed files with 375 additions and 37 deletions

1
.gitignore vendored
View file

@ -145,6 +145,7 @@ tests/temp
tests/vanilla/*
tests/modloaders/forge/*
tests/modloaders/neoforge/*
tests/modloaders/fabric/*
# Test files
handler.js

View file

@ -1,10 +1,9 @@
import path from "path";
import { isNeeded } from "../utils/rules";
import { Argument, Version } from "../utils/types";
import { Argument, Version, Config } from "../utils/types";
import { getLibraries } from "./libraries";
import { version as packageVersion } from "../../package.json";
import { getArgument } from "./log4j";
import { Config } from "../launcher/instance";
// Fill templates
function fillArguments(

View file

@ -1,14 +1,13 @@
import path from "path";
import { downloadFile, fetchJson } from "../utils/fetch";
import { getTempFolder } from "../utils/temp";
import { LauncherProfiles, logger, Modloader, FabricInstallerMeta } from "../utils/types";
import { LauncherProfiles, logger, Modloader, FabricInstallerMeta, ModloaderConfig } from "../utils/types";
import fs from "fs/promises";
import { spawn } from "child_process";
import { version as packageVersion } from "../../package.json";
import { ensureDir, exists, readJson } from "../utils/fs";
import { InstallError } from "../utils/errors";
import AdmZip from "adm-zip";
import { Config } from "../launcher/instance";
const versionWithDoubleName = ["1.9.4", "1.9.0", "1.8.9", "1.8.8", "1.8", "1.7.10"]
export function fixVersionWithDoubleName(version: string, modloader: Modloader) {
@ -20,10 +19,6 @@ export function fixVersionWithDoubleName(version: string, modloader: Modloader)
return modLoaderVersion
}
export interface ModloaderConfig extends Config {
modloader: Modloader
}
export function getVersionId(config: ModloaderConfig) {
return `${config.version}-${config.modloader.name}-${config.modloader.version}`
}
@ -66,7 +61,7 @@ async function downloadJar(
path: filePath,
});
} else if (config.modloader.name === "fabric") {
const fabricInstaller = (await fetchJson<FabricInstallerMeta>("https://meta.fabricmc.net/v2/versions/installer")).find(i => i.stable)
const fabricInstaller = (await fetchJson<FabricInstallerMeta[]>("https://meta.fabricmc.net/v2/versions/installer")).find(i => i.stable)
if (!fabricInstaller) {
throw new Error("Couldn't download fabric installer")
}
@ -194,7 +189,6 @@ export async function installForge(
}
const versionId = getVersionId(config)
console.log(versionId)
if (isModernForge(config.version)) {
// Modern forge (version >= 1.12.2)
@ -312,6 +306,5 @@ export async function installFabric(config: ModloaderConfig, logger: logger) {
const versionId = getVersionId(config)
await ensureDir(path.join(config.paths.versions, versionId))
console.log(path.join(config.paths.versions, originalVersionId, `${originalVersionId}.json`), path.join(config.paths.versions, versionId, `${versionId}.json`))
await fs.cp(path.join(fakeLauncher, "versions", originalVersionId, `${originalVersionId}.json`), path.join(config.paths.versions, versionId, `${versionId}.json`))
}

View file

@ -1,3 +1,4 @@
export { offlineAuth } from "./auth";
export { Instance, defineConfig } from "./instance";
export { RuntimeManager, getJavaComponent } from "./java";
export { importModrinthModpack } from "./modrinth"

View file

@ -1,28 +1,14 @@
import { EventEmitter } from "node:stream";
import { Auth, InstanceEvents, logger, Modloader, Paths, ProcessArgs, ProcessRam, Version } from "../utils/types";
import { BaseConfig, Config, InstanceEvents, logger, PoolFile, Version, ModloaderConfig } from "../utils/types";
import path from "node:path";
import fs from "fs/promises"
import { argumentsGenerator, AssetsDownloader, launch, LibrariesDownloader, NativesDownloader, version } from "../core";
import { ConfigError, InstallError, LaunchError } from "../utils/errors";
import { checkJava } from "./java";
import { fixVersionWithDoubleName, installFabric, installForge, ModloaderConfig } from "../core/modloaders";
import { fixVersionWithDoubleName, installFabric, installForge } from "../core/modloaders";
import { ChildProcessWithoutNullStreams } from "node:child_process";
import { prepareManifest } from "../core/mergeManifests";
export interface BaseConfig {
version: string;
auth: Auth;
paths: Paths
javaExecutable: string
modloader?: Modloader;
args?: ProcessArgs;
ram?: { max?: string; min?: string }
}
export interface Config extends BaseConfig {
paths: Required<Paths>
args: Required<ProcessArgs>
ram: Required<ProcessRam>
}
import { DownloadPool } from "../utils/fetch";
export function defineConfig(logger: logger, ...layers: Partial<BaseConfig>[]) {
let config: Partial<BaseConfig> = {} as Partial<BaseConfig>;
@ -45,6 +31,10 @@ export function defineConfig(logger: logger, ...layers: Partial<BaseConfig>[]) {
config.ram = { max: config.ram?.max ?? "2G", min: config.ram?.min ?? "2G" };
if (!config.files) {
config.files = []
}
return config as Config
}
@ -198,6 +188,38 @@ export class Instance extends EventEmitter<InstanceEvents> {
throw new InstallError("An error occured while installing the modloader", "install-modloader", this.config, this.logger, { cause: error })
}
}
if (this.config.files.length > 0) {
this.log("install-modpack", "Downloading modpack's files")
try {
const files = this.config.files.map<PoolFile>(f => {
return { url: f.url, path: path.join(this.config.paths.instance, f.path), size: f.size }
})
const pool = new DownloadPool(files, { pQueueOptions: { concurrency: 5 }, overwrite: false })
pool.on("completed", () => {
this.emit(
"progress",
"modpack-files",
pool.done,
pool.total,
pool.doneSize,
pool.totalSize,
);
});
await pool.run();
} catch (error) {
throw new InstallError("An error occured while downloading modpack's files", "install-modpack", this.config, this.logger, { cause: error })
}
}
if (this.config.overridesPath) {
this.log("install-modpack", "Copying modpack's overrides")
try {
await fs.cp(this.config.overridesPath, this.config.paths.instance, {recursive: true})
} catch (error) {
throw new InstallError("An error occured while copying modpack's overrides", "install-modpack", this.config, this.logger, { cause: error })
}
}
}
async launch(): Promise<ChildProcessWithoutNullStreams> {

47
src/launcher/modrinth.ts Normal file
View file

@ -0,0 +1,47 @@
import path from "node:path";
import { downloadFile } from "../utils/fetch";
import { getTempFolder } from "../utils/temp";
import { unzipAll } from "../utils/unzip";
import { ensureDir, exists, readJson } from "../utils/fs";
import { Modloader, ModrinthManifest, BaseConfig, PoolFile } from "../utils/types";
function getModloader(manifest: ModrinthManifest): Modloader {
if (manifest.dependencies["fabric-loader"]) {
return { name: "fabric", version: manifest.dependencies["fabric-loader"] }
} else if (manifest.dependencies["forge"]) {
return { name: "forge", version: manifest.dependencies["forge"] }
} else if (manifest.dependencies["neoforge"]) {
return { name: "neoforge", version: manifest.dependencies["neoforge"] }
} else {
throw new Error("Unknown modloader")
}
}
export async function importModrinthModpack(source: string, sourceType: "url" | "file"): Promise<Partial<BaseConfig>> {
const tempFolder = await getTempFolder("modrinth")
if (sourceType === "url") {
const outputFile = path.join(tempFolder, "pack.mrpack")
await downloadFile({ path: outputFile, url: source })
source = outputFile
}
if (!await exists(source)) {
throw new Error("Couldn't find modpack file")
}
await ensureDir(path.join(tempFolder, "pack"))
await unzipAll(source, path.join(tempFolder, "pack"), [], "overwrite")
const manifest = await readJson<ModrinthManifest>(path.join(tempFolder, "pack", "modrinth.index.json"))
return {
version: manifest.dependencies.minecraft,
modloader: getModloader(manifest),
files: manifest.files.map<PoolFile>(f => {
return { url: f.downloads[0], path: f.path, size: f.fileSize }
}),
overridesPath: path.join(tempFolder, "pack", "overrides")
}
}

View file

@ -253,4 +253,49 @@ export type FabricInstallerMeta = {
maven: string,
version: string,
stable: boolean
}[]
}
export type ModrinthManifestFile = {
path: string,
hashes : {sha1: string, sha512: string},
env: {client: string, server: string},
downloads: string[],
fileSize: number
}
export type ModrinthManifest = {
formatVersion: number,
game: string,
versionId: string,
name: string,
files: ModrinthManifestFile[],
dependencies: {
minecraft: string,
"fabric-loader": string
forge: string,
neoforge: string
}
}
export interface BaseConfig {
version: string;
auth: Auth;
paths: Paths
javaExecutable: string
modloader?: Modloader;
args?: ProcessArgs;
ram?: { max?: string; min?: string },
files?: PoolFile[],
overridesPath?: string
}
export interface Config extends BaseConfig {
paths: Required<Paths>
args: Required<ProcessArgs>
ram: Required<ProcessRam>
files: PoolFile[]
}
export interface ModloaderConfig extends Config {
modloader: Modloader
}

View file

@ -16,7 +16,7 @@ export async function unzipAll(from: string, to: string, filters: string[], mode
}
if (shouldSkip) continue;
if (!await exists(path.join(to, entry.entryName)) || mode === "overwrite") {
zip.extractEntryTo(entry.entryName, to);
zip.extractEntryTo(entry.entryName, to, true, mode === "overwrite");
}
}
}

View file

@ -3,7 +3,11 @@ import path from "path"
import { Browser, BrowserWindow } from 'happy-dom';
import PQueue from 'p-queue';
// Use "java" tests if you don't want tests to download java by themselves, you will need to provide binaries path :
// pnpm run test /path/to/java8/bin/java /path/to/java21/bin/java
const importantVersions = ["1.7.10", "1.12.2", "1.16.5", "1.18.2", "1.20.1", "1.21.1", "1.21.11"]
const modpacksUrls = ["https://cdn.modrinth.com/data/1KVo5zza/versions/FbnAVst2/Fabulously.Optimized-v12.0.7.mrpack", "https://cdn.modrinth.com/data/4BV47HRn/versions/krFPoaLH/Better%20MC%20%5BFORGE%5D%20BMC4%20v43.mrpack"]
const java8 = process.argv[2]
const java21 = process.argv[3]
@ -45,14 +49,16 @@ async function fExists(path) {
const tempFolder = import.meta.dirname
if (await fExists(path.join(tempFolder, "vanilla"))) { await fs.rm(path.join(tempFolder, "vanilla"), { recursive: true }) }
if (await fExists(path.join(tempFolder, "modrinth"))) { await fs.rm(path.join(tempFolder, "modrinth"), { recursive: true }) }
if (await fExists(path.join(tempFolder, "modloaders/forge"))) { await fs.rm(path.join(tempFolder, "modloaders/forge"), { recursive: true }) }
if (await fExists(path.join(tempFolder, "modloaders/neoforge"))) { await fs.rm(path.join(tempFolder, "modloaders/neoforge"), { recursive: true }) }
if (await fExists(path.join(tempFolder, "modloaders/fabric"))) { await fs.rm(path.join(tempFolder, "modloaders/fabric"), { recursive: true }) }
await fs.mkdir(path.join(tempFolder, "vanilla"), {recursive: true})
await fs.mkdir(path.join(tempFolder, "modloaders/forge"), {recursive: true})
await fs.mkdir(path.join(tempFolder, "modloaders/neoforge"), {recursive: true})
await fs.mkdir(path.join(tempFolder, "modloaders/fabric"), {recursive: true})
await fs.mkdir(path.join(tempFolder, "vanilla"), { recursive: true })
await fs.mkdir(path.join(tempFolder, "modrinth"), { recursive: true })
await fs.mkdir(path.join(tempFolder, "modloaders/forge"), { recursive: true })
await fs.mkdir(path.join(tempFolder, "modloaders/neoforge"), { recursive: true })
await fs.mkdir(path.join(tempFolder, "modloaders/fabric"), { recursive: true })
// Forge
{
@ -89,7 +95,7 @@ await fs.mkdir(path.join(tempFolder, "modloaders/fabric"), {recursive: true})
const parsedVersion = v.split(".")
const start = `1.${parsedVersion[0]}.${parsedVersion[1]}`
return start
})).map(arr => arr[arr.length-1])
})).map(arr => arr[arr.length - 1])
for (const neoForgeVersion of versions) {
const parsedVersion = neoForgeVersion.split(".")
@ -156,3 +162,22 @@ await fs.mkdir(path.join(tempFolder, "modloaders/fabric"), {recursive: true})
}
});
}
// Modrinth modpacks
{
modpacksUrls.forEach(async (url) => {
const modpackId = url.split("/").pop().replace(".mrpack", "")
let result = ""
if (java) {
const template = await fs.readFile(path.join(tempFolder, "modrinth-java.test.ts.template"), { encoding: "utf-8" })
// TODO: use java version based on modpack's minecraft version
result = template.replaceAll("${modpack_url}", url).replaceAll("${modpack_id}", modpackId).replaceAll("${java}", java21)
} else {
const template = await fs.readFile(path.join(tempFolder, "modrinth.test.ts.template"), { encoding: "utf-8" })
// Same but inside tests
result = template.replaceAll("${modpack_url}", url).replaceAll("${modpack_id}", modpackId)
}
await fs.writeFile(path.join(tempFolder, `modrinth/modrinth-${modpackId}.test.ts`), result)
console.log(`Done modrinth ${modpackId}`)
})
}

View file

@ -0,0 +1,50 @@
import { test } from "vitest";
import { Instance, offlineAuth, RuntimeManager, getJavaComponent, importModrinthModpack } from "../../dist/index.js";
import path from "path";
import fs from "fs/promises";
const gameRoot = path.join(import.meta.dirname, "..", "temp");
// await fs.rm(gameRoot, { recursive: true, force: true });
// await fs.mkdir(gameRoot, { recursive: true });
test("launch game", { timeout: 0, tags: ["full", "modrinth"] }, async () => {
const javaManager = new RuntimeManager(path.join(gameRoot, "java"));
javaManager.on("progress", console.log)
// const java = await javaManager.use(await getJavaComponent("${version}"))
const instance = new Instance({
auth: offlineAuth("player"),
paths: { root: gameRoot, instance: path.join(gameRoot, "instances", "${modpack_id}") },
javaExecutable: "${java}",
ram: {max:"4G",min:"4G"}
},
await importModrinthModpack("${modpack_url}", "url")
);
instance.on("progress", console.log);
instance.on("log", (step, msg) => {
console.log(`[${step}] ${msg}`)
})
await instance.install();
const p = await instance.launch();
p.stdout.on("data", (d: Buffer) => {
console.log(d.toString());
});
p.stderr.on("data", (d: Buffer) => {
console.log(d.toString());
});
await new Promise<void>((res) => {
p.on("error", (d: Buffer) => {
console.log(d.toString());
res();
});
p.on("close", () => {
console.log("closed");
res();
});
});
});

View file

@ -0,0 +1,50 @@
import { test } from "vitest";
import { Instance, offlineAuth, RuntimeManager, getJavaComponent, importModrinthModpack } from "../../../dist/index.js";
import path from "path";
import fs from "fs/promises";
const gameRoot = path.join(import.meta.dirname, "..", "temp");
// await fs.rm(gameRoot, { recursive: true, force: true });
// await fs.mkdir(gameRoot, { recursive: true });
test("launch game", { timeout: 0, tags: ["full", "modrinth"] }, async () => {
const javaManager = new RuntimeManager(path.join(gameRoot, "java"));
javaManager.on("progress", console.log)
const java = await javaManager.use(await getJavaComponent("${version}"))
const instance = new Instance({
auth: offlineAuth("player"),
paths: { root: gameRoot, instance: path.join(gameRoot, "instances", "${modpack_id}") },
javaExecutable: java,
ram: {max:"4G",min:"4G"}
},
await importModrinthModpack("${modpack_url}", "url")
);
instance.on("progress", console.log);
instance.on("log", (step, msg) => {
console.log(`[${step}] ${msg}`)
})
await instance.install();
const p = await instance.launch();
p.stdout.on("data", (d: Buffer) => {
console.log(d.toString());
});
p.stderr.on("data", (d: Buffer) => {
console.log(d.toString());
});
await new Promise<void>((res) => {
p.on("error", (d: Buffer) => {
console.log(d.toString());
res();
});
p.on("close", () => {
console.log("closed");
res();
});
});
});

View file

@ -0,0 +1,50 @@
import { test } from "vitest";
import { Instance, offlineAuth, RuntimeManager, getJavaComponent, importModrinthModpack } from "../../dist/index.js";
import path from "path";
import fs from "fs/promises";
const gameRoot = path.join(import.meta.dirname, "..", "temp");
// await fs.rm(gameRoot, { recursive: true, force: true });
// await fs.mkdir(gameRoot, { recursive: true });
test("launch game", { timeout: 0, tags: ["full", "modrinth"] }, async () => {
const javaManager = new RuntimeManager(path.join(gameRoot, "java"));
javaManager.on("progress", console.log)
// const java = await javaManager.use(await getJavaComponent("${version}"))
const instance = new Instance({
auth: offlineAuth("player"),
paths: { root: gameRoot, instance: path.join(gameRoot, "instances", "Better%20MC%20%5BFORGE%5D%20BMC4%20v43") },
javaExecutable: "/nix/store/wrf2p3qb5sycka2y4nnl7pdm8h6zpcin-openjdk-21.0.10+7/bin/java",
ram: {max:"4G",min:"4G"}
},
await importModrinthModpack("https://cdn.modrinth.com/data/4BV47HRn/versions/krFPoaLH/Better%20MC%20%5BFORGE%5D%20BMC4%20v43.mrpack", "url")
);
instance.on("progress", console.log);
instance.on("log", (step, msg) => {
console.log(`[${step}] ${msg}`)
})
await instance.install();
const p = await instance.launch();
p.stdout.on("data", (d: Buffer) => {
console.log(d.toString());
});
p.stderr.on("data", (d: Buffer) => {
console.log(d.toString());
});
await new Promise<void>((res) => {
p.on("error", (d: Buffer) => {
console.log(d.toString());
res();
});
p.on("close", () => {
console.log("closed");
res();
});
});
});

View file

@ -0,0 +1,50 @@
import { test } from "vitest";
import { Instance, offlineAuth, RuntimeManager, getJavaComponent, importModrinthModpack } from "../../dist/index.js";
import path from "path";
import fs from "fs/promises";
const gameRoot = path.join(import.meta.dirname, "..", "temp");
// await fs.rm(gameRoot, { recursive: true, force: true });
// await fs.mkdir(gameRoot, { recursive: true });
test("launch game", { timeout: 0, tags: ["full", "modrinth"] }, async () => {
const javaManager = new RuntimeManager(path.join(gameRoot, "java"));
javaManager.on("progress", console.log)
// const java = await javaManager.use(await getJavaComponent("${version}"))
const instance = new Instance({
auth: offlineAuth("player"),
paths: { root: gameRoot, instance: path.join(gameRoot, "instances", "Fabulously.Optimized-v12.0.7") },
javaExecutable: "/nix/store/wrf2p3qb5sycka2y4nnl7pdm8h6zpcin-openjdk-21.0.10+7/bin/java",
ram: {max:"4G",min:"4G"}
},
await importModrinthModpack("/home/heroz/Downloads/Fabulously.Optimized-v12.0.7.mrpack", "file")
);
instance.on("progress", console.log);
instance.on("log", (step, msg) => {
console.log(`[${step}] ${msg}`)
})
await instance.install();
const p = await instance.launch();
p.stdout.on("data", (d: Buffer) => {
console.log(d.toString());
});
p.stderr.on("data", (d: Buffer) => {
console.log(d.toString());
});
await new Promise<void>((res) => {
p.on("error", (d: Buffer) => {
console.log(d.toString());
res();
});
p.on("close", () => {
console.log("closed");
res();
});
});
});

View file

@ -18,6 +18,11 @@ export default defineConfig({
name: 'forge',
description: 'Only forge and neoforge tests from full test suite',
concurrent: false
},
{
name: 'modrinth',
description: 'Only tests that uses populars modrinth modpacks',
concurrent: false
}
],
},