Add command to generate server from CurseForge modpack.

This commit is contained in:
Daniel Scalzi
2023-03-18 17:26:35 -04:00
parent 71c2e9baa0
commit 6b2d9edf26
7 changed files with 212 additions and 7 deletions

View File

@@ -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 <id> <zipFile>`
The cursforge modpack must be downloaded as a zip and placed into `${ROOT}/modpacks/curseforge`. Pass the name of the modpack as the `<zipFile>` 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.

View File

@@ -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 <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
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)

View File

@@ -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',

View File

@@ -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<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'))
}
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.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<CurseForgeModFileResponse>(`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)
}
}
}
}

View File

@@ -23,6 +23,10 @@ export abstract class BaseFileStructure implements FileStructure {
mkdirs(this.containerDirectory)
}
public getContainerDirectory(): string {
return this.containerDirectory
}
public abstract getLoggerName(): string
}

View File

@@ -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<Server> {
private readonly ID_REGEX = /(.+-(.+)$)/
@@ -36,25 +42,33 @@ export class ServerStructure extends BaseModelStructure<Server> {
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<void> {
const effectiveId = `${id}-${minecraftVersion}`
): 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
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<Server> {
[]
)
await fms.init()
forgeModContainer = fms.getContainerDirectory()
serverMetaOpts.forgeVersion = options.forgeVersion
}
@@ -82,6 +97,12 @@ export class ServerStructure extends BaseModelStructure<Server> {
const mfs = new MiscFileStructure(absoluteServerRoot, relativeServerRoot, this.baseUrl, minecraftVersion, [])
await mfs.init()
return {
forgeModContainer,
libraryContainer: libS.getContainerDirectory(),
miscFileContainer: mfs.getContainerDirectory()
}
}
private async _doSeverRetrieval(): Promise<Server[]> {

View File

@@ -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)
}