From 76ab09a0102b0228da3bb6702cc522bec3b07530 Mon Sep 17 00:00:00 2001 From: jebibot <83044352+jebibot@users.noreply.github.com> Date: Mon, 4 Dec 2023 07:37:14 +0900 Subject: [PATCH] feat: support Fabric (#66) * Initial scaffolding for Fabric. * refactor: extract common ModStructure * feat: add FabricModStructure * refactor: add name field to VersionRepoStructure * feat: FabricResolver * feat: support Fabric * docs: update README * Small changes. * Add additional note. * Upgrade helios-distribution-types. --------- Co-authored-by: Daniel Scalzi --- README.md | 8 ++ package-lock.json | 8 +- package.json | 2 +- src/index.ts | 25 +++- src/model/fabric/FabricMeta.ts | 49 +++++++ src/model/fabric/FabricModJson.ts | 13 ++ src/model/nebula/ServerMeta.ts | 19 +++ src/parser/CurseForgeParser.ts | 6 +- src/resolver/BaseResolver.ts | 12 +- src/resolver/fabric/Fabric.resolver.ts | 123 ++++++++++++++++++ src/resolver/forge/Forge.resolver.ts | 13 +- src/structure/FileStructure.ts | 2 +- src/structure/repo/Repo.struct.ts | 5 +- src/structure/repo/VersionRepo.struct.ts | 18 ++- src/structure/spec_model/Server.struct.ts | 45 ++++++- .../spec_model/module/FabricMod.struct.ts | 91 +++++++++++++ .../spec_model/module/ForgeMod.struct.ts | 27 +--- src/structure/spec_model/module/Mod.struct.ts | 66 ++++++++++ .../module/forgemod/ForgeMod113.struct.ts | 60 ++------- .../module/forgemod/ForgeMod17.struct.ts | 64 ++------- src/util/VersionSegmentedRegistry.ts | 2 +- src/util/VersionUtil.ts | 87 ++++++++++--- 22 files changed, 564 insertions(+), 181 deletions(-) create mode 100644 src/model/fabric/FabricMeta.ts create mode 100644 src/model/fabric/FabricModJson.ts create mode 100644 src/resolver/fabric/Fabric.resolver.ts create mode 100644 src/structure/spec_model/module/FabricMod.struct.ts create mode 100644 src/structure/spec_model/module/Mod.struct.ts diff --git a/README.md b/README.md index f1bd451..4f8d3a6 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,13 @@ Options: * OPTIONAL (default: null) * If not provided forge will not be enabled. * You can provide either `latest` or `recommended` to use the latest/recommended version of forge. +* `--fabric ` Specify fabric loader version + * OPTIONAL (default: null) + * If not provided fabric will not be enabled. + * You can provide either `latest` or `recommended` to use the latest/recommended version of fabric. + +> [!NOTE] +> Forge and fabric cannot be used together on the same server. This command will fail if both are provided. > > Example Usage @@ -227,6 +234,7 @@ Ex. * `files` All modules of type `File`. * `libraries` All modules of type `Library` * `forgemods` All modules of type `ForgeMod`. + * `fabricmods` All modules of type `FabricMod`. * This is a directory of toggleable modules. See the note below. * `TestServer-1.12.2.png` Server icon file. diff --git a/package-lock.json b/package-lock.json index 31fbdc5..933a99b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dotenv": "^16.3.1", "fs-extra": "^11.1.1", "got": "^13.0.0", - "helios-distribution-types": "^1.2.0", + "helios-distribution-types": "^1.3.0", "luxon": "^3.4.3", "minimatch": "^9.0.3", "node-stream-zip": "^1.15.0", @@ -1505,9 +1505,9 @@ } }, "node_modules/helios-distribution-types": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/helios-distribution-types/-/helios-distribution-types-1.2.0.tgz", - "integrity": "sha512-C8mRJGK0zAc7rRnA06Sj0LYwVqhY445UYNTmXU876AmfBirRR2F+A3LsD3osdgTxRMzrgkxBXvYZ0QbYW6j+6Q==" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/helios-distribution-types/-/helios-distribution-types-1.3.0.tgz", + "integrity": "sha512-MP66JRHvmuE9yDoZoKeFDh3stsHger0w/cRcJAlV7UYw5ztR3m/uLbWdbfFV68B1Yc0+hDIiuFsuJT/Ve9xuiw==" }, "node_modules/http-cache-semantics": { "version": "4.1.1", diff --git a/package.json b/package.json index 4883b13..439f4cd 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "dotenv": "^16.3.1", "fs-extra": "^11.1.1", "got": "^13.0.0", - "helios-distribution-types": "^1.2.0", + "helios-distribution-types": "^1.3.0", "luxon": "^3.4.3", "minimatch": "^9.0.3", "node-stream-zip": "^1.15.0", diff --git a/src/index.ts b/src/index.ts index edca72d..39a0567 100644 --- a/src/index.ts +++ b/src/index.ts @@ -174,16 +174,22 @@ const generateServerCommand: CommandModule = { }) .option('forge', { describe: 'Forge version.', - type: 'string', - default: null + type: 'string' }) + .option('fabric', { + describe: 'Fabric version.', + type: 'string' + }) + .conflicts('forge', 'fabric') }, handler: async (argv) => { argv.root = getRoot() logger.debug(`Root set to ${argv.root}`) logger.debug(`Generating server ${argv.id} for Minecraft ${argv.version}.`, - `\n\t└ Forge version: ${argv.forge}`) + `\n\t└ Forge version: ${argv.forge}`, + `\n\t└ Fabric version: ${argv.fabric}` + ) const minecraftVersion = new MinecraftVersion(argv.version as string) @@ -196,12 +202,22 @@ const generateServerCommand: CommandModule = { } } + if(argv.fabric != null) { + if (VersionUtil.isPromotionVersion(argv.fabric as string)) { + logger.debug(`Resolving ${argv.fabric as string} Fabric Version..`) + const version = await VersionUtil.getPromotedFabricVersion(argv.fabric as string) + logger.debug(`Fabric version set to ${version}`) + argv.fabric = version + } + } + const serverStruct = new ServerStructure(argv.root as string, getBaseURL(), false, false) await serverStruct.createServer( argv.id as string, minecraftVersion, { - forgeVersion: argv.forge as string + forgeVersion: argv.forge as string, + fabricVersion: argv.fabric as string } ) } @@ -234,6 +250,7 @@ const generateServerCurseForgeCommand: CommandModule = { const minecraftVersion = new MinecraftVersion(modpackManifest.minecraft.version) // Extract forge version + // TODO Support fabric const forgeModLoader = modpackManifest.minecraft.modLoaders.find(({ id }) => id.toLowerCase().startsWith('forge-')) const forgeVersion = forgeModLoader != null ? forgeModLoader.id.substring('forge-'.length) : undefined logger.debug(`Forge version set to ${forgeVersion}`) diff --git a/src/model/fabric/FabricMeta.ts b/src/model/fabric/FabricMeta.ts new file mode 100644 index 0000000..b646ba2 --- /dev/null +++ b/src/model/fabric/FabricMeta.ts @@ -0,0 +1,49 @@ +export interface FabricVersionMeta { + version: string + stable: boolean +} + +export interface FabricLoaderMeta extends FabricVersionMeta { + separator: string + build: number + maven: string +} + +export interface FabricInstallerMeta extends FabricVersionMeta { + url: string + maven: string +} + +export interface Rule { + action: string + os?: { + name: string + version?: string + } + features?: { + [key: string]: boolean + } +} + +export interface RuleBasedArgument { + rules: Rule[] + value: string | string[] +} + +// This is really a mojang format, but it's currently only used here for Fabric. +export interface FabricProfileJson { + id: string + inheritsFrom: string + releaseTime: string + time: string + type: string + mainClass: string + arguments: { + game: (string | RuleBasedArgument)[] + jvm: (string | RuleBasedArgument)[] + } + libraries: { + name: string // Maven identifier + url: string + }[] +} \ No newline at end of file diff --git a/src/model/fabric/FabricModJson.ts b/src/model/fabric/FabricModJson.ts new file mode 100644 index 0000000..2f795df --- /dev/null +++ b/src/model/fabric/FabricModJson.ts @@ -0,0 +1,13 @@ +// https://fabricmc.net/wiki/documentation:fabric_mod_json_spec +// https://github.com/FabricMC/fabric-loader/blob/master/src/main/java/net/fabricmc/loader/impl/metadata/V1ModMetadataParser.java + +type FabricEntryPoint = string | { value: string } + +export interface FabricModJson { + + id: string + version: string + name?: string + entrypoints?: { [key: string]: FabricEntryPoint[] } + +} diff --git a/src/model/nebula/ServerMeta.ts b/src/model/nebula/ServerMeta.ts index 3566288..16afdc1 100644 --- a/src/model/nebula/ServerMeta.ts +++ b/src/model/nebula/ServerMeta.ts @@ -15,6 +15,7 @@ export interface UntrackedFilesOption { export interface ServerMetaOptions { version?: string forgeVersion?: string + fabricVersion?: string } export function getDefaultServerMeta(id: string, version: string, options?: ServerMetaOptions): ServerMeta { @@ -43,6 +44,13 @@ export function getDefaultServerMeta(id: string, version: string, options?: Serv } } + if(options?.fabricVersion) { + servMeta.meta.description = `${servMeta.meta.description} (Fabric v${options.fabricVersion})` + servMeta.fabric = { + version: options.fabricVersion + } + } + // Add empty untracked files. servMeta.untrackedFiles = [] @@ -77,6 +85,17 @@ export interface ServerMeta { version: string } + /** + * Properties related to Fabric. + */ + fabric?: { + /** + * The fabric loader version. This does NOT include the minecraft version. + * Ex. 0.14.18 + */ + version: string + } + /** * A list of option objects defining patterns for untracked files. */ diff --git a/src/parser/CurseForgeParser.ts b/src/parser/CurseForgeParser.ts index 7561c73..9e9cd19 100644 --- a/src/parser/CurseForgeParser.ts +++ b/src/parser/CurseForgeParser.ts @@ -100,9 +100,9 @@ export class CurseForgeParser { await zip.close() } - if(createServerResult.forgeModContainer) { - const requiredPath = resolve(createServerResult.forgeModContainer, ToggleableNamespace.REQUIRED) - const optionalPath = resolve(createServerResult.forgeModContainer, ToggleableNamespace.OPTIONAL_ON) + if(createServerResult.modContainer) { + const requiredPath = resolve(createServerResult.modContainer, ToggleableNamespace.REQUIRED) + const optionalPath = resolve(createServerResult.modContainer, ToggleableNamespace.OPTIONAL_ON) const disallowedFiles: { name: string, fileName: string, url: string }[] = [] diff --git a/src/resolver/BaseResolver.ts b/src/resolver/BaseResolver.ts index e26fe1b..68e7584 100644 --- a/src/resolver/BaseResolver.ts +++ b/src/resolver/BaseResolver.ts @@ -1,7 +1,9 @@ -import { Module } from 'helios-distribution-types' +import { Artifact, Module } from 'helios-distribution-types' import { VersionSegmented } from '../util/VersionSegmented.js' import { Resolver } from './Resolver.js' import { MinecraftVersion } from '../util/MinecraftVersion.js' +import { Stats } from 'fs' +import { createHash } from 'crypto' export abstract class BaseResolver implements Resolver, VersionSegmented { @@ -14,4 +16,12 @@ export abstract class BaseResolver implements Resolver, VersionSegmented { public abstract getModule(): Promise public abstract isForVersion(version: MinecraftVersion, libraryVersion: string): boolean + protected generateArtifact(buf: Buffer, stats: Stats, url: string): Artifact { + return { + size: stats.size, + MD5: createHash('md5').update(buf).digest('hex'), + url + } + } + } diff --git a/src/resolver/fabric/Fabric.resolver.ts b/src/resolver/fabric/Fabric.resolver.ts new file mode 100644 index 0000000..b995a2c --- /dev/null +++ b/src/resolver/fabric/Fabric.resolver.ts @@ -0,0 +1,123 @@ +import { mkdirs, pathExists } from 'fs-extra/esm' +import { lstat, readFile, writeFile } from 'fs/promises' +import { Module, Type } from 'helios-distribution-types' +import { dirname } from 'path' +import { FabricProfileJson } from '../../model/fabric/FabricMeta.js' +import { RepoStructure } from '../../structure/repo/Repo.struct.js' +import { LoggerUtil } from '../../util/LoggerUtil.js' +import { MavenUtil } from '../../util/MavenUtil.js' +import { MinecraftVersion } from '../../util/MinecraftVersion.js' +import { VersionUtil } from '../../util/VersionUtil.js' +import { BaseResolver } from '../BaseResolver.js' + +export class FabricResolver extends BaseResolver { + + private static readonly log = LoggerUtil.getLogger('FabricResolver') + + protected repoStructure: RepoStructure + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public static isForVersion(_version: MinecraftVersion, _libraryVersion: string): boolean { + // --fabric.addMods support was added in https://github.com/FabricMC/fabric-loader/commit/ce8405c22166ef850ae73c09ab513c17d121df5a + return VersionUtil.versionGte(_libraryVersion, '0.12.3') + } + + constructor( + absoluteRoot: string, + relativeRoot: string, + baseUrl: string, + protected loaderVersion: string, + protected minecraftVersion: MinecraftVersion + ) { + super(absoluteRoot, relativeRoot, baseUrl) + this.repoStructure = new RepoStructure(absoluteRoot, relativeRoot, 'fabric') + } + + public async getModule(): Promise { + return this.getFabricModule() + } + + public isForVersion(version: MinecraftVersion, libraryVersion: string): boolean { + return FabricResolver.isForVersion(version, libraryVersion) + } + + public async getFabricModule(): Promise { + + const versionRepo = this.repoStructure.getVersionRepoStruct() + const versionManifest = versionRepo.getVersionManifest(this.minecraftVersion, this.loaderVersion) + + FabricResolver.log.debug(`Checking for fabric profile json at ${versionManifest}..`) + if(!await pathExists(versionManifest)) { + FabricResolver.log.debug('Fabric profile not found locally, initializing download..') + await mkdirs(dirname(versionManifest)) + const manifest = await VersionUtil.getFabricProfileJson(this.minecraftVersion.toString(), this.loaderVersion) + await writeFile(versionManifest, JSON.stringify(manifest)) + } + const profileJsonBuf = await readFile(versionManifest) + const profileJson = JSON.parse(profileJsonBuf.toString()) as FabricProfileJson + + const libRepo = this.repoStructure.getLibRepoStruct() + + const modules: Module[] = [{ + id: versionRepo.getFileName(this.minecraftVersion, this.loaderVersion), + name: 'Fabric (version.json)', + type: Type.VersionManifest, + artifact: this.generateArtifact( + profileJsonBuf, + await lstat(versionManifest), + versionRepo.getVersionManifestURL(this.baseUrl, this.minecraftVersion, this.loaderVersion) + ) + }] + for (const lib of profileJson.libraries) { + FabricResolver.log.debug(`Processing ${lib.name}..`) + + const localPath = libRepo.getArtifactById(lib.name) + + if (!await libRepo.artifactExists(localPath)) { + FabricResolver.log.debug('Not found locally, downloading..') + await libRepo.downloadArtifactById(lib.url, lib.name) + } else { + FabricResolver.log.debug('Using local copy.') + } + + const libBuf = await readFile(localPath) + const stats = await lstat(localPath) + + const mavenComponents = MavenUtil.getMavenComponents(lib.name) + + modules.push({ + id: lib.name, + name: `Fabric (${mavenComponents.artifact})`, + type: Type.Library, + artifact: this.generateArtifact( + libBuf, + stats, + libRepo.getArtifactUrlByComponents( + this.baseUrl, + mavenComponents.group, mavenComponents.artifact, + mavenComponents.version, mavenComponents.classifier + ) + ) + }) + } + + // TODO Rework this + let index = -1 + for(let i=0; i{ return new Promise((resolve, reject) => { const zip = new StreamZip({ diff --git a/src/structure/FileStructure.ts b/src/structure/FileStructure.ts index d3c2237..6ce1d6c 100644 --- a/src/structure/FileStructure.ts +++ b/src/structure/FileStructure.ts @@ -1,5 +1,5 @@ export interface FileStructure { - init(): void + init(): Promise } diff --git a/src/structure/repo/Repo.struct.ts b/src/structure/repo/Repo.struct.ts index 68c4442..69f7aae 100644 --- a/src/structure/repo/Repo.struct.ts +++ b/src/structure/repo/Repo.struct.ts @@ -11,11 +11,12 @@ export class RepoStructure extends BaseFileStructure { constructor( absoluteRoot: string, - relativeRoot: string + relativeRoot: string, + name: string ) { super(absoluteRoot, relativeRoot, 'repo') this.libRepoStruct = new LibRepoStructure(this.containerDirectory, this.relativeRoot) - this.versionRepoStruct = new VersionRepoStructure(this.containerDirectory, this.relativeRoot) + this.versionRepoStruct = new VersionRepoStructure(this.containerDirectory, this.relativeRoot, name) } public getLoggerName(): string { diff --git a/src/structure/repo/VersionRepo.struct.ts b/src/structure/repo/VersionRepo.struct.ts index 71181af..fec3b5e 100644 --- a/src/structure/repo/VersionRepo.struct.ts +++ b/src/structure/repo/VersionRepo.struct.ts @@ -5,28 +5,32 @@ import { MinecraftVersion } from '../../util/MinecraftVersion.js' export class VersionRepoStructure extends BaseFileStructure { + private name: string + constructor( absoluteRoot: string, - relativeRoot: string + relativeRoot: string, + name: string ) { super(absoluteRoot, relativeRoot, 'versions') + this.name = name } public getLoggerName(): string { return 'VersionRepoStructure' } - public getFileName(minecraftVersion: MinecraftVersion, forgeVersion: string): string { - return `${minecraftVersion}-forge-${forgeVersion}` + public getFileName(minecraftVersion: MinecraftVersion, loaderVersion: string): string { + return `${minecraftVersion}-${this.name}-${loaderVersion}` } - public getVersionManifest(minecraftVersion: MinecraftVersion, forgeVersion: string): string { - const fileName = this.getFileName(minecraftVersion, forgeVersion) + public getVersionManifest(minecraftVersion: MinecraftVersion, loaderVersion: string): string { + const fileName = this.getFileName(minecraftVersion, loaderVersion) return join(this.containerDirectory, fileName, `${fileName}.json`) } - public getVersionManifestURL(url: string, minecraftVersion: MinecraftVersion, forgeVersion: string): string { - const fileName = this.getFileName(minecraftVersion, forgeVersion) + public getVersionManifestURL(url: string, minecraftVersion: MinecraftVersion, loaderVersion: string): string { + const fileName = this.getFileName(minecraftVersion, loaderVersion) return new URL(join(this.relativeRoot, fileName, `${fileName}.json`), url).toString() } diff --git a/src/structure/spec_model/Server.struct.ts b/src/structure/spec_model/Server.struct.ts index 6529c77..c5ca866 100644 --- a/src/structure/spec_model/Server.struct.ts +++ b/src/structure/spec_model/Server.struct.ts @@ -6,14 +6,16 @@ import { URL } from 'url' import { VersionSegmentedRegistry } from '../../util/VersionSegmentedRegistry.js' import { ServerMeta, getDefaultServerMeta, ServerMetaOptions, UntrackedFilesOption } from '../../model/nebula/ServerMeta.js' import { BaseModelStructure } from './BaseModel.struct.js' +import { FabricModStructure } from './module/FabricMod.struct.js' import { MiscFileStructure } from './module/File.struct.js' import { LibraryStructure } from './module/Library.struct.js' import { MinecraftVersion } from '../../util/MinecraftVersion.js' import { addSchemaToObject, SchemaTypes } from '../../util/SchemaUtil.js' import { isValidUrl } from '../../util/StringUtils.js' +import { FabricResolver } from '../../resolver/fabric/Fabric.resolver.js' export interface CreateServerResult { - forgeModContainer?: string + modContainer?: string libraryContainer: string miscFileContainer: string } @@ -53,6 +55,7 @@ export class ServerStructure extends BaseModelStructure { options: { version?: string forgeVersion?: string + fabricVersion?: string } ): Promise { const effectiveId = ServerStructure.getEffectiveId(id, minecraftVersion) @@ -69,7 +72,7 @@ export class ServerStructure extends BaseModelStructure { const serverMetaOpts: ServerMetaOptions = { version: options.version } - let forgeModContainer: string | undefined = undefined + let modContainer: string | undefined = undefined if (options.forgeVersion != null) { const fms = VersionSegmentedRegistry.getForgeModStruct( @@ -81,10 +84,23 @@ export class ServerStructure extends BaseModelStructure { [] ) await fms.init() - forgeModContainer = fms.getContainerDirectory() + modContainer = fms.getContainerDirectory() serverMetaOpts.forgeVersion = options.forgeVersion } + if (options.fabricVersion != null) { + const fms = new FabricModStructure( + absoluteServerRoot, + relativeServerRoot, + this.baseUrl, + minecraftVersion, + [] + ) + await fms.init() + modContainer = fms.getContainerDirectory() + serverMetaOpts.fabricVersion = options.fabricVersion + } + const serverMeta: ServerMeta = addSchemaToObject( getDefaultServerMeta(id, minecraftVersion.toString(), serverMetaOpts), SchemaTypes.ServerMetaSchema, @@ -99,7 +115,7 @@ export class ServerStructure extends BaseModelStructure { await mfs.init() return { - forgeModContainer, + modContainer, libraryContainer: libS.getContainerDirectory(), miscFileContainer: mfs.getContainerDirectory() } @@ -184,6 +200,27 @@ export class ServerStructure extends BaseModelStructure { modules.push(...forgeModModules) } + if(serverMeta.fabric) { + const fabricResolver = new FabricResolver(dirname(this.containerDirectory), '', this.baseUrl, serverMeta.fabric.version, minecraftVersion) + if (!fabricResolver.isForVersion(minecraftVersion, serverMeta.fabric.version)) { + throw new Error(`Fabric resolver does not support Fabric ${serverMeta.fabric.version}!`) + } + + const fabricModule = await fabricResolver.getModule() + modules.push(fabricModule) + + const fabricModStruct = new FabricModStructure( + absoluteServerRoot, + relativeServerRoot, + this.baseUrl, + minecraftVersion, + untrackedFiles + ) + + const fabricModModules = await fabricModStruct.getSpecModel() + modules.push(...fabricModModules) + } + const libraryStruct = new LibraryStructure(absoluteServerRoot, relativeServerRoot, this.baseUrl, minecraftVersion, untrackedFiles) const libraryModules = await libraryStruct.getSpecModel() modules.push(...libraryModules) diff --git a/src/structure/spec_model/module/FabricMod.struct.ts b/src/structure/spec_model/module/FabricMod.struct.ts new file mode 100644 index 0000000..015e0f2 --- /dev/null +++ b/src/structure/spec_model/module/FabricMod.struct.ts @@ -0,0 +1,91 @@ +import StreamZip from 'node-stream-zip' +import { Type } from 'helios-distribution-types' +import { capitalize } from '../../../util/StringUtils.js' +import { FabricModJson } from '../../../model/fabric/FabricModJson.js' +import { MinecraftVersion } from '../../../util/MinecraftVersion.js' +import { BaseModStructure } from './Mod.struct.js' +import { UntrackedFilesOption } from '../../../model/nebula/ServerMeta.js' + +export class FabricModStructure extends BaseModStructure { + + constructor( + absoluteRoot: string, + relativeRoot: string, + baseUrl: string, + minecraftVersion: MinecraftVersion, + untrackedFiles: UntrackedFilesOption[] + ) { + super(absoluteRoot, relativeRoot, 'fabricmods', baseUrl, minecraftVersion, Type.FabricMod, untrackedFiles) + } + + public getLoggerName(): string { + return 'FabricModStructure' + } + + protected async getModuleId(name: string, path: string): Promise { + const fmData = await this.getModMetadata(name, path) + let group + if (fmData.entrypoints != null) { + for (const t of ['main', 'client', 'server']) { + if (fmData.entrypoints[t] != null && fmData.entrypoints[t].length > 0) { + const entrypoint = fmData.entrypoints[t][0] + group = typeof entrypoint === 'string' ? entrypoint : entrypoint.value + break + } + } + // adapted from https://github.com/dscalzi/Claritas/blob/master/src/main/java/com/dscalzi/claritas/util/DataUtil.java + if (group != null) { + const packageBits = group.split('.') + const blacklist = ['common', 'util', 'internal', 'tweaker', 'tweak', 'client', ...['forge', 'fabric', 'bukkit', 'sponge'].filter(t => t !== fmData.id)] + // Note: Entry point is a fully qualified class name, hence why this adaptation pops immediately (drop class name). + while (packageBits.length > 0) { + packageBits.pop() + const term = packageBits[packageBits.length - 1] + if ((term !== fmData.id && !blacklist.includes(term)) || packageBits.length === 1 || (packageBits.length === 2 && term === fmData.id)) { + break + } + } + group = packageBits.join('.') + } + } + return this.generateMavenIdentifier(group || this.getDefaultGroup(), fmData.id, fmData.version) + } + protected async getModuleName(name: string, path: string): Promise { + const fmData = await this.getModMetadata(name, path) + return capitalize(fmData.name || fmData.id) + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected processZip(zip: StreamZip, name: string, path: string): FabricModJson { + + let raw: Buffer | undefined + try { + raw = zip.entryDataSync('fabric.mod.json') + } catch(err) { + // ignored + } + + if (raw) { + try { + const parsed = JSON.parse(raw.toString()) as FabricModJson + this.modMetadata[name] = parsed + } catch (err) { + this.logger.error(`FabricMod ${name} contains an invalid fabric.mod.json file.`) + } + } else { + this.logger.error(`FabricMod ${name} does not contain fabric.mod.json file.`) + } + + const crudeInference = this.attemptCrudeInference(name) + + if(this.modMetadata[name] == null) { + this.modMetadata[name] = ({ + id: crudeInference.name.toLowerCase(), + name: crudeInference.name, + version: crudeInference.version + }) + } + + return this.modMetadata[name]! + } +} diff --git a/src/structure/spec_model/module/ForgeMod.struct.ts b/src/structure/spec_model/module/ForgeMod.struct.ts index 9887410..3c5a117 100644 --- a/src/structure/spec_model/module/ForgeMod.struct.ts +++ b/src/structure/spec_model/module/ForgeMod.struct.ts @@ -1,15 +1,12 @@ -import { Stats } from 'fs' -import { Type, Module } from 'helios-distribution-types' -import { join } from 'path' -import { URL } from 'url' +import { Type } from 'helios-distribution-types' import { VersionSegmented } from '../../../util/VersionSegmented.js' import { MinecraftVersion } from '../../../util/MinecraftVersion.js' -import { ToggleableModuleStructure } from './ToggleableModule.struct.js' +import { BaseModStructure } from './Mod.struct.js' import { LibraryType } from '../../../model/claritas/ClaritasLibraryType.js' import { ClaritasException } from './Module.struct.js' import { UntrackedFilesOption } from '../../../model/nebula/ServerMeta.js' -export abstract class BaseForgeModStructure extends ToggleableModuleStructure implements VersionSegmented { +export abstract class BaseForgeModStructure extends BaseModStructure implements VersionSegmented { protected readonly EXAMPLE_MOD_ID = 'examplemod' @@ -23,26 +20,8 @@ export abstract class BaseForgeModStructure extends ToggleableModuleStructure im super(absoluteRoot, relativeRoot, 'forgemods', baseUrl, minecraftVersion, Type.ForgeMod, untrackedFiles) } - public async getSpecModel(): Promise { - // Sort by file name to allow control of load order. - return (await super.getSpecModel()).sort((a, b) => { - const aFileName = a.artifact.url.substring(a.artifact.url.lastIndexOf('/')+1) - const bFileName = b.artifact.url.substring(b.artifact.url.lastIndexOf('/')+1) - return aFileName.localeCompare(bFileName) - }) - } - public abstract isForVersion(version: MinecraftVersion, libraryVersion: string): boolean - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected async getModuleUrl(name: string, path: string, stats: Stats): Promise { - return new URL(join(this.relativeRoot, this.getActiveNamespace(), name), this.baseUrl).toString() - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected async getModulePath(name: string, path: string, stats: Stats): Promise { - return null - } - protected getClaritasExceptions(): ClaritasException[] { return [{ exceptionName: 'optifine', diff --git a/src/structure/spec_model/module/Mod.struct.ts b/src/structure/spec_model/module/Mod.struct.ts new file mode 100644 index 0000000..20b214b --- /dev/null +++ b/src/structure/spec_model/module/Mod.struct.ts @@ -0,0 +1,66 @@ +import { Stats } from 'fs' +import { Module } from 'helios-distribution-types' +import StreamZip from 'node-stream-zip' +import { join } from 'path' +import { URL } from 'url' +import { ToggleableModuleStructure } from './ToggleableModule.struct.js' + +export abstract class BaseModStructure extends ToggleableModuleStructure { + + protected modMetadata: {[property: string]: T | undefined} = {} + + public async getSpecModel(): Promise { + // Sort by file name to allow control of load order. + return (await super.getSpecModel()).sort((a, b) => { + const aFileName = a.artifact.url.substring(a.artifact.url.lastIndexOf('/')+1) + const bFileName = b.artifact.url.substring(b.artifact.url.lastIndexOf('/')+1) + return aFileName.localeCompare(bFileName) + }) + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async getModuleUrl(name: string, path: string, stats: Stats): Promise { + return new URL(join(this.relativeRoot, this.getActiveNamespace(), name), this.baseUrl).toString() + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async getModulePath(name: string, path: string, stats: Stats): Promise { + return null + } + + protected getModMetadata(name: string, path: string): Promise { + return new Promise((resolve, reject) => { + if (!Object.prototype.hasOwnProperty.call(this.modMetadata, name)) { + + const zip = new StreamZip({ + file: path, + storeEntries: true + }) + + zip.on('error', err => { + this.logger.error(`Failure while processing ${path}`) + reject(err) + }) + zip.on('ready', () => { + try { + const res = this.processZip(zip, name, path) + zip.close() + resolve(res) + return + } catch(err) { + zip.close() + reject(err) + return + } + }) + + } else { + resolve(this.modMetadata[name]!) + return + } + + }) + } + + protected abstract processZip(zip: StreamZip, name: string, path: string): T + +} \ No newline at end of file diff --git a/src/structure/spec_model/module/forgemod/ForgeMod113.struct.ts b/src/structure/spec_model/module/forgemod/ForgeMod113.struct.ts index ba2a75f..4049843 100644 --- a/src/structure/spec_model/module/forgemod/ForgeMod113.struct.ts +++ b/src/structure/spec_model/module/forgemod/ForgeMod113.struct.ts @@ -7,7 +7,7 @@ import { BaseForgeModStructure } from '../ForgeMod.struct.js' import { MinecraftVersion } from '../../../../util/MinecraftVersion.js' import { UntrackedFilesOption } from '../../../../model/nebula/ServerMeta.js' -export class ForgeModStructure113 extends BaseForgeModStructure { +export class ForgeModStructure113 extends BaseForgeModStructure { public static readonly IMPLEMENTATION_VERSION_REGEX = /^Implementation-Version: (.+)[\r\n]/ @@ -16,8 +16,6 @@ export class ForgeModStructure113 extends BaseForgeModStructure { return VersionUtil.isVersionAcceptable(version, [13, 14, 15, 16, 17, 18, 19, 20]) } - private forgeModMetadata: {[property: string]: ModsToml | undefined} = {} - constructor( absoluteRoot: string, relativeRoot: string, @@ -37,48 +35,14 @@ export class ForgeModStructure113 extends BaseForgeModStructure { } protected async getModuleId(name: string, path: string): Promise { - const fmData = await this.getForgeModMetadata(name, path) + const fmData = await this.getModMetadata(name, path) return this.generateMavenIdentifier(this.getClaritasGroup(path), fmData.mods[0].modId, fmData.mods[0].version) } protected async getModuleName(name: string, path: string): Promise { - return capitalize((await this.getForgeModMetadata(name, path)).mods[0].displayName) + return capitalize((await this.getModMetadata(name, path)).mods[0].displayName) } - private getForgeModMetadata(name: string, path: string): Promise { - return new Promise((resolve, reject) => { - if (!Object.prototype.hasOwnProperty.call(this.forgeModMetadata, name)) { - - const zip = new StreamZip({ - file: path, - storeEntries: true - }) - - zip.on('error', err => { - this.logger.error(`Failure while processing ${path}`) - reject(err) - }) - zip.on('ready', () => { - try { - const res = this.processZip(zip, name, path) - zip.close() - resolve(res) - return - } catch(err) { - zip.close() - reject(err) - return - } - }) - - } else { - resolve(this.forgeModMetadata[name]!) - return - } - - }) - } - - private processZip(zip: StreamZip, name: string, path: string): ModsToml { + protected processZip(zip: StreamZip, name: string, path: string): ModsToml { // Optifine is a tweak that can be loaded as a forge mod. It does not // appear to contain a mcmod.info class. This a special case we will @@ -96,7 +60,7 @@ export class ForgeModStructure113 extends BaseForgeModStructure { const info = changelogBuf.toString().split('\n')[0].trim() const version = info.split(' ')[1] - this.forgeModMetadata[name] = ({ + this.modMetadata[name] = ({ modLoader: 'javafml', loaderVersion: '', mods: [{ @@ -108,7 +72,7 @@ export class ForgeModStructure113 extends BaseForgeModStructure { }] }) - return this.forgeModMetadata[name]! + return this.modMetadata[name]! } let raw: Buffer | undefined @@ -121,7 +85,7 @@ export class ForgeModStructure113 extends BaseForgeModStructure { if (raw) { try { const parsed = toml.parse(raw.toString()) as ModsToml - this.forgeModMetadata[name] = parsed + this.modMetadata[name] = parsed } catch (err) { this.logger.error(`ForgeMod ${name} contains an invalid mods.toml file.`) } @@ -133,16 +97,16 @@ export class ForgeModStructure113 extends BaseForgeModStructure { if(cRes == null) { this.logger.error(`Claritas failed to yield metadata for ForgeMod ${name}!`) - this.logger.error('Is this mod malformated or does Claritas need an update?') + this.logger.error('Is this mod malformatted or does Claritas need an update?') } const claritasId = cRes?.id const crudeInference = this.attemptCrudeInference(name) - if(this.forgeModMetadata[name] != null) { + if(this.modMetadata[name] != null) { - const x = this.forgeModMetadata[name]! + const x = this.modMetadata[name]! for(const entry of x.mods) { if(entry.modId === this.EXAMPLE_MOD_ID) { @@ -171,7 +135,7 @@ export class ForgeModStructure113 extends BaseForgeModStructure { } } else { - this.forgeModMetadata[name] = ({ + this.modMetadata[name] = ({ modLoader: 'javafml', loaderVersion: '', mods: [{ @@ -183,7 +147,7 @@ export class ForgeModStructure113 extends BaseForgeModStructure { }) } - return this.forgeModMetadata[name]! + return this.modMetadata[name]! } } diff --git a/src/structure/spec_model/module/forgemod/ForgeMod17.struct.ts b/src/structure/spec_model/module/forgemod/ForgeMod17.struct.ts index 9c1dc42..834af05 100644 --- a/src/structure/spec_model/module/forgemod/ForgeMod17.struct.ts +++ b/src/structure/spec_model/module/forgemod/ForgeMod17.struct.ts @@ -8,15 +8,13 @@ import { MinecraftVersion } from '../../../../util/MinecraftVersion.js' import { ForgeModType_1_7 } from '../../../../model/claritas/ClaritasResult.js' import { UntrackedFilesOption } from '../../../../model/nebula/ServerMeta.js' -export class ForgeModStructure17 extends BaseForgeModStructure { +export class ForgeModStructure17 extends BaseForgeModStructure { // eslint-disable-next-line @typescript-eslint/no-unused-vars public static isForVersion(version: MinecraftVersion, libraryVersion: string): boolean { return VersionUtil.isVersionAcceptable(version, [7, 8, 9, 10, 11, 12]) } - private forgeModMetadata: {[property: string]: McModInfo | undefined} = {} - constructor( absoluteRoot: string, relativeRoot: string, @@ -36,45 +34,11 @@ export class ForgeModStructure17 extends BaseForgeModStructure { } protected async getModuleId(name: string, path: string): Promise { - const fmData = await this.getForgeModMetadata(name, path) + const fmData = await this.getModMetadata(name, path) return this.generateMavenIdentifier(this.getClaritasGroup(path), fmData.modid, fmData.version) } protected async getModuleName(name: string, path: string): Promise { - return capitalize((await this.getForgeModMetadata(name, path)).name) - } - - private getForgeModMetadata(name: string, path: string): Promise { - return new Promise((resolve, reject) => { - if (!Object.prototype.hasOwnProperty.call(this.forgeModMetadata, name)) { - - const zip = new StreamZip({ - file: path, - storeEntries: true - }) - - zip.on('error', err => { - this.logger.error(`Failure while processing ${path}`) - reject(err) - }) - zip.on('ready', () => { - try { - const res = this.processZip(zip, name, path) - zip.close() - resolve(res) - return - } catch(err) { - zip.close() - reject(err) - return - } - }) - - } else { - resolve(this.forgeModMetadata[name]!) - return - } - - }) + return capitalize((await this.getModMetadata(name, path)).name) } private isMalformedVersion(version: string): boolean { @@ -82,7 +46,7 @@ export class ForgeModStructure17 extends BaseForgeModStructure { return version.trim().length === 0 || version.indexOf('@') > -1 || version.indexOf('$') > -1 } - private processZip(zip: StreamZip, name: string, path: string): McModInfo { + protected processZip(zip: StreamZip, name: string, path: string): McModInfo { // Optifine is a tweak that can be loaded as a forge mod. It does not // appear to contain a mcmod.info class. This a special case we will // account for. @@ -98,13 +62,13 @@ export class ForgeModStructure17 extends BaseForgeModStructure { const info = changelogBuf.toString().split('\n')[0].trim() const version = info.split(' ')[1] - this.forgeModMetadata[name] = ({ + this.modMetadata[name] = ({ modid: 'optifine', name: info, version, mcversion: version.substring(0, version.indexOf('_')) }) as McModInfo - return this.forgeModMetadata[name]! + return this.modMetadata[name]! } let raw: Buffer | undefined @@ -120,9 +84,9 @@ export class ForgeModStructure17 extends BaseForgeModStructure { const resolved = JSON.parse(raw.toString()) as (McModInfoList | McModInfo[]) if (Object.prototype.hasOwnProperty.call(resolved, 'modListVersion')) { - this.forgeModMetadata[name] = (resolved as McModInfoList).modList[0] + this.modMetadata[name] = (resolved as McModInfoList).modList[0] } else { - this.forgeModMetadata[name] = (resolved as McModInfo[])[0] + this.modMetadata[name] = (resolved as McModInfo[])[0] } } catch (err) { @@ -158,16 +122,16 @@ export class ForgeModStructure17 extends BaseForgeModStructure { // Validate const crudeInference = this.attemptCrudeInference(name) - if(this.forgeModMetadata[name] != null) { + if(this.modMetadata[name] != null) { - const x = this.forgeModMetadata[name]! + const x = this.modMetadata[name]! if(x.modid == null || x.modid === '' || x.modid === this.EXAMPLE_MOD_ID) { x.modid = this.discernResult(claritasId, crudeInference.name.toLowerCase()) x.name = this.discernResult(claritasName, crudeInference.name) } - if(this.forgeModMetadata[name]!.version != null) { - const isMalformedVersion = this.isMalformedVersion(this.forgeModMetadata[name]!.version) + if(this.modMetadata[name]!.version != null) { + const isMalformedVersion = this.isMalformedVersion(this.modMetadata[name]!.version) if(isMalformedVersion) { x.version = this.discernResult(claritasVersion, crudeInference.version) } @@ -177,14 +141,14 @@ export class ForgeModStructure17 extends BaseForgeModStructure { } else { - this.forgeModMetadata[name] = ({ + this.modMetadata[name] = ({ modid: this.discernResult(claritasId, crudeInference.name.toLowerCase()), name: this.discernResult(claritasName, crudeInference.name), version: this.discernResult(claritasVersion, crudeInference.version) }) as McModInfo } - return this.forgeModMetadata[name]! + return this.modMetadata[name]! } } diff --git a/src/util/VersionSegmentedRegistry.ts b/src/util/VersionSegmentedRegistry.ts index d045c3d..3415306 100644 --- a/src/util/VersionSegmentedRegistry.ts +++ b/src/util/VersionSegmentedRegistry.ts @@ -43,7 +43,7 @@ export class VersionSegmentedRegistry { relativeRoot: string, baseUrl: string, untrackedFiles: UntrackedFilesOption[] - ): BaseForgeModStructure { + ): BaseForgeModStructure { for (const impl of VersionSegmentedRegistry.FORGEMOD_STRUCT_IMPL) { if (impl.isForVersion(minecraftVersion, forgeVersion)) { return new impl(absoluteRoot, relativeRoot, baseUrl, minecraftVersion, untrackedFiles) diff --git a/src/util/VersionUtil.ts b/src/util/VersionUtil.ts index f0f4d72..ad686af 100644 --- a/src/util/VersionUtil.ts +++ b/src/util/VersionUtil.ts @@ -2,6 +2,7 @@ import got from 'got' import { PromotionsSlim } from '../model/forge/PromotionsSlim.js' import { MinecraftVersion } from './MinecraftVersion.js' import { LoggerUtil } from './LoggerUtil.js' +import { FabricInstallerMeta, FabricLoaderMeta, FabricProfileJson, FabricVersionMeta } from '../model/fabric/FabricMeta.js' export class VersionUtil { @@ -21,6 +22,35 @@ export class VersionUtil { return false } + public static versionGte(version: string, min: string): boolean { + + if(version === min) { + return true + } + + const left = version.split('.').map(x => Number(x)) + const right = min.split('.').map(x => Number(x)) + + if(left.length != right.length) { + throw new Error('Cannot compare mismatched versions.') + } + + for(let i=0; i right[i]) { + return true + } + } + + return false + } + + public static isPromotionVersion(version: string): boolean { + return VersionUtil.PROMOTION_TYPE.indexOf(version.toLowerCase()) > -1 + } + + // ------------------------------- + // Forge + public static isOneDotTwelveFG2(libraryVersion: string): boolean { const maxFG2 = [14, 23, 5, 2847] const verSplit = libraryVersion.split('.').map(v => Number(v)) @@ -34,10 +64,6 @@ export class VersionUtil { return true } - public static isPromotionVersion(version: string): boolean { - return VersionUtil.PROMOTION_TYPE.indexOf(version.toLowerCase()) > -1 - } - public static async getPromotionIndex(): Promise { const response = await got.get({ method: 'get', @@ -67,26 +93,49 @@ export class VersionUtil { return version } - public static versionGte(version: string, min: string): boolean { + // ------------------------------- + // Fabric - if(version === min) { - return true - } + public static async getFabricInstallerMeta(): Promise { + const response = await got.get({ + method: 'get', + url: 'https://meta.fabricmc.net/v2/versions/installer', + responseType: 'json' + }) + return response.body + } - const left = version.split('.').map(x => Number(x)) - const right = min.split('.').map(x => Number(x)) + public static async getFabricLoaderMeta(): Promise { + const response = await got.get({ + method: 'get', + url: 'https://meta.fabricmc.net/v2/versions/loader', + responseType: 'json' + }) + return response.body + } - if(left.length != right.length) { - throw new Error('Cannot compare mismatched versions.') - } + public static async getFabricGameMeta(): Promise { + const response = await got.get({ + method: 'get', + url: 'https://meta.fabricmc.net/v2/versions/game', + responseType: 'json' + }) + return response.body + } - for(let i=0; i right[i]) { - return true - } - } + public static async getFabricProfileJson(gameVersion: string, loaderVersion: string): Promise { + const response = await got.get({ + method: 'get', + url: `https://meta.fabricmc.net/v2/versions/loader/${gameVersion}/${loaderVersion}/profile/json`, + responseType: 'json' + }) + return response.body + } - return false + public static async getPromotedFabricVersion(promotion: string): Promise { + const stable = promotion.toLowerCase() === 'recommended' + const fabricLoaderMeta = await this.getFabricLoaderMeta() + return !stable ? fabricLoaderMeta[0].version : fabricLoaderMeta.find(({ stable }) => stable)!.version } }