SurviePote 2/4: atlas Nebula (serveur SurviePote-26.3, distribution.json, sans binaires)

This commit is contained in:
kisakay
2026-10-18 18:44:09 +02:00
parent 14355ac2ad
commit ccaee11472
69 changed files with 8289 additions and 0 deletions
+453
View File
@@ -0,0 +1,453 @@
import dotenv from 'dotenv'
import { writeFile } from 'fs/promises'
import { resolve as resolvePath } from 'path'
import { URL } from 'url'
import { inspect } from 'util'
import yargs from 'yargs/yargs'
import { Argv, CommandModule } from 'yargs'
import { hideBin } from 'yargs/helpers'
import { DistributionStructure } from './structure/spec_model/Distribution.struct.js'
import { ServerStructure } from './structure/spec_model/Server.struct.js'
import { VersionSegmentedRegistry } from './util/VersionSegmentedRegistry.js'
import { VersionUtil } from './util/VersionUtil.js'
import { MinecraftVersion } from './util/MinecraftVersion.js'
import { LoggerUtil } from './util/LoggerUtil.js'
import { generateSchemas } from './util/SchemaUtil.js'
import { CurseForgeParser } from './parser/CurseForgeParser.js'
dotenv.config()
const logger = LoggerUtil.getLogger('Index')
function getRoot(): string {
return resolvePath(process.env.ROOT!)
}
function getHeliosDataFolder(): string | null {
if(process.env.HELIOS_DATA_FOLDER) {
return resolvePath(process.env.HELIOS_DATA_FOLDER)
}
return null
}
function getBaseURL(): string {
let baseUrl = process.env.BASE_URL!
// Users must provide protocol in all other instances.
if (!baseUrl.includes('//')) {
if (baseUrl.toLowerCase().startsWith('localhost')) {
baseUrl = 'http://' + baseUrl
} else {
throw new TypeError('Please provide a URL protocol (ex. http:// or https://)')
}
}
return (new URL(baseUrl)).toString()
}
function installLocalOption(yargs: Argv): Argv {
return yargs.option('installLocal', {
describe: 'Install the generated distribution to your local Helios data folder.',
type: 'boolean',
demandOption: false,
global: false,
default: false
})
}
function discardOutputOption(yargs: Argv): Argv {
return yargs.option('discardOutput', {
describe: 'Delete cached output after it is no longer required. May be useful if disk space is limited.',
type: 'boolean',
demandOption: false,
global: false,
default: false
})
}
function invalidateCacheOption(yargs: Argv): Argv {
return yargs.option('invalidateCache', {
describe: 'Invalidate and delete existing caches as they are encountered. Requires fresh cache generation.',
type: 'boolean',
demandOption: false,
global: false,
default: false
})
}
// function rootOption(yargs: Argv) {
// return yargs.option('root', {
// describe: 'File structure root.',
// type: 'string',
// demandOption: true,
// global: true
// })
// .coerce({
// root: resolvePath
// })
// }
// function baseUrlOption(yargs: Argv) {
// return yargs.option('baseUrl', {
// describe: 'Base url of your file host.',
// type: 'string',
// demandOption: true,
// global: true
// })
// .coerce({
// baseUrl: (arg: string) => {
// // Users must provide protocol in all other instances.
// if (arg.indexOf('//') === -1) {
// if (arg.toLowerCase().startsWith('localhost')) {
// arg = 'http://' + arg
// } else {
// throw new TypeError('Please provide a URL protocol (ex. http:// or https://)')
// }
// }
// return (new URL(arg)).toString()
// }
// })
// }
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function namePositional(yargs: Argv) {
return yargs.option('name', {
describe: 'Distribution index file name.',
type: 'string',
default: 'distribution'
})
}
// -------------
// Init Commands
const initRootCommand: CommandModule = {
command: 'root',
describe: 'Generate an empty standard file structure.',
builder: (yargs) => {
// yargs = rootOption(yargs)
return yargs
},
handler: async (argv) => {
argv.root = getRoot()
logger.debug(`Root set to ${argv.root}`)
logger.debug('Invoked init root.')
try {
await generateSchemas(argv.root as string)
await new DistributionStructure(argv.root as string, '', false, false).init()
await new CurseForgeParser(argv.root as string, '').init()
logger.info(`Successfully created new root at ${argv.root}`)
} catch (error) {
logger.error(`Failed to init new root at ${argv.root}`, error)
}
}
}
const initCommand: CommandModule = {
command: 'init',
aliases: ['i'],
describe: 'Base init command.',
builder: (yargs) => {
return yargs
.command(initRootCommand)
},
handler: (argv) => {
argv._handled = true
}
}
// -----------------
// Generate Commands
const generateServerCommand: CommandModule = {
command: 'server <id> <version>',
describe: 'Generate a new server configuration.',
builder: (yargs) => {
// yargs = rootOption(yargs)
return yargs
.positional('id', {
describe: 'Server id.',
type: 'string'
})
.positional('version', {
describe: 'Minecraft version.',
type: 'string'
})
.option('forge', {
describe: 'Forge version.',
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└ Fabric version: ${argv.fabric}`
)
const minecraftVersion = new MinecraftVersion(argv.version as string)
if(argv.forge != null) {
if (VersionUtil.isPromotionVersion(argv.forge as string)) {
logger.debug(`Resolving ${argv.forge as string} Forge Version..`)
const version = await VersionUtil.getPromotedForgeVersion(minecraftVersion, argv.forge as string)
logger.debug(`Forge version set to ${version}`)
argv.forge = version
}
if(minecraftVersion.isGreaterThanOrEqualTo(new MinecraftVersion('1.20.3'))) {
logger.error('┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓')
logger.error('┃ !! WARNING !! ┃')
logger.error('┃ ┃')
logger.error('┃ Forge 1.20.3+ removed support for --fml.modLists. ┃')
logger.error('┃ Helios Launcher can no longer load mods through Forge. ┃')
logger.error('┃ Please use Fabric or await NeoForged Support. ┃')
logger.error('┃ ┃')
logger.error('┃ !! WARNING !! ┃')
logger.error('┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛')
}
}
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,
fabricVersion: argv.fabric as string
}
)
}
}
const generateServerCurseForgeCommand: CommandModule = {
command: 'server-curseforge <id> <zipName>',
describe: 'Generate a new server configuration from a CurseForge modpack.',
builder: (yargs) => {
// yargs = rootOption(yargs)
return yargs
.positional('id', {
describe: 'Server id.',
type: 'string'
})
.positional('zipName', {
describe: 'The name of the modpack zip file.',
type: 'string'
})
},
handler: async (argv) => {
argv.root = getRoot()
logger.debug(`Root set to ${argv.root}`)
logger.debug(`Generating server ${argv.id} using CurseForge modpack ${argv.zipName} as a template.`)
const parser = new CurseForgeParser(argv.root as string, argv.zipName as string)
const modpackManifest = await parser.getModpackManifest()
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}`)
const serverStruct = new ServerStructure(argv.root as string, getBaseURL(), false, false)
const createServerResult = await serverStruct.createServer(
argv.id as string,
minecraftVersion,
{
version: modpackManifest.version,
forgeVersion
}
)
if(createServerResult) {
await parser.enrichServer(createServerResult, modpackManifest)
}
}
}
const generateDistroCommand: CommandModule = {
command: 'distro [name]',
describe: 'Generate a distribution index from the root file structure.',
builder: (yargs) => {
yargs = installLocalOption(yargs)
yargs = discardOutputOption(yargs)
yargs = invalidateCacheOption(yargs)
yargs = namePositional(yargs)
return yargs
},
handler: async (argv) => {
argv.root = getRoot()
argv.baseUrl = getBaseURL()
const finalName = `${argv.name}.json`
logger.debug(`Root set to ${argv.root}`)
logger.debug(`Base Url set to ${argv.baseUrl}`)
logger.debug(`Install option set to ${argv.installLocal}`)
logger.debug(`Discard Output option set to ${argv.discardOutput}`)
logger.debug(`Invalidate Cache option set to ${argv.invalidateCache}`)
logger.debug(`Invoked generate distro name ${finalName}.`)
const doLocalInstall = argv.installLocal as boolean
const discardOutput = argv.discardOutput as boolean ?? false
const invalidateCache = argv.invalidateCache as boolean ?? false
const heliosDataFolder = getHeliosDataFolder()
if(doLocalInstall && heliosDataFolder == null) {
logger.error('You MUST specify HELIOS_DATA_FOLDER in your .env when using the --installLocal option.')
return
}
try {
const distributionStruct = new DistributionStructure(argv.root as string, argv.baseUrl as string, discardOutput, invalidateCache)
const distro = await distributionStruct.getSpecModel()
const distroOut = JSON.stringify(distro, null, 2)
const distroPath = resolvePath(argv.root as string, finalName)
await writeFile(distroPath, distroOut)
logger.info(`Successfully generated ${finalName}`)
logger.info(`Saved to ${distroPath}`)
logger.debug('Preview:\n', distro)
if(doLocalInstall) {
const finalDestination = resolvePath(heliosDataFolder!, finalName)
logger.info(`Installing distribution to ${finalDestination}`)
await writeFile(finalDestination, distroOut)
logger.info('Success!')
}
} catch (error) {
logger.error(`Failed to generate distribution with root ${argv.root}.`, error)
}
}
}
const generateSchemasCommand: CommandModule = {
command: 'schemas',
describe: 'Generate json schemas.',
handler: async (argv) => {
argv.root = getRoot()
logger.debug(`Root set to ${argv.root}`)
logger.debug('Invoked generate schemas.')
try {
await generateSchemas(argv.root as string)
logger.info('Successfully generated schemas')
} catch (error) {
logger.error(`Failed to generate schemas with root ${argv.root}.`, error)
}
}
}
const generateCommand: CommandModule = {
command: 'generate',
aliases: ['g'],
describe: 'Base generate command.',
builder: (yargs) => {
return yargs
.command(generateServerCurseForgeCommand)
.command(generateServerCommand)
.command(generateDistroCommand)
.command(generateSchemasCommand)
},
handler: (argv) => {
argv._handled = true
}
}
const validateCommand: CommandModule = {
command: 'validate [name]',
describe: 'Validate a distribution.json against the spec.',
builder: (yargs) => {
return namePositional(yargs)
},
handler: (argv) => {
logger.debug(`Invoked validate with name ${argv.name}.json`)
}
}
const latestForgeCommand: CommandModule = {
command: 'latest-forge <version>',
describe: 'Get the latest version of forge.',
handler: async (argv) => {
logger.debug(`Invoked latest-forge with version ${argv.version}.`)
const minecraftVersion = new MinecraftVersion(argv.version as string)
const forgeVer = await VersionUtil.getPromotedForgeVersion(minecraftVersion, 'latest')
logger.info(`Latest version: Forge ${forgeVer} (${argv.version})`)
}
}
const recommendedForgeCommand: CommandModule = {
command: 'recommended-forge <version>',
describe: 'Get the recommended version of forge. Returns latest if there is no recommended build.',
handler: async (argv) => {
logger.debug(`Invoked recommended-forge with version ${argv.version}.`)
const index = await VersionUtil.getPromotionIndex()
const minecraftVersion = new MinecraftVersion(argv.version as string)
let forgeVer = VersionUtil.getPromotedVersionStrict(index, minecraftVersion, 'recommended')
if (forgeVer != null) {
logger.info(`Recommended version: Forge ${forgeVer} (${minecraftVersion})`)
} else {
logger.info(`No recommended build for ${minecraftVersion}. Checking for latest version..`)
forgeVer = VersionUtil.getPromotedVersionStrict(index, minecraftVersion, 'latest')
if (forgeVer != null) {
logger.info(`Latest version: Forge ${forgeVer} (${minecraftVersion})`)
} else {
logger.info(`No build available for ${minecraftVersion}.`)
}
}
}
}
const testCommand: CommandModule = {
command: 'test <mcVer> <forgeVer>',
describe: 'Validate a distribution.json against the spec.',
builder: (yargs) => {
return namePositional(yargs)
},
handler: async (argv) => {
logger.debug(`Invoked test with mcVer ${argv.mcVer} forgeVer ${argv.forgeVer}`)
logger.info(process.cwd())
const mcVer = new MinecraftVersion(argv.mcVer as string)
const resolver = VersionSegmentedRegistry.getForgeResolver(mcVer,
argv.forgeVer as string, getRoot(), '', getBaseURL(), false, false)
if (resolver != null) {
const mdl = await resolver.getModule()
logger.info(inspect(mdl, false, null, true))
}
}
}
// Registering yargs configuration.
await yargs(hideBin(process.argv))
.version(false)
.scriptName('')
.command(initCommand)
.command(generateCommand)
.command(validateCommand)
.command(latestForgeCommand)
.command(recommendedForgeCommand)
.command(testCommand)
.demandCommand()
.help()
.argv
@@ -0,0 +1,5 @@
export enum LibraryType {
FORGE = 'FORGE'
}
@@ -0,0 +1,44 @@
export interface ClaritasModuleMetadata {
/**
* Present on ForgeMods (1.7-1.12) of type MOD.
* Possibly present on ForgeMods (1.7-1.12) of type CORE_MOD and TWEAKER.
* Always present on ForgeMods 1.13+.
* Never present in all other circumstances.
*/
id?: string
/**
* Never present on ForgeMods (1.7-1.12) of type UNKNOWN.
* Present in all other circumstances.
*/
group?: string
/**
* Possibly present on ForgeMods (1.7-1.12).
* Never present in all other circumstances.
*/
version?: string
/**
* Possibly present on ForgeMods (1.7-1.12).
* Never present in all other circumstances.
*/
name?: string
/**
* Always present on ForgeMods (1.7-1.12).
* Never present in all other circumstances.
*/
modType?: ForgeModType_1_7
}
export enum ForgeModType_1_7 {
MOD = 'MOD',
CORE_MOD = 'CORE_MOD',
TWEAKER = 'TWEAKER',
UNKNOWN = 'UNKNOWN'
}
export interface ClaritasResult {
[jarPath: string]: ClaritasModuleMetadata | undefined
}
+49
View File
@@ -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
}[]
}
+13
View File
@@ -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[] }
}
+22
View File
@@ -0,0 +1,22 @@
// https://mcforge.readthedocs.io/en/latest/gettingstarted/structuring/#the-mcmodinfo-file
export interface McModInfo {
modid: string
name: string
description: string
version: string
mcversion: string
url: string
updateUrl?: string
updateJSON: string
authorList: string[]
credits: string
logoFile: string
screenshots: string[]
parent: string
useDependencyInformation: boolean
requiredMods: string[]
dependencies: string[]
dependants: string[] // Spelled as dependants on forge's wiki.
}
+7
View File
@@ -0,0 +1,7 @@
import { McModInfo } from './McModInfo.js'
export interface McModInfoList {
modListVersion: number
modList: McModInfo[]
}
+29
View File
@@ -0,0 +1,29 @@
// https://github.com/MinecraftForge/MinecraftForge/blob/1.15.x/mdk/src/main/resources/META-INF/mods.toml
export interface ModsToml {
modLoader: string
loaderVersion: string
issueTrackerURL?: string
mods: {
modId: string
version: string
displayName: string
updateJSONURL?: string
displayURL?: string
logoFile?: string
credits?: string
authors?: string
description: string
}[]
dependencies?: {[modId: string]: {
modId: string
mandatory: boolean
versionRange: string
ordering?: 'NONE' | 'BEFORE' | 'AFTER'
side: 'BOTH' | 'CLIENT' | 'SERVER'
}}
}
+8
View File
@@ -0,0 +1,8 @@
export interface PromotionsSlim {
homepage: string
promos: {
[id: string]: string
}
}
@@ -0,0 +1,21 @@
export interface VersionManifestFG2 {
id: string
time: string
releaseTime: string
type: string
minecraftArguments: string
mainClass: string
inheritsFrom: string
jar: string
logging: Record<string, unknown>
libraries: {
name: string
url?: string
checksums?: string[]
serverreq?: boolean
clientreq?: boolean
comment?: string
}[]
}
@@ -0,0 +1,25 @@
export interface VersionManifestFG3 {
id: string
time: string
releaseTime: string
type: string
mainClass: string
inheritsFrom: string
logging: Record<string, unknown>
arguments: {
game: string[]
}
libraries: {
name: string
downloads: {
artifact: {
path: string
url: string
sha1: string
size: number
}
}
}[]
}
+30
View File
@@ -0,0 +1,30 @@
import { Distribution } from 'helios-distribution-types'
export interface DistroMeta {
/**
* Distribution metadata to be forwarded to the distribution file.
*/
meta: {
rss: Distribution['rss']
discord?: Distribution['discord']
}
}
export function getDefaultDistroMeta(): DistroMeta {
return {
meta: {
rss: '<LINK TO RSS FEED>',
discord: {
clientId: '<FILL IN OR REMOVE DISCORD OBJECT>',
smallImageText: '<FILL IN OR REMOVE DISCORD OBJECT>',
smallImageKey: '<FILL IN OR REMOVE DISCORD OBJECT>'
}
}
}
}
+104
View File
@@ -0,0 +1,104 @@
import { Server } from 'helios-distribution-types'
export interface UntrackedFilesOption {
/**
* The subdirectory these patterns will be applied to. Ex.
* [ "files", "forgegemods" ]
*/
appliesTo: string[]
/**
* Glob patterns to match against the file.
*/
patterns: string[]
}
export interface ServerMetaOptions {
version?: string
forgeVersion?: string
fabricVersion?: string
}
export function getDefaultServerMeta(id: string, version: string, options?: ServerMetaOptions): ServerMeta {
const servMeta: ServerMeta = {
meta: {
version: options?.version ?? '1.0.0',
name: `${id} (Minecraft ${version})`,
description: `${id} Running Minecraft ${version}`,
icon: 'How to set the server icon: https://github.com/dscalzi/Nebula#setting-the-server-icon',
address: 'localhost:25565',
discord: {
shortId: '<FILL IN OR REMOVE DISCORD OBJECT>',
largeImageText: '<FILL IN OR REMOVE DISCORD OBJECT>',
largeImageKey: '<FILL IN OR REMOVE DISCORD OBJECT>'
},
mainServer: false,
autoconnect: false
}
}
if(options?.forgeVersion) {
servMeta.meta.description = `${servMeta.meta.description} (Forge v${options.forgeVersion})`
servMeta.forge = {
version: options.forgeVersion
}
}
if(options?.fabricVersion) {
servMeta.meta.description = `${servMeta.meta.description} (Fabric v${options.fabricVersion})`
servMeta.fabric = {
version: options.fabricVersion
}
}
// Add empty untracked files.
servMeta.untrackedFiles = []
return servMeta
}
export interface ServerMeta {
/**
* Server metadata to be forwarded to the distribution file.
*/
meta: {
version: Server['version']
name: Server['name']
description: Server['description']
icon?: Server['icon']
address: Server['address']
discord?: Server['discord']
mainServer: Server['mainServer']
autoconnect: Server['autoconnect']
javaOptions?: Server['javaOptions']
}
/**
* Properties related to Forge.
*/
forge?: {
/**
* The forge version. This does NOT include the minecraft version.
* Ex. 14.23.5.2854
*/
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.
*/
untrackedFiles?: UntrackedFilesOption[]
}
+9
View File
@@ -0,0 +1,9 @@
export interface Asset {
url: string
path: string
size: number
hashType: string
hash: string
}
+171
View File
@@ -0,0 +1,171 @@
import { createWriteStream } from 'fs'
import { mkdirs } from 'fs-extra/esm'
import got from 'got'
import StreamZip from 'node-stream-zip'
import { join, resolve } from 'path'
import { pipeline } from 'stream/promises'
import { ToggleableNamespace } from '../structure/spec_model/module/ToggleableModule.struct.js'
import { CreateServerResult } from '../structure/spec_model/Server.struct.js'
import { LoggerUtil } from '../util/LoggerUtil.js'
const log = LoggerUtil.getLogger('CurseForgeParser')
// No idea if this is right
export interface CurseForgeManifest {
minecraft: {
version: string
modLoaders: {
id: string
primary: boolean
}[]
}
manifestType: string
manifestVersion: number
name: string
version: string
author: string
files: {
projectID: number
fileID: number
required: boolean
}[]
overrides: string
}
export interface CurseForgeModResponse {
data: {
id: number
gameId: number
name: string
slug: string
// There are more fields that we don't use right now.
}
}
export interface CurseForgeModFileResponse {
data: {
id: number
gameId: number
isAvailable: boolean
displayName: string
fileName: string
downloadUrl: string
// There are more fields that we don't use right now.
}
}
export class CurseForgeParser {
private static cfClient = got.extend({
prefixUrl: 'https://api.curseforge.com/v1',
responseType: 'json',
headers: {
'X-API-KEY': '$2a$10$JL4kTO/N/oXIM6o3uTYC3eLxGrOI4BIAqpX4vAFeIPoXiTtagidkK'
}
})
private modpackDir: string
private zipPath: string
constructor(
private absoluteRoot: string,
private zipFileName: string
) {
this.modpackDir = join(absoluteRoot, 'modpacks', 'curseforge')
this.zipPath = join(this.modpackDir, zipFileName)
}
public async init(): Promise<void> {
await mkdirs(this.modpackDir)
}
public async getModpackManifest(): Promise<CurseForgeManifest> {
const zip = new StreamZip.async({ file: this.zipPath })
return JSON.parse((await zip.entryData('manifest.json')).toString('utf8')) as CurseForgeManifest
}
public async enrichServer(createServerResult: CreateServerResult, manifest: CurseForgeManifest): Promise<void> {
log.debug('Enriching server.')
// Extract overrides
const zip = new StreamZip.async({ file: this.zipPath })
try {
if(manifest.overrides) {
await zip.extract(manifest.overrides, createServerResult.miscFileContainer)
}
}
finally {
await zip.close()
}
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 }[] = []
// Download mods
for(const file of manifest.files) {
log.debug(`Processing - Mod: ${file.projectID}, File: ${file.fileID}`)
const fileInfo = (await CurseForgeParser.cfClient.get<CurseForgeModFileResponse>(`mods/${file.projectID}/files/${file.fileID}`)).body
log.debug(`Downloading ${fileInfo.data.fileName}`)
let dir: string
const fileNameLower = fileInfo.data.fileName.toLowerCase()
if(fileNameLower.endsWith('jar')) {
dir = file.required ? requiredPath : optionalPath
}
else if(fileNameLower.endsWith('zip')) {
// Assume it's a resource pack.
dir = join(createServerResult.miscFileContainer, 'resourcepacks')
await mkdirs(dir)
}
else {
dir = createServerResult.miscFileContainer
}
const thirdPartyDisallowed = fileInfo.data.downloadUrl == null
if(thirdPartyDisallowed) {
log.warn(`${fileInfo.data.fileName} is not available for 3rd-party download through the curseforge API!`)
const modInfo = (await CurseForgeParser.cfClient.get<CurseForgeModResponse>(`mods/${file.projectID}`)).body
disallowedFiles.push({
name: modInfo.data.name,
fileName: fileInfo.data.fileName,
url: `https://www.curseforge.com/minecraft/mc-mods/${modInfo.data.slug}/download/${file.fileID}`
})
} else {
const downloadStream = got.stream(fileInfo.data.downloadUrl)
const fileWriterStream = createWriteStream(join(dir, fileInfo.data.fileName))
await pipeline(downloadStream, fileWriterStream)
}
}
if(disallowedFiles.length > 0) {
log.error('============================================')
log.error('\x1b[41mWARNING\x1b[0m')
log.error(`${disallowedFiles.length} files declared by this modpack do not permit 3rd-party downloads.`)
log.error('They MUST be downloaded manually.')
log.error('The mods and their download urls will be listed below.')
log.error('============================================')
for(const file of disallowedFiles) {
log.error(`${file.name} (${file.fileName}) - \x1b[32m${file.url}\x1b[0m`)
}
log.error('============================================')
log.error('YOUR MODPACK IS NOT FULLY GENERATED!')
log.error('MANUAL ACTION REQUIRED! SCROLL UP AND READ THE WARNING!')
log.error('============================================')
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
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 {
constructor(
protected absoluteRoot: string,
protected relativeRoot: string,
protected baseUrl: string
) {}
public abstract getModule(): Promise<Module>
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
}
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from 'helios-distribution-types'
export interface Resolver {
getModule(): Promise<Module>
}
@@ -0,0 +1,122 @@
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
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<Module> {
return this.getFabricModule()
}
public isForVersion(version: MinecraftVersion, libraryVersion: string): boolean {
return FabricResolver.isForVersion(version, libraryVersion)
}
public async getFabricModule(): Promise<Module> {
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<modules.length; i++) {
if(modules[i].id.startsWith('net.fabricmc:fabric-loader')) {
index = i
break
}
}
const fabricModule = modules[index]
fabricModule.type = Type.Fabric
modules.splice(index)
fabricModule.subModules = modules
return fabricModule
}
}
+153
View File
@@ -0,0 +1,153 @@
import StreamZip from 'node-stream-zip'
import { RepoStructure } from '../../structure/repo/Repo.struct.js'
import { BaseResolver } from '../BaseResolver.js'
import { MinecraftVersion } from '../../util/MinecraftVersion.js'
import { VersionUtil } from '../../util/VersionUtil.js'
import { LoggerUtil } from '../../util/LoggerUtil.js'
export abstract class ForgeResolver extends BaseResolver {
protected readonly MOJANG_REMOTE_REPOSITORY = 'https://libraries.minecraft.net/'
protected readonly REMOTE_REPOSITORY = 'https://files.minecraftforge.net/maven/'
protected repoStructure: RepoStructure
protected artifactVersion: string
constructor(
absoluteRoot: string,
relativeRoot: string,
baseUrl: string,
protected minecraftVersion: MinecraftVersion,
protected forgeVersion: string,
protected discardOutput: boolean,
protected invalidateCache: boolean
) {
super(absoluteRoot, relativeRoot, baseUrl)
this.repoStructure = new RepoStructure(absoluteRoot, relativeRoot, 'forge')
this.artifactVersion = this.inferArtifactVersion()
this.checkSecurity()
}
public checkSecurity(): void {
const major = this.minecraftVersion.getMajor()
const minor = this.minecraftVersion.getMinor()
// https://github.com/apache/logging-log4j2/pull/608
// https://github.com/advisories/GHSA-jfh8-c2jp-5v3q
// https://www.minecraft.net/en-us/article/important-message--security-vulnerability-java-edition
// https://twitter.com/gigaherz/status/1469331288368861195
// https://gist.github.com/TheCurle/f15a6b63ceee3be58bff5e7a97c3a4e6
const patchMatrix: { [major: number]: string } = {
18: '38.0.17',
17: '37.1.1',
16: '36.2.20',
15: '31.2.56',
14: '28.2.25',
13: '25.0.222',
12: '14.23.5.2857'
}
const isVulnerable = major == 1 && (minor <= 18 && minor >= 12)
const hasPatch = major == 1 && minor >= 12
let unsafe
if(isVulnerable) {
if(hasPatch) {
unsafe = !VersionUtil.versionGte(this.forgeVersion, patchMatrix[minor])
} else {
unsafe = true
}
}
if(unsafe) {
const logger = LoggerUtil.getLogger('ForgeSecurity')
logger.error('==================================================================')
logger.error(' WARNING ')
logger.error(' This version of Forge is vulnerable to a CRITICAL RCE exploit. ')
logger.error(' DO NOT USE THIS VERSION! ')
if(hasPatch) {
logger.error(` A patch is available as of Minecraft Forge v${patchMatrix[minor]} `)
}
else {
logger.error(' There is no patch available for this version. ')
}
logger.error('==================================================================')
logger.error('To abort, use CTRL + C.')
logger.error('Nebula will proceed in 15 seconds..')
const target = new Date().getTime() + (15*1000)
while(new Date().getTime() <= target) {
// Wait
}
}
}
// Coverage is not 100% but that doesnt matter.
// It's enough and you should always use the latest version anyway.
public inferArtifactVersion(): string {
const version = `${this.minecraftVersion}-${this.forgeVersion}`
const ver = this.forgeVersion.split('.')
const major = Number(ver[0])
if ([12, 11, 10].includes(major)) {
const minor = Number(ver[1])
const revision = Number(ver[2])
const extra = Number(ver[3])
if (major === 10) {
if (minor === 13 && revision >= 2 && extra >= 1300) {
return `${version}-1.7.10`
}
}
else if (major === 11) {
if (minor === 15) {
if (revision === 1 && extra >= 1890) {
return `${version}-1.8.9`
} else if (revision === 0 && extra <= 1654) {
return `${version}-1.8.8`
}
} else if (minor === 14 && revision === 0 && extra <= 1295) {
return `${version}-1.8`
}
} else if (major === 12) {
if (minor === 17 && revision === 0 && extra <= 1936) {
return `${version}-1.9.4`
} else if (minor === 16) {
if (revision === 0 && extra <= 1885) {
return `${version}-1.9`
} else if (revision === 1 && extra === 1938) {
return `${version}-1.9.0`
}
}
}
}
return version
}
protected async getVersionManifestFromJar(jarPath: string): Promise<Buffer>{
return new Promise((resolve, reject) => {
const zip = new StreamZip({
file: jarPath,
storeEntries: true
})
zip.on('ready', () => {
try {
const data = zip.entryDataSync('version.json')
zip.close()
resolve(data)
} catch(err) {
reject(err)
}
})
zip.on('error', err => reject(err))
})
}
}
@@ -0,0 +1,255 @@
import { createHash } from 'crypto'
import { copy, mkdirs, pathExists, remove } from 'fs-extra/esm'
import { lstat, readFile } from 'fs/promises'
import { Module, Type } from 'helios-distribution-types'
import { basename, join } from 'path'
import { VersionManifestFG2 } from '../../../model/forge/VersionManifestFG2.js'
import { LibRepoStructure } from '../../../structure/repo/LibRepo.struct.js'
import { MavenUtil } from '../../../util/MavenUtil.js'
import { PackXZExtractWrapper } from '../../../util/java/PackXZExtractWrapper.js'
import { VersionUtil } from '../../../util/VersionUtil.js'
import { ForgeResolver } from '../Forge.resolver.js'
import { MinecraftVersion } from '../../../util/MinecraftVersion.js'
import { LoggerUtil } from '../../../util/LoggerUtil.js'
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never
export class ForgeGradle2Adapter extends ForgeResolver {
private static readonly logger = LoggerUtil.getLogger('FG2 Adapter')
public static isForVersion(version: MinecraftVersion, libraryVersion: string): boolean {
if(version.getMinor() === 12 && !VersionUtil.isOneDotTwelveFG2(libraryVersion)) {
return false
}
return VersionUtil.isVersionAcceptable(version, [7, 8, 9, 10, 11, 12])
}
constructor(
absoluteRoot: string,
relativeRoot: string,
baseUrl: string,
minecraftVersion: MinecraftVersion,
forgeVersion: string,
discardOutput: boolean,
invalidateCache: boolean
) {
super(absoluteRoot, relativeRoot, baseUrl, minecraftVersion, forgeVersion, discardOutput, invalidateCache)
}
public async getModule(): Promise<Module> {
return this.getForgeByVersion()
}
public isForVersion(version: MinecraftVersion, libraryVersion: string): boolean {
return ForgeGradle2Adapter.isForVersion(version, libraryVersion)
}
public async getForgeByVersion(): Promise<Module> {
const libRepo = this.repoStructure.getLibRepoStruct()
const targetLocalPath = libRepo.getLocalForge(this.artifactVersion, 'universal')
ForgeGradle2Adapter.logger.debug(`Checking for forge version at ${targetLocalPath}..`)
if (!await libRepo.artifactExists(targetLocalPath)) {
ForgeGradle2Adapter.logger.debug('Forge not found locally, initializing download..')
await libRepo.downloadArtifactByComponents(
this.REMOTE_REPOSITORY,
LibRepoStructure.FORGE_GROUP,
LibRepoStructure.FORGE_ARTIFACT,
this.artifactVersion, 'universal', 'jar')
} else {
ForgeGradle2Adapter.logger.debug('Using locally discovered forge.')
}
ForgeGradle2Adapter.logger.debug(`Beginning processing of Forge v${this.forgeVersion} (Minecraft ${this.minecraftVersion})`)
let versionManifestBuf: Buffer
try {
versionManifestBuf = await this.getVersionManifestFromJar(targetLocalPath)
} catch(err) {
throw new Error('Failed to find version.json in forge universal jar.')
}
const versionManifest = JSON.parse(versionManifestBuf.toString()) as VersionManifestFG2
const forgeModule: Module = {
id: MavenUtil.mavenComponentsToIdentifier(
LibRepoStructure.FORGE_GROUP,
LibRepoStructure.FORGE_ARTIFACT,
this.artifactVersion, 'universal'
),
name: 'Minecraft Forge',
type: Type.ForgeHosted,
artifact: this.generateArtifact(
await readFile(targetLocalPath),
await lstat(targetLocalPath),
libRepo.getArtifactUrlByComponents(
this.baseUrl,
LibRepoStructure.FORGE_GROUP,
LibRepoStructure.FORGE_ARTIFACT,
this.artifactVersion, 'universal'
)
),
subModules: []
}
const postProcessQueue = []
for (const lib of versionManifest.libraries) {
if (lib.name.startsWith('net.minecraftforge:forge:')) {
// We've already processed forge.
continue
}
ForgeGradle2Adapter.logger.debug(`Processing ${lib.name}..`)
const extension = await this.determineExtension(lib, libRepo)
const localPath = libRepo.getArtifactById(lib.name, extension)
const postProcess = extension === 'jar.pack.xz'
let queueDownload = !await libRepo.artifactExists(localPath)
let libBuf
if (!queueDownload) {
libBuf = await readFile(localPath)
// VERIFY HASH
if (!postProcess) { // Checksums for .pack.xz in the version.json are completely useless.
if (lib.checksums?.length == 1) {
const sha1 = createHash('sha1').update(libBuf).digest('hex')
if (sha1 !== lib.checksums[0]) {
ForgeGradle2Adapter.logger.debug('Hashes do not match, redownloading..')
queueDownload = true
}
}
}
} else {
ForgeGradle2Adapter.logger.debug('Not found locally, downloading..')
queueDownload = true
}
if (queueDownload) {
await libRepo.downloadArtifactById(lib.url || this.MOJANG_REMOTE_REPOSITORY, lib.name, extension)
libBuf = await readFile(localPath)
} else {
ForgeGradle2Adapter.logger.debug('Using local copy.')
}
const stats = await lstat(localPath)
const mavenComponents = MavenUtil.getMavenComponents(lib.name)
const properId = MavenUtil.mavenComponentsToIdentifier(
mavenComponents.group, mavenComponents.artifact, mavenComponents.version,
mavenComponents.classifier, extension
)
forgeModule.subModules?.push({
id: properId,
name: `Minecraft Forge (${mavenComponents?.artifact})`,
type: Type.Library,
artifact: this.generateArtifact(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
libBuf!,
stats,
libRepo.getArtifactUrlByComponents(
this.baseUrl,
mavenComponents.group, mavenComponents.artifact,
mavenComponents.version, mavenComponents.classifier, extension
)
)
})
if (postProcess) {
postProcessQueue.push({
id: properId,
localPath
})
}
}
for (const entry of await this.processPackXZFiles(postProcessQueue)) {
const el = forgeModule.subModules?.find((element) => element.id === entry.id)
if (el != null) {
el.artifact.MD5 = entry.MD5
} else {
ForgeGradle2Adapter.logger.error(`Error during post processing, could not update ${entry.id}`)
}
}
return forgeModule
}
private async determineExtension(lib: ArrayElement<VersionManifestFG2['libraries']>, libRepo: LibRepoStructure): Promise<string> {
if(lib.url == null) {
return 'jar'
}
ForgeGradle2Adapter.logger.debug('Determing extension..')
const possibleExt = [
'jar.pack.xz',
'jar'
]
// Check locally.
for(const ext of possibleExt) {
const localPath = libRepo.getArtifactById(lib.name, ext)
const exists = await libRepo.artifactExists(localPath)
if(exists) {
return ext
}
}
// Check remote.
for(const ext of possibleExt) {
const exists = await libRepo.headArtifactById(this.REMOTE_REPOSITORY, lib.name, ext)
if(exists) {
return ext
}
}
// Default to jar.
return 'jar'
}
private async processPackXZFiles(
processingQueue: {id: string, localPath: string}[]
): Promise<{id: string, MD5: string}[]> {
if(processingQueue.length == 0) {
return []
}
const accumulator = []
const tempDir = this.repoStructure.getTempDirectory()
if (await pathExists(tempDir)) {
await remove(tempDir)
}
await mkdirs(tempDir)
const files = []
for (const entry of processingQueue) {
const tmpFile = join(tempDir, basename(entry.localPath))
await copy(entry.localPath, tmpFile)
files.push(tmpFile)
}
ForgeGradle2Adapter.logger.debug('Spawning PackXZExtract.')
const packXZExecutor = new PackXZExtractWrapper()
await packXZExecutor.extractUnpack(files)
ForgeGradle2Adapter.logger.debug('All files extracted, calculating hashes..')
for (const entry of processingQueue) {
const tmpFileName = basename(entry.localPath)
const tmpFile = join(tempDir, tmpFileName.substring(0, tmpFileName.indexOf('.pack.xz')))
const buf = await readFile(tmpFile)
accumulator.push({
id: entry.id,
MD5: createHash('md5').update(buf).digest('hex')
})
}
ForgeGradle2Adapter.logger.debug('Complete, removing temp directory..')
await remove(tempDir)
return accumulator
}
}
@@ -0,0 +1,778 @@
import { ForgeResolver } from '../Forge.resolver.js'
import { MinecraftVersion } from '../../../util/MinecraftVersion.js'
import { LoggerUtil } from '../../../util/LoggerUtil.js'
import { VersionUtil } from '../../../util/VersionUtil.js'
import { Module, Type } from 'helios-distribution-types'
import { LibRepoStructure } from '../../../structure/repo/LibRepo.struct.js'
import { pathExists, remove, mkdirs, copy, writeJson } from 'fs-extra/esm'
import { lstat, readFile, writeFile } from 'fs/promises'
import { join, basename, dirname } from 'path'
import { spawn } from 'child_process'
import { JavaUtil } from '../../../util/java/JavaUtil.js'
import { VersionManifestFG3 } from '../../../model/forge/VersionManifestFG3.js'
import { MavenUtil } from '../../../util/MavenUtil.js'
import { createHash } from 'crypto'
interface GeneratedFile {
name: string
group: string
artifact: string
version: string
classifiers: string[] | [undefined]
skipIfNotPresent?: boolean
classpath?: boolean
}
export class ForgeGradle3Adapter extends ForgeResolver {
private static readonly logger = LoggerUtil.getLogger('FG3 Adapter')
private static readonly WILDCARD_MCP_VERSION = '${mcpVersion}'
public static isForVersion(version: MinecraftVersion, libraryVersion: string): boolean {
if(version.getMinor() === 12 && VersionUtil.isOneDotTwelveFG2(libraryVersion)) {
return false
}
return VersionUtil.isVersionAcceptable(version, [12, 13, 14, 15, 16, 17, 18, 19, 20, 21])
}
public static isExecutableJar(version: MinecraftVersion): boolean {
return version.isGreaterThanOrEqualTo(new MinecraftVersion('1.20.3'))
}
private generatedFiles: GeneratedFile[] | undefined
private wildcardsInUse: string[] | undefined
constructor(
absoluteRoot: string,
relativeRoot: string,
baseUrl: string,
minecraftVersion: MinecraftVersion,
forgeVersion: string,
discardOutput: boolean,
invalidateCache: boolean
) {
super(absoluteRoot, relativeRoot, baseUrl, minecraftVersion, forgeVersion, discardOutput, invalidateCache)
this.configure()
}
private configure(): void {
if(ForgeGradle3Adapter.isExecutableJar(this.minecraftVersion)) {
// Separate block for 1.20.4+
this.generatedFiles = [
{
name: 'universal jar',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.FORGE_ARTIFACT,
version: this.artifactVersion,
classifiers: ['universal'],
},
{
name: 'client jar',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.FORGE_ARTIFACT,
version: this.artifactVersion,
classifiers: ['client']
},
{
name: 'client shim',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.FORGE_ARTIFACT,
version: this.artifactVersion,
classifiers: ['shim'],
classpath: false
},
{
name: 'fmlcore',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.FMLCORE_ARTIFACT,
version: this.artifactVersion,
classifiers: [undefined]
},
{
name: 'javafmllanguage',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.JAVAFMLLANGUAGE_ARTIFACT,
version: this.artifactVersion,
classifiers: [undefined]
},
{
name: 'mclanguage',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.MCLANGUAGE_ARTIFACT,
version: this.artifactVersion,
classifiers: [undefined]
},
{
name: 'lowcodelanguage',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.LOWCODELANGUAGE_ARTIFACT,
version: this.artifactVersion,
classifiers: [undefined]
}
]
return
}
// Configure for 13, 14, 15, 16, 17, 18, 19
if(VersionUtil.isVersionAcceptable(this.minecraftVersion, [13, 14, 15, 16, 17, 18, 19, 20])) {
// https://github.com/MinecraftForge/MinecraftForge/commit/97d4652f5fe15931b980117efabdff332f9f6428
const mcpUnifiedVersion = `${this.minecraftVersion}-${ForgeGradle3Adapter.WILDCARD_MCP_VERSION}`
this.generatedFiles = [
{
name: 'universal jar',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.FORGE_ARTIFACT,
version: this.artifactVersion,
classifiers: ['universal'],
classpath: false
},
{
name: 'client jar',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.FORGE_ARTIFACT,
version: this.artifactVersion,
classifiers: ['client'],
classpath: false
},
{
name: 'client data',
group: LibRepoStructure.MINECRAFT_GROUP,
artifact: LibRepoStructure.MINECRAFT_CLIENT_ARTIFACT,
version: this.minecraftVersion.toString(),
classifiers: ['data'],
skipIfNotPresent: true,
classpath: false
},
{
name: 'client srg',
group: LibRepoStructure.MINECRAFT_GROUP,
artifact: LibRepoStructure.MINECRAFT_CLIENT_ARTIFACT,
version: mcpUnifiedVersion,
classifiers: ['srg'],
classpath: false
}
]
this.wildcardsInUse = [
ForgeGradle3Adapter.WILDCARD_MCP_VERSION
]
if(VersionUtil.isVersionAcceptable(this.minecraftVersion, [13, 14, 15, 16])) {
// Base jar present for 1.13-1.16
this.generatedFiles.unshift(
{
name: 'base jar',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.FORGE_ARTIFACT,
version: this.artifactVersion,
classifiers: [undefined]
}
)
}
if(VersionUtil.isVersionAcceptable(this.minecraftVersion, [17, 18, 19, 20])) {
// Added in 1.17+
this.generatedFiles.unshift(
{
name: 'fmlcore',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.FMLCORE_ARTIFACT,
version: this.artifactVersion,
classifiers: [undefined]
},
{
name: 'javafmllanguage',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.JAVAFMLLANGUAGE_ARTIFACT,
version: this.artifactVersion,
classifiers: [undefined]
},
{
name: 'mclanguage',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.MCLANGUAGE_ARTIFACT,
version: this.artifactVersion,
classifiers: [undefined]
}
)
}
if (VersionUtil.isVersionAcceptable(this.minecraftVersion, [18, 19, 20])) {
// Added in 1.18+
this.generatedFiles.unshift(
{
name: 'lowcodelanguage',
group: LibRepoStructure.FORGE_GROUP,
artifact: LibRepoStructure.LOWCODELANGUAGE_ARTIFACT,
version: this.artifactVersion,
classifiers: [undefined]
}
)
}
if(VersionUtil.isVersionAcceptable(this.minecraftVersion, [13, 14, 15])) {
// 13, 14, 15 use just the MC version.
this.generatedFiles.push(
{
name: 'client slim',
group: LibRepoStructure.MINECRAFT_GROUP,
artifact: LibRepoStructure.MINECRAFT_CLIENT_ARTIFACT,
version: this.minecraftVersion.toString(),
classifiers: [
'slim',
'slim-stable'
]
},
{
name: 'client extra',
group: LibRepoStructure.MINECRAFT_GROUP,
artifact: LibRepoStructure.MINECRAFT_CLIENT_ARTIFACT,
version: this.minecraftVersion.toString(),
classifiers: [
'extra',
'extra-stable'
]
}
)
} else {
// 16+ uses the mcp unified version.
this.generatedFiles.push(
{
name: 'client slim',
group: LibRepoStructure.MINECRAFT_GROUP,
artifact: LibRepoStructure.MINECRAFT_CLIENT_ARTIFACT,
version: mcpUnifiedVersion,
classifiers: [
'slim',
'slim-stable'
],
classpath: false
},
{
name: 'client extra',
group: LibRepoStructure.MINECRAFT_GROUP,
artifact: LibRepoStructure.MINECRAFT_CLIENT_ARTIFACT,
version: mcpUnifiedVersion,
classifiers: [
'extra',
'extra-stable'
],
classpath: false
}
)
}
return
}
// Configure for 12
if(VersionUtil.isVersionAcceptable(this.minecraftVersion, [12])) {
// NOTHING TO CONFIGURE
return
}
}
public async getModule(): Promise<Module> {
return this.process()
}
public isForVersion(version: MinecraftVersion, libraryVersion: string): boolean {
return ForgeGradle3Adapter.isForVersion(version, libraryVersion)
}
private async process(): Promise<Module> {
const libRepo = this.repoStructure.getLibRepoStruct()
// Get Installer
const installerPath = libRepo.getLocalForge(this.artifactVersion, 'installer')
ForgeGradle3Adapter.logger.debug(`Checking for forge installer at ${installerPath}..`)
if (!await libRepo.artifactExists(installerPath)) {
ForgeGradle3Adapter.logger.debug('Forge installer not found locally, initializing download..')
await libRepo.downloadArtifactByComponents(
this.REMOTE_REPOSITORY,
LibRepoStructure.FORGE_GROUP,
LibRepoStructure.FORGE_ARTIFACT,
this.artifactVersion, 'installer', 'jar'
)
} else {
ForgeGradle3Adapter.logger.debug('Using locally discovered forge installer.')
}
ForgeGradle3Adapter.logger.debug(`Beginning processing of Forge v${this.forgeVersion} (Minecraft ${this.minecraftVersion})`)
if(this.generatedFiles != null && this.generatedFiles.length > 0) {
// Run installer
return this.processWithInstaller(installerPath)
} else {
// Installer not required
return this.processWithoutInstaller(installerPath)
}
}
private async processWithInstaller(installerPath: string): Promise<Module> {
let doInstall = true
// Check cache.
const cacheDir = this.repoStructure.getForgeCacheDirectory(this.artifactVersion)
if (await pathExists(cacheDir)) {
if(this.invalidateCache) {
ForgeGradle3Adapter.logger.info(`Removing existing cache ${cacheDir}..`)
await remove(cacheDir)
} else {
// Use cache.
doInstall = false
ForgeGradle3Adapter.logger.info(`Using cached results at ${cacheDir}.`)
}
} else {
await mkdirs(cacheDir)
}
const installerOutputDir = cacheDir
if(doInstall) {
const workingInstaller = join(installerOutputDir, basename(installerPath))
await copy(installerPath, workingInstaller)
// Required for the installer to function.
await writeFile(join(installerOutputDir, 'launcher_profiles.json'), JSON.stringify({}))
ForgeGradle3Adapter.logger.debug('Spawning forge installer')
ForgeGradle3Adapter.logger.info('============== [ IMPORTANT ] ==============')
ForgeGradle3Adapter.logger.info('When the installer opens please set the client installation directory to:')
ForgeGradle3Adapter.logger.info(installerOutputDir)
ForgeGradle3Adapter.logger.info('===========================================')
await this.executeInstaller(workingInstaller)
ForgeGradle3Adapter.logger.debug('Installer finished, beginning processing..')
}
await this.verifyInstallerRan(installerOutputDir)
ForgeGradle3Adapter.logger.debug('Processing Version Manifest')
const versionManifestTuple = await this.processVersionManifest(installerOutputDir)
const versionManifest = versionManifestTuple[0]
ForgeGradle3Adapter.logger.debug('Processing generated forge files.')
const forgeModule = await this.processForgeModule(versionManifest, installerOutputDir)
// Attach version.json module.
forgeModule.subModules?.unshift(versionManifestTuple[1])
ForgeGradle3Adapter.logger.debug('Processing Libraries')
const libs = await this.processLibraries(versionManifest, installerOutputDir)
forgeModule.subModules = forgeModule.subModules?.concat(libs)
if(this.discardOutput) {
ForgeGradle3Adapter.logger.info(`Removing installer output at ${installerOutputDir}..`)
await remove(installerOutputDir)
ForgeGradle3Adapter.logger.info('Removed successfully.')
}
return forgeModule
}
private getVersionManifestPath(installerOutputDir: string): string {
const versionRepo = this.repoStructure.getVersionRepoStruct()
const versionName = versionRepo.getFileName(this.minecraftVersion, this.forgeVersion)
return join(installerOutputDir, 'versions', versionName, `${versionName}.json`)
}
private async verifyInstallerRan(installerOutputDir: string): Promise<void> {
const versionManifestPath = this.getVersionManifestPath(installerOutputDir)
if(!await pathExists(versionManifestPath)) {
await remove(installerOutputDir)
throw new Error(`Forge was either not installed or installed to the wrong location. When the forge installer opens, you MUST set the installation directory to ${installerOutputDir}`)
}
}
private async processVersionManifest(installerOutputDir: string): Promise<[VersionManifestFG3, Module]> {
const versionRepo = this.repoStructure.getVersionRepoStruct()
const versionManifestPath = this.getVersionManifestPath(installerOutputDir)
const versionManifestBuf = await readFile(versionManifestPath)
const versionManifest = JSON.parse(versionManifestBuf.toString()) as VersionManifestFG3
const versionManifestModule: Module = {
id: this.artifactVersion,
name: 'Minecraft Forge (version.json)',
type: Type.VersionManifest,
artifact: this.generateArtifact(
versionManifestBuf,
await lstat(versionManifestPath),
versionRepo.getVersionManifestURL(this.baseUrl, this.minecraftVersion, this.forgeVersion)
)
}
const destination = versionRepo.getVersionManifest(
this.minecraftVersion,
this.forgeVersion
)
await copy(versionManifestPath, destination, {overwrite: true})
return [versionManifest, versionManifestModule]
}
private async processForgeModule(versionManifest: VersionManifestFG3, installerOutputDir: string): Promise<Module> {
const libDir = join(installerOutputDir, 'libraries')
if(this.wildcardsInUse) {
if(this.wildcardsInUse.includes(ForgeGradle3Adapter.WILDCARD_MCP_VERSION)) {
const mcpVersion = this.getMCPVersion(versionManifest.arguments.game)
if(mcpVersion == null) {
throw new Error('MCP Version not found.. did forge change their format?')
}
this.generatedFiles = this.generatedFiles!.map(f => {
if(f.version.includes(ForgeGradle3Adapter.WILDCARD_MCP_VERSION)) {
return {
...f,
version: f.version.replace(ForgeGradle3Adapter.WILDCARD_MCP_VERSION, mcpVersion)
}
}
return f
})
}
}
const mdls: Module[] = []
for (const entry of this.generatedFiles!) {
const targetLocations: string[] = []
let located = false
classifierLoop:
for (const _classifier of entry.classifiers) {
const targetLocalPath = join(
libDir,
MavenUtil.mavenComponentsAsNormalizedPath(entry.group, entry.artifact, entry.version, _classifier)
)
targetLocations.push(targetLocalPath)
const exists = await pathExists(targetLocalPath)
if (exists) {
mdls.push({
id: MavenUtil.mavenComponentsToIdentifier(
entry.group,
entry.artifact,
entry.version,
_classifier
),
name: `Minecraft Forge (${entry.name})`,
type: Type.Library,
classpath: entry.classpath ?? true,
artifact: this.generateArtifact(
await readFile(targetLocalPath),
await lstat(targetLocalPath),
this.repoStructure.getLibRepoStruct().getArtifactUrlByComponents(
this.baseUrl,
entry.group,
entry.artifact,
entry.version,
_classifier
)
),
subModules: []
})
const destination = this.repoStructure.getLibRepoStruct().getArtifactByComponents(
entry.group,
entry.artifact,
entry.version,
_classifier
)
await copy(targetLocalPath, destination, {overwrite: true})
located = true
break classifierLoop
}
}
if (!entry.skipIfNotPresent && !located) {
throw new Error(`Required file ${entry.name} not found at any expected location:\n\t${targetLocations.join('\n\t')}`)
}
}
const forgeModule = mdls.shift()!
forgeModule.type = Type.ForgeHosted
forgeModule.subModules = mdls
return forgeModule
}
private async processLibraries(manifest: VersionManifestFG3, installerOutputDir: string): Promise<Module[]> {
const libDir = join(installerOutputDir, 'libraries')
const libRepo = this.repoStructure.getLibRepoStruct()
const mdls: Module[] = []
for (const entry of manifest.libraries) {
const artifact = entry.downloads.artifact
if (artifact.url) {
const targetLocalPath = join(libDir, artifact.path)
if (!await pathExists(targetLocalPath)) {
throw new Error(`Expected library ${entry.name} not found!`)
}
const components = MavenUtil.getMavenComponents(entry.name)
mdls.push({
id: entry.name,
name: `Minecraft Forge (${components.artifact})`,
type: Type.Library,
artifact: this.generateArtifact(
await readFile(targetLocalPath),
await lstat(targetLocalPath),
libRepo.getArtifactUrlByComponents(
this.baseUrl,
components.group,
components.artifact,
components.version,
components.classifier,
components.extension
)
)
})
const destination = libRepo.getArtifactByComponents(
components.group,
components.artifact,
components.version,
components.classifier,
components.extension
)
await copy(targetLocalPath, destination, {overwrite: true})
}
}
return mdls
}
private executeInstaller(installerExec: string): Promise<void> {
return new Promise(resolve => {
const fiLogger = LoggerUtil.getLogger('Forge Installer')
const child = spawn(JavaUtil.getJavaExecutable(), [
'-jar',
installerExec
], {
cwd: dirname(installerExec)
})
child.stdout.on('data', (data) => fiLogger.info(data.toString('utf8').trim()))
child.stderr.on('data', (data) => fiLogger.error(data.toString('utf8').trim()))
child.on('close', code => {
if(code === 0) {
fiLogger.info('Exited with code', code)
} else {
fiLogger.error('Exited with code', code)
}
resolve()
})
})
}
private getMCPVersion(args: string[]): string | null {
for (let i = 0; i < args.length; i++) {
if (args[i] === '--fml.mcpVersion') {
return args[i + 1]
}
}
return null
}
private async processWithoutInstaller(installerPath: string): Promise<Module> {
// Extract version.json from installer.
let versionManifestBuf: Buffer
try {
versionManifestBuf = await this.getVersionManifestFromJar(installerPath)
} catch(err) {
throw new Error('Failed to find version.json in forge installer jar.')
}
const versionManifest = JSON.parse(versionManifestBuf.toString()) as VersionManifestFG3
// Save Version Manifest
const versionManifestDest = this.repoStructure.getVersionRepoStruct().getVersionManifest(
this.minecraftVersion,
this.forgeVersion
)
await mkdirs(dirname(versionManifestDest))
await writeJson(versionManifestDest, versionManifest, { spaces: 4 })
const libRepo = this.repoStructure.getLibRepoStruct()
const universalLocalPath = libRepo.getLocalForge(this.artifactVersion, 'universal')
ForgeGradle3Adapter.logger.debug(`Checking for Forge Universal jar at ${universalLocalPath}..`)
const forgeMdl = versionManifest.libraries.find(val => val.name.startsWith('net.minecraftforge:forge:'))
if(forgeMdl == null) {
throw new Error('Forge entry not found in version.json!')
}
let forgeUniversalBuffer
// Check for local universal jar.
if (await libRepo.artifactExists(universalLocalPath)) {
const localUniBuf = await readFile(universalLocalPath)
const sha1 = createHash('sha1').update(localUniBuf).digest('hex')
if(sha1 !== forgeMdl.downloads.artifact.sha1) {
ForgeGradle3Adapter.logger.debug('SHA-1 of local universal jar does not match version.json entry.')
ForgeGradle3Adapter.logger.debug('Redownloading Forge Universal jar..')
} else {
ForgeGradle3Adapter.logger.debug('Using locally discovered forge.')
forgeUniversalBuffer = localUniBuf
}
} else {
ForgeGradle3Adapter.logger.debug('Forge Universal jar not found locally, initializing download..')
}
// Download if local is missing or corrupt
if(!forgeUniversalBuffer) {
await libRepo.downloadArtifactByComponents(
this.REMOTE_REPOSITORY,
LibRepoStructure.FORGE_GROUP,
LibRepoStructure.FORGE_ARTIFACT,
this.artifactVersion, 'universal', 'jar')
forgeUniversalBuffer = await readFile(universalLocalPath)
}
ForgeGradle3Adapter.logger.debug(`Beginning processing of Forge v${this.forgeVersion} (Minecraft ${this.minecraftVersion})`)
const forgeModule: Module = {
id: MavenUtil.mavenComponentsToIdentifier(
LibRepoStructure.FORGE_GROUP,
LibRepoStructure.FORGE_ARTIFACT,
this.artifactVersion, 'universal'
),
name: 'Minecraft Forge',
type: Type.ForgeHosted,
artifact: this.generateArtifact(
forgeUniversalBuffer,
await lstat(universalLocalPath),
libRepo.getArtifactUrlByComponents(
this.baseUrl,
LibRepoStructure.FORGE_GROUP,
LibRepoStructure.FORGE_ARTIFACT,
this.artifactVersion, 'universal'
)
),
subModules: []
}
// Attach Version Manifest module.
forgeModule.subModules?.push({
id: this.artifactVersion,
name: 'Minecraft Forge (version.json)',
type: Type.VersionManifest,
artifact: this.generateArtifact(
await readFile(versionManifestDest),
await lstat(versionManifestDest),
this.repoStructure.getVersionRepoStruct().getVersionManifestURL(
this.baseUrl, this.minecraftVersion, this.forgeVersion)
)
})
for(const lib of versionManifest.libraries) {
if (lib.name.startsWith('net.minecraftforge:forge:')) {
// We've already processed forge.
continue
}
ForgeGradle3Adapter.logger.debug(`Processing ${lib.name}..`)
const extension = 'jar'
const localPath = libRepo.getArtifactById(lib.name, extension)
let queueDownload = !await libRepo.artifactExists(localPath)
let libBuf
if (!queueDownload) {
libBuf = await readFile(localPath)
const sha1 = createHash('sha1').update(libBuf).digest('hex')
if (sha1 !== lib.downloads.artifact.sha1) {
ForgeGradle3Adapter.logger.debug('Hashes do not match, redownloading..')
queueDownload = true
}
} else {
ForgeGradle3Adapter.logger.debug('Not found locally, downloading..')
queueDownload = true
}
if (queueDownload) {
await libRepo.downloadArtifactDirect(lib.downloads.artifact.url, lib.downloads.artifact.path)
libBuf = await readFile(localPath)
} else {
ForgeGradle3Adapter.logger.debug('Using local copy.')
}
const stats = await lstat(localPath)
const mavenComponents = MavenUtil.getMavenComponents(lib.name)
const properId = MavenUtil.mavenComponentsToIdentifier(
mavenComponents.group, mavenComponents.artifact, mavenComponents.version,
mavenComponents.classifier, extension
)
forgeModule.subModules?.push({
id: properId,
name: `Minecraft Forge (${mavenComponents?.artifact})`,
type: Type.Library,
artifact: this.generateArtifact(
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
libBuf!,
stats,
libRepo.getArtifactUrlByComponents(
this.baseUrl,
mavenComponents.group, mavenComponents.artifact,
mavenComponents.version, mavenComponents.classifier, extension
)
)
})
}
return forgeModule
}
}
+32
View File
@@ -0,0 +1,32 @@
import { mkdirs } from 'fs-extra/esm'
import { join, resolve } from 'path'
import { FileStructure } from './FileStructure.js'
import { Logger } from 'winston'
import { LoggerUtil } from '../util/LoggerUtil.js'
export abstract class BaseFileStructure implements FileStructure {
protected logger: Logger
protected containerDirectory: string
constructor(
protected absoluteRoot: string,
protected relativeRoot: string,
protected structRoot: string
) {
this.relativeRoot = join(relativeRoot, structRoot)
this.containerDirectory = resolve(absoluteRoot, structRoot)
this.logger = LoggerUtil.getLogger(this.getLoggerName())
}
public async init(): Promise<void> {
await mkdirs(this.containerDirectory)
}
public getContainerDirectory(): string {
return this.containerDirectory
}
public abstract getLoggerName(): string
}
+5
View File
@@ -0,0 +1,5 @@
export interface FileStructure {
init(): Promise<void>
}
+99
View File
@@ -0,0 +1,99 @@
import got from 'got'
import { mkdirs, pathExists } from 'fs-extra/esm'
import { createWriteStream } from 'fs'
import { dirname, join, resolve } from 'path'
import { URL } from 'url'
import { MavenUtil } from '../../util/MavenUtil.js'
import { BaseFileStructure } from '../BaseFileStructure.js'
import { LoggerUtil } from '../../util/LoggerUtil.js'
export abstract class BaseMavenRepo extends BaseFileStructure {
private static readonly logger = LoggerUtil.getLogger('BaseMavenRepo')
constructor(
absoluteRoot: string,
relativeRoot: string,
structRoot: string
) {
super(absoluteRoot, relativeRoot, structRoot)
}
public getArtifactById(mavenIdentifier: string, extension?: string): string {
return resolve(this.containerDirectory, MavenUtil.mavenIdentifierAsPath(mavenIdentifier, extension))
}
public getArtifactByComponents(
group: string, artifact: string, version: string, classifier?: string, extension = 'jar'
): string {
return resolve(this.containerDirectory,
MavenUtil.mavenComponentsAsPath(group, artifact, version, classifier, extension))
}
public getArtifactUrlByComponents(
baseURL: string, group: string, artifact: string, version: string, classifier?: string, extension = 'jar'
): string {
return new URL(join(this.relativeRoot,
MavenUtil.mavenComponentsAsPath(group, artifact, version, classifier, extension)), baseURL).toString()
}
public async artifactExists(path: string): Promise<boolean> {
return pathExists(path)
}
public async downloadArtifactById(url: string, mavenIdentifier: string, extension?: string): Promise<void> {
return this.downloadArtifactBase(url, MavenUtil.mavenIdentifierAsPath(mavenIdentifier, extension))
}
public async downloadArtifactByComponents(
url: string, group: string, artifact: string, version: string, classifier?: string, extension?: string
): Promise<void> {
return this.downloadArtifactBase(url,
MavenUtil.mavenComponentsAsPath(group, artifact, version, classifier, extension))
}
private async downloadArtifactBase(url: string, relative: string): Promise<void> {
const resolvedURL = new URL(relative, url).toString()
return this.downloadArtifactDirect(resolvedURL, relative)
}
public async downloadArtifactDirect(url: string, path: string): Promise<void> {
BaseMavenRepo.logger.debug(`Downloading ${url}..`)
const request = got.stream.get({ url })
const localPath = resolve(this.containerDirectory, path)
await mkdirs(dirname(localPath))
const writer = createWriteStream(localPath)
request.pipe(writer)
return new Promise((resolve, reject) => {
writer.on('finish', () => {
BaseMavenRepo.logger.debug(`Completed download of ${url}.`)
resolve()
})
writer.on('error', reject)
})
}
public async headArtifactById(url: string, mavenIdentifier: string, extension?: string): Promise<boolean> {
return this.headArtifactBase(url, MavenUtil.mavenIdentifierAsPath(mavenIdentifier, extension))
}
public async headArtifactByComponents(
url: string, group: string, artifact: string, version: string, classifier?: string, extension?: string
): Promise<boolean> {
return this.headArtifactBase(url,
MavenUtil.mavenComponentsAsPath(group, artifact, version, classifier, extension))
}
private async headArtifactBase(url: string, relative: string): Promise<boolean> {
const resolvedURL = new URL(relative, url).toString()
try {
const response = await got.head({
url: resolvedURL
})
return response.statusCode === 200
} catch (ignored) {
return false
}
}
}
@@ -0,0 +1,33 @@
import { BaseMavenRepo } from './BaseMavenRepo.js'
export class LibRepoStructure extends BaseMavenRepo {
public static readonly MINECRAFT_GROUP = 'net.minecraft'
public static readonly MINECRAFT_CLIENT_ARTIFACT = 'client'
public static readonly FORGE_GROUP = 'net.minecraftforge'
public static readonly FORGE_ARTIFACT = 'forge'
public static readonly FMLCORE_ARTIFACT = 'fmlcore'
public static readonly JAVAFMLLANGUAGE_ARTIFACT = 'javafmllanguage'
public static readonly MCLANGUAGE_ARTIFACT = 'mclanguage'
public static readonly LOWCODELANGUAGE_ARTIFACT = 'lowcodelanguage'
constructor(
absoluteRoot: string,
relativeRoot: string
) {
super(absoluteRoot, relativeRoot, 'lib')
}
public getLoggerName(): string {
return 'LibRepoStructure'
}
public getLocalForge(version: string, classifier?: string): string {
return this.getArtifactByComponents(
LibRepoStructure.FORGE_GROUP,
LibRepoStructure.FORGE_ARTIFACT,
version, classifier, 'jar')
}
}
+57
View File
@@ -0,0 +1,57 @@
import { mkdirs } from 'fs-extra/esm'
import { join } from 'path'
import { BaseFileStructure } from '../BaseFileStructure.js'
import { LibRepoStructure } from './LibRepo.struct.js'
import { VersionRepoStructure } from './VersionRepo.struct.js'
export class RepoStructure extends BaseFileStructure {
private libRepoStruct: LibRepoStructure
private versionRepoStruct: VersionRepoStructure
constructor(
absoluteRoot: 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, name)
}
public getLoggerName(): string {
return 'RepoStructure'
}
public async init(): Promise<void> {
await super.init()
await this.libRepoStruct.init()
await this.versionRepoStruct.init()
await mkdirs(this.getCacheDirectory())
}
public getLibRepoStruct(): LibRepoStructure {
return this.libRepoStruct
}
public getVersionRepoStruct(): VersionRepoStructure {
return this.versionRepoStruct
}
public getTempDirectory(): string {
return join(this.absoluteRoot, 'temp')
}
public getWorkDirectory(): string {
return join(this.absoluteRoot, 'work')
}
public getCacheDirectory(): string {
return join(this.absoluteRoot, 'cache')
}
public getForgeCacheDirectory(artifactVersion: string): string {
return join(this.getCacheDirectory(), 'forge', artifactVersion)
}
}
@@ -0,0 +1,37 @@
import { join } from 'path'
import { URL } from 'url'
import { BaseFileStructure } from '../BaseFileStructure.js'
import { MinecraftVersion } from '../../util/MinecraftVersion.js'
export class VersionRepoStructure extends BaseFileStructure {
private name: string
constructor(
absoluteRoot: string,
relativeRoot: string,
name: string
) {
super(absoluteRoot, relativeRoot, 'versions')
this.name = name
}
public getLoggerName(): string {
return 'VersionRepoStructure'
}
public getFileName(minecraftVersion: MinecraftVersion, loaderVersion: string): string {
return `${minecraftVersion}-${this.name}-${loaderVersion}`
}
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, loaderVersion: string): string {
const fileName = this.getFileName(minecraftVersion, loaderVersion)
return new URL(join(this.relativeRoot, fileName, `${fileName}.json`), url).toString()
}
}
@@ -0,0 +1,19 @@
import { BaseFileStructure } from '../BaseFileStructure.js'
import { SpecModelStructure } from './SpecModelStructure.js'
export abstract class BaseModelStructure<T> extends BaseFileStructure implements SpecModelStructure<T[]> {
protected resolvedModels: T[] | undefined
constructor(
absoluteRoot: string,
relativeRoot: string,
structRoot: string,
protected baseUrl: string
) {
super(absoluteRoot, relativeRoot, structRoot)
}
public abstract getSpecModel(): Promise<T[]>
}
@@ -0,0 +1,62 @@
import { mkdirs, pathExists } from 'fs-extra/esm'
import { readFile, writeFile } from 'fs/promises'
import { Distribution } from 'helios-distribution-types'
import { SpecModelStructure } from './SpecModelStructure.js'
import { ServerStructure } from './Server.struct.js'
import { join, resolve } from 'path'
import { DistroMeta, getDefaultDistroMeta } from '../../model/nebula/DistroMeta.js'
import { addSchemaToObject, SchemaTypes } from '../../util/SchemaUtil.js'
import { LoggerUtil } from '../../util/LoggerUtil.js'
const logger = LoggerUtil.getLogger('DistributionStructure')
export class DistributionStructure implements SpecModelStructure<Distribution> {
private readonly DISTRO_META_FILE = 'distrometa.json'
private serverStruct: ServerStructure
private metaPath: string
constructor(
private absoluteRoot: string,
private baseUrl: string,
discardOutput: boolean,
invalidateCache: boolean
) {
this.serverStruct = new ServerStructure(this.absoluteRoot, this.baseUrl, discardOutput, invalidateCache)
this.metaPath = join(this.absoluteRoot, 'meta')
}
public async init(): Promise<void> {
await mkdirs(this.absoluteRoot)
await mkdirs(this.metaPath)
const distroMetaFile = resolve(this.metaPath, this.DISTRO_META_FILE)
if(await pathExists(distroMetaFile)) {
logger.warn(`Distro Meta file already exists at ${distroMetaFile}!`)
logger.warn('If you wish to regenerate this file, you must delete the existing one!')
} else {
const distroMeta: DistroMeta = addSchemaToObject(
getDefaultDistroMeta(),
SchemaTypes.DistroMetaSchema,
this.absoluteRoot
)
await writeFile(distroMetaFile, JSON.stringify(distroMeta, null, 2))
}
await this.serverStruct.init()
}
public async getSpecModel(): Promise<Distribution> {
const distroMeta = JSON.parse(await readFile(resolve(this.metaPath, this.DISTRO_META_FILE), 'utf-8')) as DistroMeta
return {
version: '1.0.0',
rss: distroMeta.meta.rss,
...(distroMeta.meta.discord ? {discord: distroMeta.meta.discord} : {}),
servers: await this.serverStruct.getSpecModel()
}
}
}
@@ -0,0 +1,254 @@
import { mkdirs, pathExists } from 'fs-extra/esm'
import { lstat, readdir, readFile, writeFile } from 'fs/promises'
import { Server, Module } from 'helios-distribution-types'
import { dirname, join, resolve as resolvePath } from 'path'
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 {
modContainer?: string
libraryContainer: string
miscFileContainer: string
}
export class ServerStructure extends BaseModelStructure<Server> {
private readonly ID_REGEX = /(.+-(.+)$)/
private readonly SERVER_META_FILE = 'servermeta.json'
constructor(
absoluteRoot: string,
baseUrl: string,
private discardOutput: boolean,
private invalidateCache: boolean
) {
super(absoluteRoot, '', 'servers', baseUrl)
}
public getLoggerName(): string {
return 'ServerStructure'
}
public async getSpecModel(): Promise<Server[]> {
if (this.resolvedModels == null) {
this.resolvedModels = await this._doSeverRetrieval()
}
return this.resolvedModels
}
public static getEffectiveId(id: string, minecraftVersion: MinecraftVersion): string {
return `${id}-${minecraftVersion}`
}
public async createServer(
id: string,
minecraftVersion: MinecraftVersion,
options: {
version?: string
forgeVersion?: string
fabricVersion?: string
}
): Promise<CreateServerResult | null> {
const effectiveId = ServerStructure.getEffectiveId(id, minecraftVersion)
const absoluteServerRoot = resolvePath(this.containerDirectory, effectiveId)
const relativeServerRoot = join(this.relativeRoot, effectiveId)
if (await pathExists(absoluteServerRoot)) {
this.logger.error('Server already exists! Aborting.')
return null
}
await mkdirs(absoluteServerRoot)
const serverMetaOpts: ServerMetaOptions = {
version: options.version
}
let modContainer: string | undefined = undefined
if (options.forgeVersion != null) {
const fms = VersionSegmentedRegistry.getForgeModStruct(
minecraftVersion,
options.forgeVersion,
absoluteServerRoot,
relativeServerRoot,
this.baseUrl,
[]
)
await fms.init()
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,
this.absoluteRoot
)
await writeFile(resolvePath(absoluteServerRoot, this.SERVER_META_FILE), JSON.stringify(serverMeta, null, 2))
const libS = new LibraryStructure(absoluteServerRoot, relativeServerRoot, this.baseUrl, minecraftVersion, [])
await libS.init()
const mfs = new MiscFileStructure(absoluteServerRoot, relativeServerRoot, this.baseUrl, minecraftVersion, [])
await mfs.init()
return {
modContainer,
libraryContainer: libS.getContainerDirectory(),
miscFileContainer: mfs.getContainerDirectory()
}
}
private async _doSeverRetrieval(): Promise<Server[]> {
const accumulator: Server[] = []
const files = await readdir(this.containerDirectory)
for (const file of files) {
const absoluteServerRoot = resolvePath(this.containerDirectory, file)
const relativeServerRoot = join(this.relativeRoot, file)
if ((await lstat(absoluteServerRoot)).isDirectory()) {
this.logger.info(`Beginning processing of ${file}.`)
const match = this.ID_REGEX.exec(file)
if (match == null) {
this.logger.warn(`Server directory ${file} does not match the defined standard.`)
this.logger.warn('All server ids must end with -<minecraft version> (ex. -1.12.2)')
continue
}
// Read server meta
const serverMeta = JSON.parse(await readFile(resolvePath(absoluteServerRoot, this.SERVER_META_FILE), 'utf-8')) as ServerMeta
const minecraftVersion = new MinecraftVersion(match[2])
const untrackedFiles: UntrackedFilesOption[] = serverMeta.untrackedFiles || []
let iconUrl: string = null!
// Resolve server icon
if(serverMeta.meta.icon && isValidUrl(serverMeta.meta.icon)) {
// Use the url they gave us.
iconUrl = serverMeta.meta.icon
} else {
this.logger.info('Server icon is either not set or not a valid URL.')
this.logger.info(`Looking for an icon file at ${absoluteServerRoot}`)
const subFiles = await readdir(absoluteServerRoot)
for (const subFile of subFiles) {
const caseInsensitive = subFile.toLowerCase()
if (caseInsensitive.endsWith('.jpg') || caseInsensitive.endsWith('.png')) {
iconUrl = new URL(join(relativeServerRoot, subFile), this.baseUrl).toString()
}
}
if (!iconUrl) {
this.logger.warn(`No icon file found for server ${file}.`)
}
}
const modules: Module[] = []
if(serverMeta.forge) {
const forgeResolver = VersionSegmentedRegistry.getForgeResolver(
minecraftVersion,
serverMeta.forge.version,
dirname(this.containerDirectory),
'',
this.baseUrl,
this.discardOutput,
this.invalidateCache
)
// Resolve forge
const forgeItselfModule = await forgeResolver.getModule()
modules.push(forgeItselfModule)
const forgeModStruct = VersionSegmentedRegistry.getForgeModStruct(
minecraftVersion,
serverMeta.forge.version,
absoluteServerRoot,
relativeServerRoot,
this.baseUrl,
untrackedFiles
)
const forgeModModules = await forgeModStruct.getSpecModel()
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)
const fileStruct = new MiscFileStructure(absoluteServerRoot, relativeServerRoot, this.baseUrl, minecraftVersion, untrackedFiles)
const fileModules = await fileStruct.getSpecModel()
modules.push(...fileModules)
accumulator.push({
id: match[1],
name: serverMeta.meta.name,
description: serverMeta.meta.description,
icon: iconUrl,
version: serverMeta.meta.version,
address: serverMeta.meta.address,
minecraftVersion: match[2],
...(serverMeta.meta.discord ? {discord: serverMeta.meta.discord} : {}),
mainServer: serverMeta.meta.mainServer,
autoconnect: serverMeta.meta.autoconnect,
javaOptions: serverMeta.meta.javaOptions,
modules
})
} else {
this.logger.warn(`Path ${file} in server directory is not a directory!`)
}
}
return accumulator
}
}
@@ -0,0 +1,7 @@
import { FileStructure } from '../FileStructure.js'
export interface SpecModelStructure<T> extends FileStructure {
getSpecModel(): Promise<T>
}
@@ -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<FabricModJson> {
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<string> {
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<string> {
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]
}
}
@@ -0,0 +1,65 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Stats } from 'fs'
import { Type, Module } from 'helios-distribution-types'
import { URL } from 'url'
import { ModuleStructure } from './Module.struct.js'
import { readdir, stat } from 'fs/promises'
import { join, resolve, sep } from 'path'
import { MinecraftVersion } from '../../../util/MinecraftVersion.js'
import { UntrackedFilesOption } from '../../../model/nebula/ServerMeta.js'
export class MiscFileStructure extends ModuleStructure {
constructor(
absoluteRoot: string,
relativeRoot: string,
baseUrl: string,
minecraftVersion: MinecraftVersion,
untrackedFiles: UntrackedFilesOption[]
) {
super(absoluteRoot, relativeRoot, 'files', baseUrl, minecraftVersion, Type.File, untrackedFiles)
}
public getLoggerName(): string {
return 'MiscFileStructure'
}
public async getSpecModel(): Promise<Module[]> {
if (this.resolvedModels == null) {
this.resolvedModels = await this.recursiveModuleScan(this.containerDirectory)
}
return this.resolvedModels
}
protected async recursiveModuleScan(dir: string): Promise<Module[]> {
let acc: Module[] = []
const subdirs = await readdir(dir)
for (const file of subdirs) {
const filePath = resolve(dir, file)
const stats = await stat(filePath)
if (stats.isDirectory()) {
acc = acc.concat(await this.recursiveModuleScan(filePath))
} else {
if(!this.FILE_NAME_BLACKLIST.includes(file)) {
acc.push(await this.parseModule(file, filePath, stats))
}
}
}
return acc
}
protected async getModuleId(name: string, path: string): Promise<string> {
return name
}
protected async getModuleName(name: string, path: string): Promise<string> {
return name
}
protected async getModuleUrl(name: string, path: string, stats: Stats): Promise<string> {
return new URL(join(this.relativeRoot, ...path.substr(this.containerDirectory.length+1).split(sep)), this.baseUrl).toString()
}
protected async getModulePath(name: string, path: string, stats: Stats): Promise<string | null> {
return path.substr(this.containerDirectory.length+1).replace(/\\/g, '/')
}
}
@@ -0,0 +1,42 @@
import { Type } from 'helios-distribution-types'
import { VersionSegmented } from '../../../util/VersionSegmented.js'
import { MinecraftVersion } from '../../../util/MinecraftVersion.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<T> extends BaseModStructure<T> implements VersionSegmented {
protected readonly EXAMPLE_MOD_ID = 'examplemod'
constructor(
absoluteRoot: string,
relativeRoot: string,
baseUrl: string,
minecraftVersion: MinecraftVersion,
untrackedFiles: UntrackedFilesOption[]
) {
super(absoluteRoot, relativeRoot, 'forgemods', baseUrl, minecraftVersion, Type.ForgeMod, untrackedFiles)
}
public abstract isForVersion(version: MinecraftVersion, libraryVersion: string): boolean
protected getClaritasExceptions(): ClaritasException[] {
return [{
exceptionName: 'optifine',
proxyMetadata: {
group: 'net.optifine'
}
}]
}
protected getClaritasType(): LibraryType {
return LibraryType.FORGE
}
protected discernResult(claritasValue: string | undefined, crudeInference: string): string {
return (claritasValue == null || claritasValue == '') ? crudeInference : claritasValue
}
}
@@ -0,0 +1,46 @@
import { ModuleStructure } from './Module.struct.js'
import { Type, TypeMetadata } from 'helios-distribution-types'
import { Stats } from 'fs'
import { join } from 'path'
import { URL } from 'url'
import { MinecraftVersion } from '../../../util/MinecraftVersion.js'
import { UntrackedFilesOption } from '../../../model/nebula/ServerMeta.js'
export class LibraryStructure extends ModuleStructure {
constructor(
absoluteRoot: string,
relativeRoot: string,
baseUrl: string,
minecraftVersion: MinecraftVersion,
untrackedFiles: UntrackedFilesOption[]
) {
super(absoluteRoot, relativeRoot, 'libraries', baseUrl, minecraftVersion, Type.Library, untrackedFiles, (name: string) => {
return name.toLowerCase().endsWith(TypeMetadata[this.type].defaultExtension!)
})
}
public getLoggerName(): string {
return 'LibraryStructure'
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async getModuleId(name: string, path: string): Promise<string> {
const inference = this.attemptCrudeInference(name)
return this.generateMavenIdentifier(this.getDefaultGroup(), inference.name, inference.version)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async getModuleName(name: string, path: string): Promise<string> {
const inference = this.attemptCrudeInference(name)
return inference.name
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async getModuleUrl(name: string, path: string, stats: Stats): Promise<string> {
return new URL(join(this.relativeRoot, name), this.baseUrl).toString()
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected async getModulePath(name: string, path: string, stats: Stats): Promise<string | null> {
return null
}
}
@@ -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<T> extends ToggleableModuleStructure {
protected modMetadata: {[property: string]: T | undefined} = {}
public async getSpecModel(): Promise<Module[]> {
// 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<string> {
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<string | null> {
return null
}
protected getModMetadata(name: string, path: string): Promise<T> {
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
}
@@ -0,0 +1,221 @@
import { minimatch } from 'minimatch'
import { createHash } from 'crypto'
import { pathExists } from 'fs-extra/esm'
import { Stats } from 'fs'
import { lstat, readdir, readFile } from 'fs/promises'
import { Artifact, Module, Type, TypeMetadata } from 'helios-distribution-types'
import { resolve } from 'path'
import { BaseModelStructure } from '../BaseModel.struct.js'
import { LibraryType } from '../../../model/claritas/ClaritasLibraryType.js'
import { ClaritasResult, ClaritasModuleMetadata } from '../../../model/claritas/ClaritasResult.js'
import { ClaritasWrapper } from '../../../util/java/ClaritasWrapper.js'
import { MinecraftVersion } from '../../../util/MinecraftVersion.js'
import { UntrackedFilesOption } from '../../../model/nebula/ServerMeta.js'
export interface ModuleCandidate {
file: string
filePath: string
stats: Stats
}
export interface ClaritasException {
exceptionName: string
proxyMetadata: ClaritasModuleMetadata
}
export abstract class ModuleStructure extends BaseModelStructure<Module> {
private readonly crudeRegex = /(.+?)-(.+).[jJ][aA][rR]/
protected readonly DEFAULT_VERSION = '0.0.0'
protected readonly FILE_NAME_BLACKLIST = [
'.gitkeep'
]
protected untrackedFilePatterns: string[] // List of glob patterns.
protected claritasResult!: ClaritasResult
constructor(
absoluteRoot: string,
relativeRoot: string,
structRoot: string,
baseUrl: string,
protected minecraftVersion: MinecraftVersion,
protected type: Type,
untrackedFiles: UntrackedFilesOption[],
protected filter?: ((name: string, path: string, stats: Stats) => boolean)
) {
super(absoluteRoot, relativeRoot, structRoot, baseUrl)
this.untrackedFilePatterns = this.determineUntrackedFiles(structRoot, untrackedFiles)
}
public async getSpecModel(): Promise<Module[]> {
if (this.resolvedModels == null) {
this.resolvedModels = await this._doModuleRetrieval(await this._doModuleDiscovery(this.containerDirectory))
}
return this.resolvedModels
}
protected getDefaultGroup(): string {
return `generated.${this.type.toLowerCase()}`
}
protected generateMavenIdentifier(group: string, id: string, version: string): string {
return `${group}:${id}:${version}@${TypeMetadata[this.type].defaultExtension}`
}
protected attemptCrudeInference(name: string): { name: string, version: string } {
const result = this.crudeRegex.exec(name)
if(result != null) {
return {
name: result[1],
version: result[2]
}
} else {
return {
name: name.substring(0, name.lastIndexOf('.')),
version: this.DEFAULT_VERSION
}
}
}
protected getClaritasGroup(path: string): string {
return this.claritasResult[path]?.group || this.getDefaultGroup()
}
protected getClaritasExceptions(): ClaritasException[] {
return []
}
protected getClaritasType(): LibraryType | null {
return null
}
protected abstract getModuleId(name: string, path: string): Promise<string>
protected abstract getModuleName(name: string, path: string): Promise<string>
protected abstract getModuleUrl(name: string, path: string, stats: Stats): Promise<string>
protected abstract getModulePath(name: string, path: string, stats: Stats): Promise<string | null>
protected async parseModule(file: string, filePath: string, stats: Stats): Promise<Module> {
const artifact: Artifact = {
size: stats.size,
url: await this.getModuleUrl(file, filePath, stats)
}
const relativeToContainer = filePath.substr(this.containerDirectory.length+1)
const untrackedByPattern = this.isFileUntracked(relativeToContainer)
if(!untrackedByPattern) {
const buf = await readFile(filePath)
artifact.MD5 = createHash('md5').update(buf).digest('hex')
} else {
this.logger.debug(`File ${relativeToContainer} is untracked. Matching pattern: ${untrackedByPattern}`)
}
const mdl: Module = {
id: await this.getModuleId(file, filePath),
name: await this.getModuleName(file, filePath),
type: this.type,
artifact
}
const pth = await this.getModulePath(file, filePath, stats)
if (pth) {
mdl.artifact.path = pth
}
return mdl
}
protected async _doModuleDiscovery(scanDirectory: string): Promise<ModuleCandidate[]> {
const moduleCandidates: ModuleCandidate[] = []
if (await pathExists(scanDirectory)) {
const files = await readdir(scanDirectory)
for (const file of files) {
const filePath = resolve(scanDirectory, file)
const stats = await lstat(filePath)
if (stats.isFile()) {
if(!this.FILE_NAME_BLACKLIST.includes(file)) {
if(this.filter == null || this.filter(file, filePath, stats)) {
moduleCandidates.push({file, filePath, stats})
}
}
}
}
}
return moduleCandidates
}
protected async invokeClaritas(moduleCandidates: ModuleCandidate[]): Promise<void> {
if(this.getClaritasType() != null) {
const claritasExecutor = new ClaritasWrapper(this.absoluteRoot)
let claritasCandidates = moduleCandidates
const exceptionCandidates: [ModuleCandidate, ClaritasException][] = []
for(const exception of this.getClaritasExceptions()) {
const exceptionCandidate = moduleCandidates.find((value) => value.file.toLowerCase().includes(exception.exceptionName))
if(exceptionCandidate != null) {
exceptionCandidates.push([exceptionCandidate, exception])
claritasCandidates = claritasCandidates.filter((value) => !value.file.toLowerCase().includes(exception.exceptionName))
}
}
this.claritasResult = await claritasExecutor.execute(
this.getClaritasType()!,
this.minecraftVersion,
claritasCandidates.map(entry => entry.filePath)
)
if(this.claritasResult == null) {
this.logger.error('Failed to process Claritas result!')
} else {
for(const [candidate, exception] of exceptionCandidates) {
this.claritasResult[candidate.filePath] = exception.proxyMetadata
}
}
}
}
protected async _doModuleRetrieval(moduleCandidates: ModuleCandidate[], options?: {
preProcess?: (candidate: ModuleCandidate) => void
postProcess?: (module: Module) => void
}): Promise<Module[]> {
const accumulator: Module[] = []
if(moduleCandidates.length > 0) {
// Invoke Claritas and attach result to class.
await this.invokeClaritas(moduleCandidates)
// Process Modules
for(const candidate of moduleCandidates) {
options?.preProcess?.(candidate)
const mdl = await this.parseModule(candidate.file, candidate.filePath, candidate.stats)
options?.postProcess?.(mdl)
accumulator.push(mdl)
}
}
return accumulator
}
protected determineUntrackedFiles(targetStructRoot: string, untrackedFileOptions?: UntrackedFilesOption[]): string[] {
if(untrackedFileOptions) {
return untrackedFileOptions
.filter(x => x.appliesTo.includes(targetStructRoot))
.reduce((acc, cur) => acc.concat(cur.patterns), [] as string[])
}
return []
}
// Will return the matching pattern, undefined if no match.
protected isFileUntracked(pathRelativeToContainer: string): string | undefined {
return this.untrackedFilePatterns.find(pattern => minimatch(pathRelativeToContainer, pattern))
}
}
@@ -0,0 +1,85 @@
import { ModuleStructure, ModuleCandidate } from './Module.struct.js'
import { Type, Module } from 'helios-distribution-types'
import { mkdirs } from 'fs-extra/esm'
import { Stats } from 'fs'
import { resolve } from 'path'
import { MinecraftVersion } from '../../../util/MinecraftVersion.js'
import { UntrackedFilesOption } from '../../../model/nebula/ServerMeta.js'
export enum ToggleableNamespace {
REQUIRED = 'required',
OPTIONAL_ON = 'optionalon',
OPTIONAL_OFF = 'optionaloff'
}
export interface ToggleableModuleCandidate extends ModuleCandidate {
namespace: ToggleableNamespace
}
export abstract class ToggleableModuleStructure extends ModuleStructure {
private activeNamespace: string | undefined
constructor(
absoluteRoot: string,
relativeRoot: string,
structRoot: string,
baseUrl: string,
minecraftVersion: MinecraftVersion,
type: Type,
untrackedFiles: UntrackedFilesOption[],
filter?: ((name: string, path: string, stats: Stats) => boolean)
) {
super(absoluteRoot, relativeRoot, structRoot, baseUrl, minecraftVersion, type, untrackedFiles, filter)
}
public async init(): Promise<void> {
await super.init()
for(const namespace of Object.values(ToggleableNamespace)) {
await mkdirs(resolve(this.containerDirectory, namespace))
}
}
public async getSpecModel(): Promise<Module[]> {
if (this.resolvedModels == null) {
const moduleCandidates: ToggleableModuleCandidate[] = []
for(const value of Object.values(ToggleableNamespace)) {
moduleCandidates.push(...(await super._doModuleDiscovery(resolve(this.containerDirectory, value))).map(val => ({...val, namespace: value})))
}
this.resolvedModels = await this._doModuleRetrieval(moduleCandidates, {
preProcess: (candidate) => {
this.activeNamespace = (candidate as ToggleableModuleCandidate).namespace
},
postProcess: (module) => {
this.getNamespaceMapper(this.activeNamespace as ToggleableNamespace)(module)
}
})
// Cleanup
this.activeNamespace = undefined
}
return this.resolvedModels
}
protected getActiveNamespace(): string {
return this.activeNamespace || ''
}
protected getNamespaceMapper(namespace: ToggleableNamespace): (x: Module) => void {
switch(namespace) {
case ToggleableNamespace.REQUIRED:
return (): void => { /* do nothing */ }
case ToggleableNamespace.OPTIONAL_ON:
return (x): void => { x.required = { value: false } }
case ToggleableNamespace.OPTIONAL_OFF:
return (x): void => { x.required = { value: false, def: false } }
}
}
}
@@ -0,0 +1,153 @@
import StreamZip from 'node-stream-zip'
import toml from 'toml'
import { capitalize } from '../../../../util/StringUtils.js'
import { VersionUtil } from '../../../../util/VersionUtil.js'
import { ModsToml } from '../../../../model/forge/ModsToml.js'
import { BaseForgeModStructure } from '../ForgeMod.struct.js'
import { MinecraftVersion } from '../../../../util/MinecraftVersion.js'
import { UntrackedFilesOption } from '../../../../model/nebula/ServerMeta.js'
export class ForgeModStructure113 extends BaseForgeModStructure<ModsToml> {
public static readonly IMPLEMENTATION_VERSION_REGEX = /^Implementation-Version: (.+)[\r\n]/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public static isForVersion(version: MinecraftVersion, libraryVersion: string): boolean {
return VersionUtil.isVersionAcceptable(version, [13, 14, 15, 16, 17, 18, 19, 20, 21])
}
constructor(
absoluteRoot: string,
relativeRoot: string,
baseUrl: string,
minecraftVersion: MinecraftVersion,
untrackedFiles: UntrackedFilesOption[]
) {
super(absoluteRoot, relativeRoot, baseUrl, minecraftVersion, untrackedFiles)
}
public isForVersion(version: MinecraftVersion, libraryVersion: string): boolean {
return ForgeModStructure113.isForVersion(version, libraryVersion)
}
public getLoggerName(): string {
return 'ForgeModStructure (1.13)'
}
protected async getModuleId(name: string, path: string): Promise<string> {
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<string> {
return capitalize((await this.getModMetadata(name, path)).mods[0].displayName)
}
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
// account for.
if (name.toLowerCase().includes('optifine')) {
// Read zip for changelog.txt
let changelogBuf: Buffer
try {
changelogBuf = zip.entryDataSync('changelog.txt')
} catch(err) {
throw new Error('Failed to read OptiFine changelog.')
}
const info = changelogBuf.toString().split('\n')[0].trim()
const version = info.split(' ')[1]
this.modMetadata[name] = ({
modLoader: 'javafml',
loaderVersion: '',
mods: [{
modId: 'optifine',
version,
displayName: 'OptiFine',
description: `OptiFine is a Minecraft optimization mod.
It allows Minecraft to run faster and look better with full support for shaders, HD textures and many configuration options.`
}]
})
return this.modMetadata[name]
}
let raw: Buffer | undefined
try {
raw = zip.entryDataSync('META-INF/mods.toml')
} catch(err) {
// ignored
}
if (raw) {
try {
const parsed = toml.parse(raw.toString()) as ModsToml
this.modMetadata[name] = parsed
} catch (err) {
this.logger.error(`ForgeMod ${name} contains an invalid mods.toml file.`)
}
} else {
this.logger.error(`ForgeMod ${name} does not contain mods.toml file.`)
}
const cRes = this.claritasResult?.[path]
if(cRes == null) {
this.logger.error(`Claritas failed to yield metadata for ForgeMod ${name}!`)
this.logger.error('Is this mod malformatted or does Claritas need an update?')
}
const claritasId = cRes?.id
const crudeInference = this.attemptCrudeInference(name)
if(this.modMetadata[name] != null) {
const x = this.modMetadata[name]
for(const entry of x.mods) {
if(entry.modId === this.EXAMPLE_MOD_ID) {
entry.modId = this.discernResult(claritasId, crudeInference.name.toLowerCase())
entry.displayName = crudeInference.name
}
if (entry.version === '${file.jarVersion}') {
let version = crudeInference.version
try {
const manifest = zip.entryDataSync('META-INF/MANIFEST.MF')
const keys = manifest.toString().split('\n')
// this.logger.debug(keys)
for (const key of keys) {
const match = ForgeModStructure113.IMPLEMENTATION_VERSION_REGEX.exec(key)
if (match != null) {
version = match[1]
}
}
this.logger.debug(`ForgeMod ${name} contains a version wildcard, inferring ${version}`)
} catch {
this.logger.debug(`ForgeMod ${name} contains a version wildcard yet no MANIFEST.MF.. Defaulting to ${version}`)
}
entry.version = version
}
}
} else {
this.modMetadata[name] = ({
modLoader: 'javafml',
loaderVersion: '',
mods: [{
modId: this.discernResult(claritasId, crudeInference.name.toLowerCase()),
version: crudeInference.version,
displayName: crudeInference.name,
description: ''
}]
})
}
return this.modMetadata[name]
}
}
@@ -0,0 +1,154 @@
import StreamZip from 'node-stream-zip'
import { capitalize } from '../../../../util/StringUtils.js'
import { VersionUtil } from '../../../../util/VersionUtil.js'
import { McModInfo } from '../../../../model/forge/McModInfo.js'
import { McModInfoList } from '../../../../model/forge/McModInfoList.js'
import { BaseForgeModStructure } from '../ForgeMod.struct.js'
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<McModInfo> {
// 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])
}
constructor(
absoluteRoot: string,
relativeRoot: string,
baseUrl: string,
minecraftVersion: MinecraftVersion,
untrackedFiles: UntrackedFilesOption[]
) {
super(absoluteRoot, relativeRoot, baseUrl, minecraftVersion, untrackedFiles)
}
public isForVersion(version: MinecraftVersion, libraryVersion: string): boolean {
return ForgeModStructure17.isForVersion(version, libraryVersion)
}
public getLoggerName(): string {
return 'ForgeModStructure (1.7)'
}
protected async getModuleId(name: string, path: string): Promise<string> {
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<string> {
return capitalize((await this.getModMetadata(name, path)).name)
}
private isMalformedVersion(version: string): boolean {
// Ex. empty, @VERSION@, ${version}
return version.trim().length === 0 || version.includes('@') || version.includes('$')
}
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.
if (name.toLowerCase().includes('optifine')) {
// Read zip for changelog.txt
let changelogBuf: Buffer
try {
changelogBuf = zip.entryDataSync('changelog.txt')
} catch(err) {
throw new Error('Failed to read OptiFine changelog.')
}
const info = changelogBuf.toString().split('\n')[0].trim()
const version = info.split(' ')[1]
this.modMetadata[name] = ({
modid: 'optifine',
name: info,
version,
mcversion: version.substring(0, version.indexOf('_'))
}) as McModInfo
return this.modMetadata[name]
}
let raw: Buffer | undefined
try {
raw = zip.entryDataSync('mcmod.info')
} catch(err) {
// ignored
}
if (raw) {
// Assuming the main mod will be the first entry in this file.
try {
const resolved = JSON.parse(raw.toString()) as (McModInfoList | McModInfo[])
if (Object.prototype.hasOwnProperty.call(resolved, 'modListVersion')) {
this.modMetadata[name] = (resolved as McModInfoList).modList[0]
} else {
this.modMetadata[name] = (resolved as McModInfo[])[0]
}
} catch (err) {
this.logger.error(`ForgeMod ${name} contains an invalid mcmod.info file.`)
}
} else {
this.logger.warn(`ForgeMod ${name} does not contain mcmod.info file.`)
}
const cRes = this.claritasResult[path]
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?')
} else {
switch(cRes.modType!) {
case ForgeModType_1_7.CORE_MOD:
this.logger.info(`CORE_MOD Discovered: ForgeMod ${name} has no @Mod annotation. Metadata inference capabilities are limited.`)
break
case ForgeModType_1_7.TWEAKER:
this.logger.info(`TWEAKER Discovered: ForgeMod ${name} has no @Mod annotation. Metadata inference capabilities may be limited.`)
break
case ForgeModType_1_7.UNKNOWN:
this.logger.error(`Jar file ${name} is not a ForgeMod. Is it a library?`)
break
}
}
const claritasId = cRes?.id
const claritasVersion = cRes?.version
const claritasName = cRes?.name
// Validate
const crudeInference = this.attemptCrudeInference(name)
if(this.modMetadata[name] != null) {
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.modMetadata[name].version != null) {
const isMalformedVersion = this.isMalformedVersion(this.modMetadata[name].version)
if(isMalformedVersion) {
x.version = this.discernResult(claritasVersion, crudeInference.version)
}
} else {
x.version = this.discernResult(claritasVersion, crudeInference.version)
}
} else {
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.modMetadata[name]
}
}
+48
View File
@@ -0,0 +1,48 @@
import { createLogger, format, transports, Logger } from 'winston'
import { SPLAT } from 'triple-beam'
import { DateTime } from 'luxon'
import { inspect } from 'util'
export class LoggerUtil {
public static getLogger(label: string): Logger {
return createLogger({
format: format.combine(
format.label(),
format.colorize(),
format.label({ label }),
format.printf(infoUnknown => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const info = infoUnknown as {[key: string | symbol]: any}
if(info[SPLAT]) {
if(info[SPLAT].length === 1 && info[SPLAT][0] instanceof Error) {
const err: Error = info[SPLAT][0]
if(info.message.length > err.message.length && info.message.endsWith(err.message)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
info.message = info.message.substring(0, info.message.length-err.message.length)
}
} else if(info[SPLAT].length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
info.message += ' ' + info[SPLAT].map((it: any) => {
if(typeof it === 'object' && it != null) {
return inspect(it, false, 4, true)
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return it
}).join(' ')
}
}
if(typeof info.message === 'object') {
info.message = inspect(info.message, false, 4, true)
}
return `[${DateTime.local().toFormat('yyyy-MM-dd TT').trim()}] [${info.level}] [${info.label}]: ${info.message}${info.stack ? `\n${info.stack}` : ''}`
})
),
level: 'debug',
transports: [
new transports.Console()
]
})
}
}
+100
View File
@@ -0,0 +1,100 @@
import { normalize } from 'path'
import { URL } from 'url'
export interface MavenComponents {
group: string
artifact: string
version: string
classifier?: string
extension: string
}
export class MavenUtil {
public static readonly ID_REGEX = /([^@:]+):([^@:]+):?([^@:]+)?:?(?:([^@:]+))?:?(?:@{1}([^@:]+))?/
public static mavenComponentsToIdentifier(
group: string,
artifact: string,
version: string,
classifier?: string,
extension?: string
): string {
return `${group}:${artifact}:${version}${classifier != null ? `:${classifier}` : ''}${extension != null ? `@${extension}` : ''}`
}
public static mavenComponentsToExtensionlessIdentifier(
group: string,
artifact: string,
version: string,
classifier?: string
): string {
return MavenUtil.mavenComponentsToIdentifier(group, artifact, version, classifier)
}
public static mavenComponentsToVersionlessIdentifier(
group: string,
artifact: string
): string {
return `${group}:${artifact}`
}
public static isMavenIdentifier(id: string): boolean {
return MavenUtil.ID_REGEX.test(id)
}
public static getMavenComponents(id: string, extension = 'jar'): MavenComponents {
if (!MavenUtil.isMavenIdentifier(id)) {
throw new Error('Id is not a maven identifier.')
}
const result = MavenUtil.ID_REGEX.exec(id)
if (result != null) {
return {
group: result[1],
artifact: result[2],
version: result[3],
classifier: result[4],
extension: result[5] || extension
}
}
throw new Error('Failed to process maven data.')
}
public static mavenIdentifierAsPath(id: string, extension = 'jar'): string {
const tmp = MavenUtil.getMavenComponents(id, extension)
return MavenUtil.mavenComponentsAsPath(
tmp.group, tmp.artifact, tmp.version, tmp.classifier, tmp.extension
)
}
public static mavenComponentsAsPath(
group: string, artifact: string, version: string, classifier?: string, extension = 'jar'
): string {
return `${group.replace(/\./g, '/')}/${artifact}/${version}/${artifact}-${version}${classifier != null ? `-${classifier}` : ''}.${extension}`
}
public static mavenIdentifierToUrl(id: string, extension = 'jar'): URL {
return new URL(MavenUtil.mavenIdentifierAsPath(id, extension))
}
public static mavenComponentsToUrl(
group: string, artifact: string, version: string, classifier?: string, extension = 'jar'
): URL {
return new URL(MavenUtil.mavenComponentsAsPath(group, artifact, version, classifier, extension))
}
public static mavenIdentifierToPath(id: string, extension = 'jar'): string {
return normalize(MavenUtil.mavenIdentifierAsPath(id, extension))
}
public static mavenComponentsAsNormalizedPath(
group: string, artifact: string, version: string, classifier?: string, extension = 'jar'
): string {
return normalize(MavenUtil.mavenComponentsAsPath(group, artifact, version, classifier, extension))
}
}
+21
View File
@@ -0,0 +1,21 @@
export class MetadataUtil {
public static completeGroupInference(partial: string, id: string): string {
const bits = partial.split('.')
let isBadTerm = true
while(isBadTerm && bits.length > 2) {
const term = bits[bits.length-1]
if(term !== id) {
isBadTerm = false
} else {
bits.pop()
}
}
return bits.join('.')
}
}
+51
View File
@@ -0,0 +1,51 @@
export class MinecraftVersion {
private static readonly MINECRAFT_VERSION_REGEX = /(\d+).(\d+).?(\d+)?/
private readonly major: number
private readonly minor: number
private readonly revision: number | undefined
constructor(version: string) {
const res = MinecraftVersion.MINECRAFT_VERSION_REGEX.exec(version)
if(res != null) {
this.major = Number(res[1])
this.minor = Number(res[2])
this.revision = res[3] != null ? Number(res[3]) : undefined
} else {
throw new Error(`${version} is not a valid minecraft version!`)
}
}
public static isMinecraftVersion(version: string): boolean {
return MinecraftVersion.MINECRAFT_VERSION_REGEX.test(version)
}
public getMajor(): number { return this.major }
public getMinor(): number { return this.minor }
public getRevision(): number | undefined { return this.revision }
public toString(): string { return `${this.major}.${this.minor}${this.revision != null ? '.' + this.revision : ''}`}
public compareTo(other: MinecraftVersion): number {
// Compare major
if (this.major !== other.major) {
return this.major - other.major
}
// Compare minor
if (this.minor !== other.minor) {
return this.minor - other.minor
}
// Compare revision (null as 0)
const thisRevision = this.revision ?? 0
const otherRevision = other.revision ?? 0
return thisRevision - otherRevision
}
public isGreaterThanOrEqualTo(other: MinecraftVersion): boolean {
return this.compareTo(other) >= 0
}
}
+77
View File
@@ -0,0 +1,77 @@
import { mkdirs, pathExists, remove } from 'fs-extra/esm'
import { writeFile } from 'fs/promises'
import { join, resolve } from 'path'
import { createGenerator } from 'ts-json-schema-generator'
import { URL, fileURLToPath } from 'url'
import { DistroMeta } from '../model/nebula/DistroMeta.js'
import { ServerMeta } from '../model/nebula/ServerMeta.js'
import { LoggerUtil } from './LoggerUtil.js'
const logger = LoggerUtil.getLogger('SchemaUtil')
const __filename = fileURLToPath(import.meta.url)
const __dirname = fileURLToPath(new URL('.', import.meta.url))
interface SchemaType {
/**
* URL to the JSON schema for this type of file.
* This is used by editors to validate and annotate the data.
*/
$schema?: string
}
export type DistroMetaSchema = DistroMeta & SchemaType
export type ServerMetaSchema = ServerMeta & SchemaType
export enum SchemaTypes {
DistroMetaSchema = 'DistroMetaSchema',
ServerMetaSchema = 'ServerMetaSchema'
}
function getSchemaFileName(typeName: string): string {
return `${typeName}.schema.json`
}
function getSchemaDirectory(absoluteRoot: string): string {
return resolve(absoluteRoot, 'schemas')
}
function getSchemaLocation(typeName: string, absoluteRoot: string): string {
return resolve(getSchemaDirectory(absoluteRoot), getSchemaFileName(typeName))
}
export function addSchemaToObject<T>(obj: T, typeName: string, absoluteRoot: string): T {
return {
$schema: new URL(`file:${getSchemaLocation(typeName, absoluteRoot)}`).href,
...obj
}
}
export async function generateSchemas(absoluteRoot: string): Promise<void> {
const selfPath = __filename.replace('dist', 'src').replace('.js', '.ts')
const schemaDir = getSchemaDirectory(absoluteRoot)
if(await pathExists(schemaDir)) {
await remove(schemaDir)
}
await mkdirs(schemaDir)
for(const typeName of Object.values(SchemaTypes)) {
logger.info(`Generating schema for ${typeName}`)
const schema = createGenerator({
tsconfig: join(__dirname, '..', '..', 'tsconfig.json'),
path: selfPath,
type: typeName
}).createSchema(typeName)
const schemaString = JSON.stringify(schema)
const schemaLoc = getSchemaLocation(typeName, absoluteRoot)
await writeFile(schemaLoc, schemaString)
logger.info(`Schema for ${typeName} saved to ${schemaLoc}`)
}
}
+15
View File
@@ -0,0 +1,15 @@
export function capitalize(str: string): string {
if (!str) {
return str
}
return str.charAt(0).toUpperCase() + str.slice(1)
}
export function isValidUrl(candidate: string): boolean {
try {
new URL(candidate)
return true
} catch (err) {
return false
}
}
+7
View File
@@ -0,0 +1,7 @@
import { MinecraftVersion } from './MinecraftVersion.js'
export interface VersionSegmented {
isForVersion(version: MinecraftVersion, libraryVersion: string): boolean
}
@@ -0,0 +1,55 @@
import { ForgeModStructure113 } from '../structure/spec_model/module/forgemod/ForgeMod113.struct.js'
import { ForgeModStructure17 } from '../structure/spec_model/module/forgemod/ForgeMod17.struct.js'
import { ForgeGradle3Adapter } from '../resolver/forge/adapter/ForgeGradle3.resolver.js'
import { ForgeGradle2Adapter } from '../resolver/forge/adapter/ForgeGradle2.resolver.js'
import { ForgeResolver } from '../resolver/forge/Forge.resolver.js'
import { BaseForgeModStructure } from '../structure/spec_model/module/ForgeMod.struct.js'
import { MinecraftVersion } from './MinecraftVersion.js'
import { UntrackedFilesOption } from '../model/nebula/ServerMeta.js'
export class VersionSegmentedRegistry {
public static readonly FORGE_ADAPTER_IMPL = [
ForgeGradle2Adapter,
ForgeGradle3Adapter
]
public static readonly FORGEMOD_STRUCT_IMPL = [
ForgeModStructure17,
ForgeModStructure113
]
public static getForgeResolver(
minecraftVersion: MinecraftVersion,
forgeVersion: string,
absoluteRoot: string,
relativeRoot: string,
baseURL: string,
discardOutput: boolean,
invalidateCache: boolean
): ForgeResolver {
for (const impl of VersionSegmentedRegistry.FORGE_ADAPTER_IMPL) {
if (impl.isForVersion(minecraftVersion, forgeVersion)) {
return new impl(absoluteRoot, relativeRoot, baseURL, minecraftVersion, forgeVersion, discardOutput, invalidateCache)
}
}
throw new Error(`No forge resolver found for Minecraft ${minecraftVersion}!`)
}
public static getForgeModStruct(
minecraftVersion: MinecraftVersion,
forgeVersion: string,
absoluteRoot: string,
relativeRoot: string,
baseUrl: string,
untrackedFiles: UntrackedFilesOption[]
): BaseForgeModStructure<unknown> {
for (const impl of VersionSegmentedRegistry.FORGEMOD_STRUCT_IMPL) {
if (impl.isForVersion(minecraftVersion, forgeVersion)) {
return new impl(absoluteRoot, relativeRoot, baseUrl, minecraftVersion, untrackedFiles)
}
}
throw new Error(`No forge mod structure found for Minecraft ${minecraftVersion}!`)
}
}
+139
View File
@@ -0,0 +1,139 @@
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 {
private static readonly logger = LoggerUtil.getLogger('VersionUtil')
public static readonly PROMOTION_TYPE = [
'recommended',
'latest'
]
public static isVersionAcceptable(version: MinecraftVersion, acceptable: number[]): boolean {
if (version.getMajor() === 1) {
return acceptable.find((element) => version.getMinor() === element) != null
}
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<left.length; i++) {
if(left[i] > right[i]) {
return true
}
}
return false
}
public static isPromotionVersion(version: string): boolean {
return VersionUtil.PROMOTION_TYPE.includes(version.toLowerCase())
}
// -------------------------------
// Forge
public static isOneDotTwelveFG2(libraryVersion: string): boolean {
const maxFG2 = [14, 23, 5, 2847]
const verSplit = libraryVersion.split('.').map(v => Number(v))
for(let i=0; i<maxFG2.length; i++) {
if(verSplit[i] > maxFG2[i]) {
return false
}
}
return true
}
public static async getPromotionIndex(): Promise<PromotionsSlim> {
const response = await got.get<PromotionsSlim>({
method: 'get',
url: 'https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json',
responseType: 'json'
})
return response.body
}
public static getPromotedVersionStrict(index: PromotionsSlim, minecraftVersion: MinecraftVersion, promotion: string): string {
const workingPromotion = promotion.toLowerCase()
return index.promos[`${minecraftVersion}-${workingPromotion}`]
}
public static async getPromotedForgeVersion(minecraftVersion: MinecraftVersion, promotion: string): Promise<string> {
const workingPromotion = promotion.toLowerCase()
const res = await VersionUtil.getPromotionIndex()
let version = res.promos[`${minecraftVersion}-${workingPromotion}`]
if (version == null) {
VersionUtil.logger.warn(`No ${workingPromotion} version found for Forge ${minecraftVersion}.`)
VersionUtil.logger.warn('Attempting to pull latest version instead.')
version = res.promos[`${minecraftVersion}-latest`]
if (version == null) {
throw new Error(`No latest version found for Forge ${minecraftVersion}.`)
}
}
return version
}
// -------------------------------
// Fabric
public static async getFabricInstallerMeta(): Promise<FabricInstallerMeta[]> {
const response = await got.get<FabricInstallerMeta[]>({
method: 'get',
url: 'https://meta.fabricmc.net/v2/versions/installer',
responseType: 'json'
})
return response.body
}
public static async getFabricLoaderMeta(): Promise<FabricLoaderMeta[]> {
const response = await got.get<FabricLoaderMeta[]>({
method: 'get',
url: 'https://meta.fabricmc.net/v2/versions/loader',
responseType: 'json'
})
return response.body
}
public static async getFabricGameMeta(): Promise<FabricVersionMeta[]> {
const response = await got.get<FabricVersionMeta[]>({
method: 'get',
url: 'https://meta.fabricmc.net/v2/versions/game',
responseType: 'json'
})
return response.body
}
public static async getFabricProfileJson(gameVersion: string, loaderVersion: string): Promise<FabricProfileJson> {
const response = await got.get<FabricProfileJson>({
method: 'get',
url: `https://meta.fabricmc.net/v2/versions/loader/${gameVersion}/${loaderVersion}/profile/json`,
responseType: 'json'
})
return response.body
}
public static async getPromotedFabricVersion(promotion: string): Promise<string> {
const stable = promotion.toLowerCase() === 'recommended'
const fabricLoaderMeta = await this.getFabricLoaderMeta()
return !stable ? fabricLoaderMeta[0].version : fabricLoaderMeta.find(({ stable }) => stable)!.version
}
}
+70
View File
@@ -0,0 +1,70 @@
import { JarExecutor } from './JarExecutor.js'
import { join, resolve } from 'path'
import { ClaritasResult } from '../../model/claritas/ClaritasResult.js'
import { MinecraftVersion } from '../MinecraftVersion.js'
import { LibraryType } from '../../model/claritas/ClaritasLibraryType.js'
import { pathExists, remove, mkdirs } from 'fs-extra/esm'
import { readFile, writeFile } from 'fs/promises'
export class ClaritasWrapper extends JarExecutor<ClaritasResult> {
private readonly WORK_DIR: string
private readonly ARG_FILE: string
private readonly OUTPUT_FILE: string
constructor(cwd: string) {
super('Claritas')
this.WORK_DIR = resolve(cwd, 'claritasWork')
this.ARG_FILE = resolve(this.WORK_DIR, 'claritasArgFile.txt')
this.OUTPUT_FILE = resolve(this.WORK_DIR, 'claritasOutput.json')
this.onCloseListeners.push(async (code) => {
if(code !== 0) {
this.logger.error('Claritas finished with non-zero exit code, ', code)
this.lastExecutionResult = undefined!
} else {
if(await pathExists(this.OUTPUT_FILE)) {
this.lastExecutionResult = JSON.parse((await readFile(this.OUTPUT_FILE)).toString('utf8')) as ClaritasResult
} else {
this.logger.error('Claritas output file not found when exit code is 0, is this a bug?')
this.lastExecutionResult = undefined!
}
}
await this.cleanOutput()
})
}
protected getJarPath(): string {
return join(process.cwd(), 'libraries', 'java', 'Claritas.jar')
}
private async writeArgFile(...programArgs: string[]): Promise<void> {
await mkdirs(this.WORK_DIR)
await writeFile(
this.ARG_FILE,
programArgs.join('\n')
)
}
public async execute(libraryType: LibraryType, mcVersion: MinecraftVersion, absoluteJarPaths: string[]): Promise<ClaritasResult> {
await this.writeArgFile(
'--absoluteJarPaths', absoluteJarPaths.join(','),
'--libraryType', libraryType,
'--mcVersion', mcVersion.toString(),
'--outputFile', this.OUTPUT_FILE,
'--previewOutput', 'true'
)
return await super.executeJar(
[`-Dclaritas.argFile=${this.ARG_FILE}`]
)
}
private async cleanOutput(): Promise<void> {
if(await pathExists(this.WORK_DIR)) {
await remove(this.WORK_DIR)
}
}
}
+55
View File
@@ -0,0 +1,55 @@
import { JavaUtil } from './JavaUtil.js'
import { Logger } from 'winston'
import { spawn } from 'child_process'
import { LoggerUtil } from '../LoggerUtil.js'
export abstract class JarExecutor<T> {
protected readonly logger: Logger
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected stdoutListeners: ((chunk: any) => void)[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected stderrListeners: ((chunk: any) => void)[] = []
protected onCloseListeners: ((code: number | null) => Promise<void>)[] = []
protected lastExecutionResult!: T
protected constructor(loggerName: string) {
this.logger = LoggerUtil.getLogger(loggerName)
}
protected abstract getJarPath(): string
protected executeJar(vmOptions: string[], ...args: string[]): Promise<T> {
this.lastExecutionResult = undefined!
return new Promise((resolve, reject) => {
const child = spawn(JavaUtil.getJavaExecutable(), [
...vmOptions,
'-jar',
this.getJarPath(),
...args
])
child.stdout.on('data', (data) => this.logger.info(data.toString('utf8').trim()))
this.stdoutListeners.forEach(l => child.stdout.on('data', l))
child.stderr.on('data', (data) => this.logger.error(data.toString('utf8').trim()))
this.stderrListeners.forEach(l => child.stderr.on('data', l))
// eslint-disable-next-line @typescript-eslint/no-misused-promises
child.on('close', async code => {
this.logger.info('Exited with code', code)
for(const l of this.onCloseListeners) {
await l(code)
}
resolve(this.lastExecutionResult)
})
child.on('error', (err) => {
this.logger.info('Error during process execution', err)
reject(err)
})
})
}
}
+7
View File
@@ -0,0 +1,7 @@
export class JavaUtil {
public static getJavaExecutable(): string {
return process.env.JAVA_EXECUTABLE!
}
}
@@ -0,0 +1,30 @@
import { join } from 'path'
import { JarExecutor } from './JarExecutor.js'
export class PackXZExtractWrapper extends JarExecutor<void> {
constructor() {
super('PackXZExtract')
}
protected getJarPath(): string {
return join(process.cwd(), 'libraries', 'java', 'PackXZExtract.jar')
}
protected execute(command: string, paths: string[]): Promise<void> {
return super.executeJar([], command, paths.join(','))
}
public extractUnpack(paths: string[]): Promise<void> {
return this.execute('-packxz', paths)
}
public extract(paths: string[]): Promise<void> {
return this.execute('-xz', paths)
}
public unpack(paths: string[]): Promise<void> {
return this.execute('-pack', paths)
}
}