diff --git a/README.md b/README.md index dfd9f3c..4bd9e9b 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,22 @@ Options: --- +#### Generate Server from CurseForge Modpack + +Generate an new server in the root directory, including files and mods from an existing CurseForge modpack. + +`generate server-curseforge ` + +The cursforge modpack must be downloaded as a zip and placed into `${ROOT}/modpacks/curseforge`. Pass the name of the modpack as the `` argument. + +> +> Example Usage +> +> `generate server-curseforge WesterosCraft-Prod The+WesterosCraft+Modpack-2.1.6.zip` +> + +--- + #### Generate Distribution Generate a distribution file from the root file structure. diff --git a/src/index.ts b/src/index.ts index a01169e..4a844d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ 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() @@ -134,6 +135,7 @@ const initRootCommand: CommandModule = { 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) @@ -203,7 +205,53 @@ const generateServerCommand: CommandModule = { forgeVersion: argv.forge as string } ) + } +} +const generateServerCurseForgeCommand: CommandModule = { + command: 'server-curseforge ', + 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 + 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) + } } } @@ -286,6 +334,7 @@ const generateCommand: CommandModule = { describe: 'Base generate command.', builder: (yargs) => { return yargs + .command(generateServerCurseForgeCommand) .command(generateServerCommand) .command(generateDistroCommand) .command(generateSchemasCommand) diff --git a/src/model/nebula/ServerMeta.ts b/src/model/nebula/ServerMeta.ts index 2446724..0cd3602 100644 --- a/src/model/nebula/ServerMeta.ts +++ b/src/model/nebula/ServerMeta.ts @@ -13,6 +13,7 @@ export interface UntrackedFilesOption { } export interface ServerMetaOptions { + version?: string forgeVersion?: string } @@ -20,7 +21,7 @@ export function getDefaultServerMeta(id: string, version: string, options?: Serv const servMeta: ServerMeta = { meta: { - version: '1.0.0', + version: options?.version ?? '1.0.0', name: `${id} (Minecraft ${version})`, description: `${id} Running Minecraft ${version}`, address: 'localhost:25565', diff --git a/src/parser/CurseForgeParser.ts b/src/parser/CurseForgeParser.ts new file mode 100644 index 0000000..392bb0a --- /dev/null +++ b/src/parser/CurseForgeParser.ts @@ -0,0 +1,114 @@ +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 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 { + await mkdirs(this.modpackDir) + } + + public async getModpackManifest(): Promise { + + const zip = new StreamZip.async({ file: this.zipPath }) + return JSON.parse((await zip.entryData('manifest.json')).toString('utf8')) + } + + public async enrichServer(createServerResult: CreateServerResult, manifest: CurseForgeManifest): Promise { + 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.forgeModContainer) { + const requiredPath = resolve(createServerResult.forgeModContainer, ToggleableNamespace.REQUIRED) + const optionalPath = resolve(createServerResult.forgeModContainer, ToggleableNamespace.OPTIONAL_ON) + + // Download mods + for(const file of manifest.files) { + log.debug(`Processing - Mod: ${file.projectID}, File: ${file.fileID}`) + const modInfo = (await CurseForgeParser.cfClient.get(`mods/${file.projectID}/files/${file.fileID}`)).body + log.debug(`Downloading ${modInfo.data.fileName}`) + + const isJar = modInfo.data.fileName.toLowerCase().endsWith('jar') + + const downloadStream = got.stream(modInfo.data.downloadUrl) + const dir = isJar ? file.required ? requiredPath : optionalPath : createServerResult.miscFileContainer + const fileWriterStream = createWriteStream(join(dir, modInfo.data.fileName)) + + await pipeline(downloadStream, fileWriterStream) + } + } + } + +} \ No newline at end of file diff --git a/src/structure/BaseFileStructure.ts b/src/structure/BaseFileStructure.ts index 5c9dfac..0790390 100644 --- a/src/structure/BaseFileStructure.ts +++ b/src/structure/BaseFileStructure.ts @@ -23,6 +23,10 @@ export abstract class BaseFileStructure implements FileStructure { mkdirs(this.containerDirectory) } + public getContainerDirectory(): string { + return this.containerDirectory + } + public abstract getLoggerName(): string } diff --git a/src/structure/spec_model/Server.struct.ts b/src/structure/spec_model/Server.struct.ts index b36d743..24060bd 100644 --- a/src/structure/spec_model/Server.struct.ts +++ b/src/structure/spec_model/Server.struct.ts @@ -11,6 +11,12 @@ import { LibraryStructure } from './module/Library.struct.js' import { MinecraftVersion } from '../../util/MinecraftVersion.js' import { addSchemaToObject, SchemaTypes } from '../../util/SchemaUtil.js' +export interface CreateServerResult { + forgeModContainer?: string + libraryContainer: string + miscFileContainer: string +} + export class ServerStructure extends BaseModelStructure { private readonly ID_REGEX = /(.+-(.+)$)/ @@ -36,25 +42,33 @@ export class ServerStructure extends BaseModelStructure { 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 } - ): Promise { - const effectiveId = `${id}-${minecraftVersion}` + ): Promise { + 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 + return null } await mkdirs(absoluteServerRoot) - const serverMetaOpts: ServerMetaOptions = {} + const serverMetaOpts: ServerMetaOptions = { + version: options.version + } + let forgeModContainer: string | undefined = undefined if (options.forgeVersion != null) { const fms = VersionSegmentedRegistry.getForgeModStruct( @@ -66,6 +80,7 @@ export class ServerStructure extends BaseModelStructure { [] ) await fms.init() + forgeModContainer = fms.getContainerDirectory() serverMetaOpts.forgeVersion = options.forgeVersion } @@ -82,6 +97,12 @@ export class ServerStructure extends BaseModelStructure { const mfs = new MiscFileStructure(absoluteServerRoot, relativeServerRoot, this.baseUrl, minecraftVersion, []) await mfs.init() + return { + forgeModContainer, + libraryContainer: libS.getContainerDirectory(), + miscFileContainer: mfs.getContainerDirectory() + } + } private async _doSeverRetrieval(): Promise { diff --git a/src/util/VersionSegmentedRegistry.ts b/src/util/VersionSegmentedRegistry.ts index f8899ff..d045c3d 100644 --- a/src/util/VersionSegmentedRegistry.ts +++ b/src/util/VersionSegmentedRegistry.ts @@ -14,7 +14,7 @@ export class VersionSegmentedRegistry { ForgeGradle3Adapter ] - public static readonly FORGEMOD_STRUCT_IML = [ + public static readonly FORGEMOD_STRUCT_IMPL = [ ForgeModStructure17, ForgeModStructure113 ] @@ -44,7 +44,7 @@ export class VersionSegmentedRegistry { baseUrl: string, untrackedFiles: UntrackedFilesOption[] ): BaseForgeModStructure { - for (const impl of VersionSegmentedRegistry.FORGEMOD_STRUCT_IML) { + for (const impl of VersionSegmentedRegistry.FORGEMOD_STRUCT_IMPL) { if (impl.isForVersion(minecraftVersion, forgeVersion)) { return new impl(absoluteRoot, relativeRoot, baseUrl, minecraftVersion, untrackedFiles) }