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
+3
View File
@@ -0,0 +1,3 @@
github: dscalzi
patreon: dscalzi
custom: ['https://www.paypal.me/dscalzi']
+91
View File
@@ -0,0 +1,91 @@
# Build directory
dist
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
+1
View File
@@ -0,0 +1 @@
22
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019-2020 Daniel D. Scalzi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+361
View File
@@ -0,0 +1,361 @@
# Nebula
Generate a distribution.json for Helios. Documentation on this format can be found [here][distro.md].
## Requirements
* Node.js 22
* Java 8+ (https://adoptium.net/)
* This is required to run the forge installer, process [XZ](https://tukaani.org/xz/format.html) files, and run bytecode analysis on mod files.
* Although 1.17 requires Java 16, the forge installer works with Java 8.
## Setup
1. Clone the repository
2. Install the dependencies (`npm i`)
3. Create a file called [`.env`][dotenvnpm] at the root directory of the cloned folder and set the required values.
Example
```properties
JAVA_EXECUTABLE=C:\Program Files\Eclipse Adoptium\jdk-17.0.12.7-hotspot\bin\java.exe
ROOT=D:\TestRoot2
BASE_URL=http://localhost:8080/
HELIOS_DATA_FOLDER=C:\Users\user\AppData\Roaming\Helios Launcher
```
## Usage
Nebula is still being developed. Usage may change, but it has remained stable for some time now.
#### TL;DR (Too Long; Didn't Read) Usage
This is the barebones overall usage. Please read the rest of this document.
* Follow the setup instructions above.
* Run the `init root` command.
* Generate servers using the `g server` command.
* Put all files in their respective folders (documented below).
* Generate the distribution using the `g distro` command.
* When in doubt, reread this document and then ask on Discord.
## Commands
Commands will be documented here. You can run any command with the `--help` option to view more information.
### Command Usage
This explains how to run the commands listed below. There are a few ways to run commands, pick your preferred method.
Example: To run `init root`, you would do `npm run start -- init root`.
*Recommended*
* Run **`npm run start -- <COMMAND>`**
* *Why is this recommended? This command will compile the source code first.*
*Other*
* Build the project using **`npm run build`**
* Run **`node dist/index.js <COMMAND>`** OR **`npm run faststart -- <COMMAND>`**
* `faststart` is an alias to run the main file without building.
> ***Note:***
> - ***If you modify any files, you will have to rebuild the project.***
> - ***After pulling from git, you will have to rebuild the project.***
>
> ***npm start does this automatically.***
---
### Init
Init commands are used for initializing empty file structures.
Aliases: [`init`, `i`]
__*Subcommands*__
---
#### Init Root
Generate an empty standard file structure. JSON schemas will also be generated.
`init root`
---
### Generate
Generate commands are used for generation.
Aliases: [`generate`, `g`]
__*SubCommands*__
---
#### Generate Server
Generate an new server in the root directory. Options are provided to include forge in the generated server.
`generate server <id> <version> <options>`
Options:
* `--forge <string>` Specify forge version. This is WITHOUT the minecraft version (ex. 14.23.5.2847)
* OPTIONAL (default: null)
* If not provided forge will not be enabled.
* You can provide either `latest` or `recommended` to use the latest/recommended version of forge.
* `--fabric <string>` Specify fabric loader version
* OPTIONAL (default: null)
* If not provided fabric will not be enabled.
* You can provide either `latest` or `recommended` to use the latest/recommended version of fabric.
> [!NOTE]
> Forge and fabric cannot be used together on the same server. This command will fail if both are provided.
>
> Example Usage
>
> `generate server Test1 1.12.2 --forge 14.23.5.2847`
>
---
#### 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.
`generate distro [name]`
Arguments:
* `name` The name of the distribution file.
* OPTIONAL (default: `distribution`)
Options:
* `--installLocal` Have the application install a copy of the generated distribution to the Helios data folder.
* OPTIONAL (default: false)
* This is useful to easily test the new distribution.json in dev mode on Helios.
* Tip: Set name to `distribution_dev` when using this option.
* `--discardOutput` Delete cached output after it is no longer required. May be useful if disk space is limited.
* OPTIONAL (default: false)
* `--invalidateCache` Invalidate and delete existing caches as they are encountered. Requires fresh cache generation.
* OPTIONAL (default: false)
#### Notes
As of Forge 1.13, the installer must be run to generate required files. The installer output is cached by default. This is done to speed up subsequent builds and allow Nebula to be run as a CI job. Options are provided to discard installer output (no caching) and invalidate caches (delete cached output and require fresh generation). To invalidate only a single version cache, manually delete the cached folder.
>
> Example Usage
>
> `generate distro`
>
> `generate distro distribution_dev --installLocal`
>
---
#### Generate Schemas
Generate the JSON schemas used by Nebula's internal types (ex. Distro Meta and Server Meta schemas). This command should be used to update the schemas when a change to Nebula requires it. You may need to reopen your editor for the new JSON schemas to take effect.
`generate schemas`
---
### Latest Forge
Get the latest version of Forge.
`latest-forge <version>`
---
### Recommended Forge
Get the recommended version of Forge. If no recommended build is available, it will pull the latest version.
`recommended-forge <version>`
---
## File Structure Setup (Tentative)
Nebula aims to provide users with an information preserving structure for storing files. The application will use this structure to generate a full distribution.json for HeliosLauncher. For coherency, the distribution structure is modularized and encapsulated by a directory pattern. These encapsulations will be explained below. They can be generated manually or by using the commands documented above.
### Distribution Encapsulation
The distribution object is represented by the main root. All command output will be stored in this directory. The structure is documented below.
Ex.
* `TestRoot` The root directory which encapsulates the distribution.
* `servers` All server files are stored in this directory.
### Server Encapsulation
Server objects are encapsulated in their own folders. The name of the server's folder contains both its id and version.
Ex.
* `servers`
* `TestServer-1.12.2` A server with id TestServer set to version 1.12.2.
The server directory will contain files pertaining to that server.
Ex.
* `TestServer-1.12.2`
* `files` All modules of type `File`.
* `libraries` All modules of type `Library`
* `forgemods` All modules of type `ForgeMod`.
* `fabricmods` All modules of type `FabricMod`.
* This is a directory of toggleable modules. See the note below.
* `TestServer-1.12.2.png` Server icon file.
#### Setting the Server Icon
You can set the server icon in two ways.
1. __*(Preferred)*__ Place your server icon in the root server directory as shown in the example above. Only jpg and png files will be looked at. The name of the file does not matter.
2. Paste the **full** URL to your server icon in the servermeta.json for your server. It is highly recommended to only use files that are hosted on your own servers.
The value in servermeta.json will always be used so long as it is not empty and is a [valid url](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL). If it is empty or an [invalid url](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL), then the first method will be used.
#### Toggleable Modules
If a directory represents a toggleable mod, it will have three subdirectories. You must filter your files into these three.
* `required` Modules that are required.
* `optionalon` Modules that are optional and enabled by default.
* `optionaloff` Modules that are optional and disabled by default.
### Additional Metadata
To preserve metadata that cannot be inferred via file structure, two files exist. Default values will be generated when applicable. Customize to fit your needs. These values should be self explanatory. If further details are required, see the [distribution.json specification document][distro.md].
#### ${ROOT}/meta/distrometa.json
Represents the additiona metadata on the distribution object.
A JSON schema is provided to assist editing this file. It should automatically be referenced when the default file is generated.
Sample:
```json
{
"$schema": "file:///${ROOT}/schemas/DistroMetaSchema.schema.json",
"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>"
}
}
}
```
#### servers/${YOUR_SERVER}/servermeta.json
Represents the additional metadata on the server object (for a YOUR_SERVER).
A JSON schema is provided to assist editing this file. It should automatically be referenced when the default file is generated.
Sample:
```json
{
"$schema": "file:///${ROOT}/schemas/ServerMetaSchema.schema.json",
"meta": {
"version": "1.0.0",
"name": "Test (Minecraft 1.12.2)",
"description": "Test Running Minecraft 1.12.2 (Forge v14.23.5.2854)",
"icon": "How to set the server icon: https://github.com/dscalzi/Nebula#setting-the-server-icon",
"address": "localhost:25565",
"discord": {
"shortId": "1.12.2 Test Server",
"largeImageText": "1.12.2 Test Server",
"largeImageKey": "seal-circle"
},
"mainServer": false,
"autoconnect": true
},
"forge": {
"version": "14.23.5.2854"
},
"untrackedFiles": []
}
```
#### Untracked Files
Untracked files is optional. MD5 hashes will not be generated for files matching the provided glob patterns. The launcher will not validate/update files without MD5 hashes.
```json
{
"untrackedFiles": [
{
"appliesTo": ["files"],
"patterns": [
"config/*.cfg",
"config/**/*.yml"
]
}
]
}
```
In the above example, all files of type `cfg` in the config directory will be untracked. Additionally, all files of type `yml` in the config directory and its subdirectories will be untracked. You can tweak these patterns to fit your needs, this is purely an example. The patterns will only be applied to the folders specified in `appliesTo`. As an example, valid values include `files`, `forgemods`, `libraries`, etc.
```json
{
"untrackedFiles": [
{
"appliesTo": ["files"],
"patterns": [
"config/*.cfg",
"config/**/*.yml"
]
},
{
"appliesTo": ["forgemods"],
"patterns": [
"optionalon/*.jar"
]
}
]
}
```
Another example where all `optionalon` forgemods are untracked. **Untracking mods is NOT recommended. This is an example ONLY.**
### Note on JSON Schemas
The `$schema` property in a JSON file is a URL to a JSON schema file. This property is optional. Nebula provides schemas for internal types to make editing the JSON easier. Editors, such as Visual Studio Code, will use this schema file to validate the data and show useful information, like property descriptions. Valid properties will also be autocompleted. For detailed information, you may view the [JSON Schema Website][jsonschemawebsite].
Nebula will store JSON schemas in `${ROOT}/schemas`. This is so that they will always be in sync with your local version of Nebula. They will initially be generated by the `init root` command. To update the schemas, you can run the `generate schemas` command.
[dotenvnpm]: https://www.npmjs.com/package/dotenv
[distro.md]: https://github.com/dscalzi/HeliosLauncher/blob/master/docs/distro.md
[jsonschemawebsite]: https://json-schema.org/
+112
View File
@@ -0,0 +1,112 @@
# SurviePote — Atlas (Nebula) : configuration plug-and-play
Génère `root/distribution.json` pour le launcher Helios (SurviePote, MC 26.3, Fabric Loader 0.20.0).
Le launcher lit la distribution à `https://mcpv.ihorizon.org/distribution.json`
(cf `launcher/app/assets/js/distromanager.js`).
## 1. Arborescence `root/`
```
atlas/
├── .env # ROOT=./root, BASE_URL, JAVA_EXECUTABLE (voir §2)
├── SURVIEPOTE.md # ce fichier
└── root/ # web root à servir (TNDev: NE PAS committer les .jar, voir .gitignore)
├── distribution.json # fichier généré, servi à https://mcpv.ihorizon.org/distribution.json
├── meta/
│ └── distrometa.json # rss + discord client (globaux)
├── schemas/ # schémas JSON régénérés par `init root` / `g schemas`
├── modpacks/curseforge/ # vide (import CurseForge, inutilisé)
└── servers/
└── SurviePote-26.3/
├── servermeta.json # nom, adresse, discord, main/autoconnect
├── SurviePote-26.3.png # icône serveur (copiée de launcher/build/icon.png)
├── fabricmods/
│ ├── required/ # 31 mods partagés (client+serveur), TOUS requis
│ ├── optionalon/ # vide
│ └── optionaloff/ # vide
├── files/
│ ├── resourcepacks/FallPack_26.3_v3.1.zip # module File, chemin préservé
│ └── shaderpacks/Revelation-dev.zip # module File, chemin préservé
└── libraries/ # vide (libs Fabric résolues à la volée via FabricResolver)
```
## 2. `.env`
```properties
JAVA_EXECUTABLE=/run/current-system/sw/bin/java
ROOT=./root
BASE_URL=https://mcpv.ihorizon.org/
HELIOS_DATA_FOLDER=
# DEV ALTERNATIVE (décommenter en dev local):
# BASE_URL=http://localhost:8080/
```
- `BASE_URL` **doit** finir par `/` : c'est le préfixe des URLs dans `distribution.json`.
En dev, passer à `http://localhost:8080/` puis régénérer (`g distro`).
- Écart volontaire : la consigne disait `/usr/bin/java`, inexistant sur NixOS ;
on pointe vers le binaire réel (`readlink -f $(which java)`).
- `HELIOS_DATA_FOLDER` vide = pas de `--installLocal` (pas de dossier Helios local).
## 3. Re-générer
```bash
cd atlas
npm install # une fois (Node 22 requis, testé en 24.14.0 OK)
npm run start -- init root # (re)crée root/meta, root/schemas, root/servers
npm run faststart -- g server SurviePote 26.3 --fabric 0.20.0 # un seul serveur
# ... peupler fabricmods/ + files/ (voir §4), éditer servermeta.json ...
npm run faststart -- g distro # (re)génère root/distribution.json
```
`npm run start` rebuild avant d'exécuter ; `faststart` réutilise `dist/` (plus rapide).
Ne **jamais** utiliser `--installLocal` ici (pas de Helios local).
## 4. Où sont mods / files, tri, choix d'implémentation
- Source mods : `~/.local/share/PrismLauncher/instances/26.3/minecraft/mods/*.jar`
(copie physique par `cp`, pas de symlink).
- `fabricmods/required/` (31) : tous les mods **sauf 15 client-only purs** (même logique
que `server/`) : sodium, iris, continuity, dynamic-fps, xaerominimap, xaeroworldmap,
skinlayers3d, waveycapes, capes, chat_heads, morechathistory, fast-ip-ping, modmenu,
appleskin, vanillarice.
- Tri : Nebula trie les mods par nom de fichier (`BaseModStructure.getSpecModel`),
donc « tri mods auto » = natif, rien à faire.
- **Choix resourcepack/shader** : `src/structure/spec_model/module/File.struct.ts`
(`MiscFileStructure extends ModuleStructure`, scan récursif, pas de namespaces
`required/optionalon/optionaloff`) — contrairement à `FabricModStructure`
(cf `ToggleableModule.struct.ts`). Les modules `File` sont donc **toujours requis**.
Le choix user « optionnels ON » n'est **pas exprimable** en natif : les mettre en
`fabricmods/optionalon` casserait le typage (`Type.FabricMod`, pas de `artifact.path`).
→ FallPack + Revelation sont en `files/resourcepacks/` et `files/shaderpacks/`
(chemins relatifs préservés dans `artifact.path`, URLs sous `.../files/...`).
Pour du vrai optionnel, il faudrait patcher Nebula (rendre `MiscFileStructure`
toggleable) ou gérer ça côté launcher.
## 5. Servir via HTTP
Le web root = `atlas/root/` (les URLs sont relatives à `BASE_URL`).
- Dev : `cd atlas/root && python3 -m http.server 8080`
(+ `.env` → `BASE_URL=http://localhost:8080/`, régénérer).
- Prod (TNDev) : servir `atlas/root/` depuis la racine `https://mcpv.ihorizon.org/`
(nginx `root` ou copie du contenu), de sorte que
`https://mcpv.ihorizon.org/distribution.json` réponde.
## 6. TODO restants
- [ ] Discord Rich Presence : `root/meta/distrometa.json` → `discord.clientId` vide
(+ `smallImageText/Key` vides) — mettre le vrai clientId ou supprimer l'objet.
- [ ] RSS : `root/meta/distrometa.json` → `rss` vide — mettre l'URL du flux ou laisser vide.
- [ ] Discord serveur : placeholder `SurviePote / seal-circle` dans `servermeta.json` —
valider `largeImageKey` (asset du portail Discord).
- [ ] Icône : `SurviePote-26.3.png` = copie de `launcher/build/icon.png` (143 K) ;
le champ `icon` de `servermeta.json` garde le texte d'aide (URL invalide),
Nebula utilise donc le PNG (voir `Server.struct.ts` : fallback fichier).
Remplacer par une vraie icône si besoin.
- [ ] `BASE_URL` finale : vérifier `https://mcpv.ihorizon.org/` + déploiement du contenu
de `root/` à la racine web, puis tester le launcher en prod.
- [ ] Client-only exclus (sodium, iris, xaero…) : si les clients doivent les recevoir
via le launcher, les ajouter en `fabricmods/optionalon/` (actuellement exclus,
logique serveur).
- [ ] Ne pas committer les binaires (`root/servers/**`, `root/distribution.json` à
décider) — ajouter au `.gitignore` si besoin.
+57
View File
@@ -0,0 +1,57 @@
// @ts-check
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import stylistic from '@stylistic/eslint-plugin';
export default defineConfig(
{
ignores: ['**/dist/**', 'node_modules', 'eslint.config.mjs'],
},
eslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ['**/*.ts', '**/*.mts', '**/*.cts', '**/*.tsx'],
plugins: {
'@stylistic': stylistic,
'@typescript-eslint': tseslint.plugin,
},
rules: {
'@stylistic/semi': ['error', 'never'],
'@stylistic/quotes': ['error', 'single'],
'@stylistic/indent': ['error', 4],
'@stylistic/member-delimiter-style': ['error', {
multiline: {
delimiter: 'none',
requireLast: false
},
singleline: {
delimiter: 'comma',
requireLast: false
}
}],
'@typescript-eslint/consistent-indexed-object-style': 'off', // Don't need to enforce types be Record
'@typescript-eslint/explicit-function-return-type': ['warn'], // Warn if no return type is set
'@typescript-eslint/no-unsafe-call': 'off', // There are instances where internal types are ambigious but it doesnt matter
'@typescript-eslint/no-unsafe-member-access': 'off', // Method calls, above reason
"@typescript-eslint/no-unused-vars": ["error", { // Allow unused caught errors
"caughtErrors": "none"
}],
'@typescript-eslint/prefer-nullish-coalescing': 'off', // Logical OR is fine depending on the circumstance
'@typescript-eslint/prefer-promise-reject-errors': 'off', // We reject with errors passed through event listeners that are untyped
'@typescript-eslint/require-await': 'off', // This is literally broken
'@typescript-eslint/restrict-template-expressions': 'off' // We make use of template expressions on objects
},
}
);
+2585
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
{
"name": "nebula",
"version": "0.1.0",
"description": "Utility package to generate a distribution.json for Helios.",
"type": "module",
"exports": "./dist/index.js",
"scripts": {
"clean": "rimraf dist",
"tsc": "tsc",
"build": "npm run clean && npm run tsc",
"start": "npm run build && node dist/index.js",
"fs": "node dist/index.js",
"faststart": "node dist/index.js",
"lint": "eslint ."
},
"repository": {
"type": "git",
"url": "git+https://github.com/dscalzi/Nebula.git"
},
"keywords": [
"helios",
"nebula",
"distribution"
],
"author": "Daniel Scalzi",
"license": "MIT",
"bugs": {
"url": "https://github.com/dscalzi/Nebula/issues"
},
"homepage": "https://github.com/dscalzi/Nebula#readme",
"devDependencies": {
"@stylistic/eslint-plugin": "^5.6.1",
"@types/fs-extra": "^11.0.4",
"@types/luxon": "^3.7.1",
"@types/node": "^20.19.27",
"@types/triple-beam": "^1.3.5",
"@types/yargs": "^17.0.35",
"eslint": "^9.39.2",
"rimraf": "^6.1.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.51.0"
},
"dependencies": {
"dotenv": "^17.2.3",
"fs-extra": "^11.3.3",
"got": "^14.6.5",
"helios-distribution-types": "^1.3.0",
"luxon": "^3.7.2",
"minimatch": "^10.1.1",
"node-stream-zip": "^1.15.0",
"toml": "^3.0.0",
"triple-beam": "^1.4.1",
"ts-json-schema-generator": "^2.4.0",
"winston": "^3.19.0",
"yargs": "^18.0.0"
}
}
+432
View File
@@ -0,0 +1,432 @@
{
"version": "1.0.0",
"servers": [
{
"id": "SurviePote-26.3",
"name": "SurviePote",
"description": "Serveur survie moddée entre potes (Minecraft 26.3, Fabric).",
"icon": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/SurviePote-26.3.png",
"version": "1.0.0",
"address": "mcpv.ihorizon.org:25565",
"minecraftVersion": "26.3",
"mainServer": true,
"autoconnect": true,
"modules": [
{
"id": "net.fabricmc:fabric-loader:0.20.0",
"name": "Fabric (fabric-loader)",
"type": "Fabric",
"artifact": {
"size": 1988875,
"MD5": "e09c87e1337e0c41f9c37d546a8faa5d",
"url": "https://mcpv.ihorizon.org/repo/lib/net/fabricmc/fabric-loader/0.20.0/fabric-loader-0.20.0.jar"
},
"subModules": [
{
"id": "26.3-fabric-0.20.0",
"name": "Fabric (version.json)",
"type": "VersionManifest",
"artifact": {
"size": 2768,
"MD5": "7867623da046314a5ba913ab06340387",
"url": "https://mcpv.ihorizon.org/repo/versions/26.3-fabric-0.20.0/26.3-fabric-0.20.0.json"
}
},
{
"id": "org.ow2.asm:asm:9.11",
"name": "Fabric (asm)",
"type": "Library",
"artifact": {
"size": 131056,
"MD5": "67fe79a0c88ca77bf89ad0897cd0ffbc",
"url": "https://mcpv.ihorizon.org/repo/lib/org/ow2/asm/asm/9.11/asm-9.11.jar"
}
},
{
"id": "org.ow2.asm:asm-analysis:9.11",
"name": "Fabric (asm-analysis)",
"type": "Library",
"artifact": {
"size": 39663,
"MD5": "1f0307062449c4990dd8f772f6fb452e",
"url": "https://mcpv.ihorizon.org/repo/lib/org/ow2/asm/asm-analysis/9.11/asm-analysis-9.11.jar"
}
},
{
"id": "org.ow2.asm:asm-commons:9.11",
"name": "Fabric (asm-commons)",
"type": "Library",
"artifact": {
"size": 77917,
"MD5": "2771e9f87a5c9caf6abab10c3e995ea8",
"url": "https://mcpv.ihorizon.org/repo/lib/org/ow2/asm/asm-commons/9.11/asm-commons-9.11.jar"
}
},
{
"id": "org.ow2.asm:asm-tree:9.11",
"name": "Fabric (asm-tree)",
"type": "Library",
"artifact": {
"size": 51954,
"MD5": "20ed5cf0fd4b739262b2c62303ebf32f",
"url": "https://mcpv.ihorizon.org/repo/lib/org/ow2/asm/asm-tree/9.11/asm-tree-9.11.jar"
}
},
{
"id": "org.ow2.asm:asm-util:9.11",
"name": "Fabric (asm-util)",
"type": "Library",
"artifact": {
"size": 98078,
"MD5": "4e6556015e270658fc05bc2823f2da08",
"url": "https://mcpv.ihorizon.org/repo/lib/org/ow2/asm/asm-util/9.11/asm-util-9.11.jar"
}
},
{
"id": "net.fabricmc:sponge-mixin:0.17.5+mixin.0.8.7",
"name": "Fabric (sponge-mixin)",
"type": "Library",
"artifact": {
"size": 1551593,
"MD5": "5fd69fb11801c52d88a854ffe05eb839",
"url": "https://mcpv.ihorizon.org/repo/lib/net/fabricmc/sponge-mixin/0.17.5+mixin.0.8.7/sponge-mixin-0.17.5+mixin.0.8.7.jar"
}
}
]
},
{
"id": "dev.architectury.utils:architectury:22.0.5@jar",
"name": "Architectury",
"type": "FabricMod",
"artifact": {
"size": 622542,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/architectury-fabric-22.0.5.jar",
"MD5": "907df9cd4c2d9df0d95ab4f649b0dafd"
}
},
{
"id": "net.blay09.mods:balm:26.3.0.3@jar",
"name": "Balm",
"type": "FabricMod",
"artifact": {
"size": 1213047,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/balm-fabric-26.3-26.3.0.3.jar",
"MD5": "e436dfcb6eebef5adb7b68c2dc8afaa1"
}
},
{
"id": "org.betterx:bclib:26.301.2@jar",
"name": "BCLib",
"type": "FabricMod",
"artifact": {
"size": 1250438,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/bclib-26.301.2.jar",
"MD5": "a9cfe49091794c3c4e499c3ec9d5e65f"
}
},
{
"id": "generated.fabricmod:better:end-26.301.2@jar",
"name": "Better",
"type": "FabricMod",
"artifact": {
"size": 99419157,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/better-end-26.301.2.jar",
"MD5": "522b9df994d1a6111aa3d716d364073b"
}
},
{
"id": "net.bettercombat:bettercombat:3.2.2@jar",
"name": "Better Combat",
"type": "FabricMod",
"artifact": {
"size": 931245,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/bettercombat-fabric-3.2.2+26.3.jar",
"MD5": "85ca794996f4599a9843623f2ac2ffce"
}
},
{
"id": "generated.fabricmod:biomesoplenty:26.3.0.0.15@jar",
"name": "BiomesOPlenty",
"type": "FabricMod",
"artifact": {
"size": 23204575,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/BiomesOPlenty-fabric-26.3-26.3.0.0.15.jar",
"MD5": "53eb5271f71b948dff00ce05c33f39cc"
}
},
{
"id": "me.shedaniel.autoconfig.example:cloth-config:26.3.159@jar",
"name": "Cloth Config v26.3",
"type": "FabricMod",
"artifact": {
"size": 1135009,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/cloth-config-fabric-26.3.159.jar",
"MD5": "ad876761ca3b7ace3b026bc901dd493e"
}
},
{
"id": "generated.fabricmod:clumps:26.3.2@jar",
"name": "Clumps",
"type": "FabricMod",
"artifact": {
"size": 20154,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/Clumps-fabric-26.3-26.3.2.jar",
"MD5": "ee7ecb188801b1e506dadeee26d01a37"
}
},
{
"id": "com.natamus:collective:8.58@jar",
"name": "Collective",
"type": "FabricMod",
"artifact": {
"size": 1294475,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/collective-26.3.0-8.58.jar",
"MD5": "19d0bb757fbc910514254261bb022e91"
}
},
{
"id": "ivkond.mc.mods.eh:easy_homes:1.0.1+26.3@jar",
"name": "Easy Homes",
"type": "FabricMod",
"artifact": {
"size": 115668,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/easy_homes-fabric-1.0.1+26.3.jar",
"MD5": "b59adc60f71cb3bf517b876b64689c91"
}
},
{
"id": "generated.fabricmod:fabric-api:0.163.0+26.3@jar",
"name": "Fabric API",
"type": "FabricMod",
"artifact": {
"size": 1774707,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/fabric-api-0.163.0+26.3.jar",
"MD5": "e3de98c7b25b1bdacd8005cd3030ed95"
}
},
{
"id": "generated.fabricmod:fabric-language-kotlin:1.14.2+kotlin.2.4.21@jar",
"name": "Fabric Language Kotlin",
"type": "FabricMod",
"artifact": {
"size": 8142878,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/fabric-language-kotlin-1.14.2+kotlin.2.4.21.jar",
"MD5": "08b201c7d0e80ba7a3d77cca0b2d3d3f"
}
},
{
"id": "glitchcore.fabric.core:glitchcore:26.3.0.0.5@jar",
"name": "GlitchCore",
"type": "FabricMod",
"artifact": {
"size": 339788,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/GlitchCore-fabric-26.3-26.3.0.0.5.jar",
"MD5": "298d2a20d0f2d9555951c5cce87c2003"
}
},
{
"id": "com.blocklegend001:immersiveores:26.3-0.1@jar",
"name": "ImmersiveOres",
"type": "FabricMod",
"artifact": {
"size": 543252,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/ImmersiveOres-26.3-0.1.jar",
"MD5": "840a11c59c29eadd909cba3ce6f569bb"
}
},
{
"id": "snownee.jade:jade:26.3.5+fabric@jar",
"name": "Jade",
"type": "FabricMod",
"artifact": {
"size": 1092685,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/Jade-mc26.3-Fabric-26.3.5.jar",
"MD5": "e446d8a9e2fd40d3bd0b88c1a9c7dc05"
}
},
{
"id": "mezz.jei:jei:31.16.0.83@jar",
"name": "Just Enough Items",
"type": "FabricMod",
"artifact": {
"size": 7379654,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/jei-26.3-fabric-31.16.0.83.jar",
"MD5": "0f78657ef2dff197c9951cbd7a20c5c4"
}
},
{
"id": "dev.nikrecs:leashall:1.5.0@jar",
"name": "LeashAll",
"type": "FabricMod",
"artifact": {
"size": 170235,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/leashall-fabric-26.3-1.5.0.jar",
"MD5": "f9118c5809c2b1f5b385302af1475681"
}
},
{
"id": "net.mezzdev.config:mezz_config:0.6.8@jar",
"name": "MezzConfig",
"type": "FabricMod",
"artifact": {
"size": 368401,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/mezz_config-26.3-fabric-0.6.8.jar",
"MD5": "228595e27e2977ec10adfc05dce05489"
}
},
{
"id": "com.chaosthedude:naturescompass:26.3-2.5.1-fabric@jar",
"name": "Nature's Compass",
"type": "FabricMod",
"artifact": {
"size": 232647,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/NaturesCompass-26.3-2.5.1-fabric.jar",
"MD5": "ee0b1ce7458afa5c4701c02b42a9f475"
}
},
{
"id": "dev.tonimatas:packetfixer:3.3.6@jar",
"name": "Packet Fixer",
"type": "FabricMod",
"artifact": {
"size": 32477,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/PacketFixer-fabric-3.3.6.jar",
"MD5": "b15fd6703384c58c19d30624923460a5"
}
},
{
"id": "net.lopymine.patpat.entrypoint.impl:patpat:1.3.2+26.3+fabric@jar",
"name": "PatPat",
"type": "FabricMod",
"artifact": {
"size": 1211460,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/PatPat-1.3.2+26.3+fabric.jar",
"MD5": "366d0fb54566f096ed320aa6ca1b84ba"
}
},
{
"id": "generated.fabricmod:placeholder-api:3.2.0+26.3@jar",
"name": "Placeholder API",
"type": "FabricMod",
"artifact": {
"size": 254959,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/placeholder-api-3.2.0+26.3.jar",
"MD5": "ca2d9a353e202ce5c11177fb4a7668b1"
}
},
{
"id": "com.zigythebird.playeranim:player_animation_library:1.2.8+mc.26.3@jar",
"name": "Player Animation Library",
"type": "FabricMod",
"artifact": {
"size": 406793,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/PlayerAnimationLibMerged-1.2.8+mc.26.3.jar",
"MD5": "e176ae70db08a26839282e6d373b5d97"
}
},
{
"id": "net.blay09.mods:shogi:26.3.0.3@jar",
"name": "Shogi",
"type": "FabricMod",
"artifact": {
"size": 466899,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/shogi-fabric-26.3-26.3.0.3.jar",
"MD5": "7d4488104a6d0714838b5c801f50407d"
}
},
{
"id": "com.jaquadro.minecraft:storagedrawers:26.3.0.1@jar",
"name": "Storage Drawers",
"type": "FabricMod",
"artifact": {
"size": 1224098,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/StorageDrawers-fabric-26.3.0.1.jar",
"MD5": "7194e41762c99278d25d4a2c7fe4cec6"
}
},
{
"id": "terrablender.core:terrablender:26.3.0.0.9@jar",
"name": "TerraBlender",
"type": "FabricMod",
"artifact": {
"size": 358202,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/TerraBlender-fabric-26.3-26.3.0.0.9.jar",
"MD5": "310a79696ae06b2049c29f1689c86db1"
}
},
{
"id": "com.serilum:treeharvester:10.3@jar",
"name": "Tree Harvester",
"type": "FabricMod",
"artifact": {
"size": 158451,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/treeharvester-26.3.0-10.3.jar",
"MD5": "63fe436df405aa30d6adf50ebf65737f"
}
},
{
"id": "net.execheinz:upgrader:1.2.1@jar",
"name": "Upgrader",
"type": "FabricMod",
"artifact": {
"size": 107274,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/upgrader-fabric-1.2.1+26.3.jar",
"MD5": "1a0d1ad1d17ce8686e11cf081cf1acf4"
}
},
{
"id": "de.miraculixx:veinminer:2.12.3@jar",
"name": "Veinminer",
"type": "FabricMod",
"artifact": {
"size": 524474,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/veinminer-fabric-2.12.3.jar",
"MD5": "d18e6fe6b403a3a00f722317cf681b24"
}
},
{
"id": "net.blay09.mods:waystones:26.3.0.1@jar",
"name": "Waystones",
"type": "FabricMod",
"artifact": {
"size": 1211675,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/waystones-fabric-26.3-26.3.0.1.jar",
"MD5": "4329117f8e28f657fa79f464deda3fde"
}
},
{
"id": "de.ambertation.wover.entrypoint:wover:26.301.2@jar",
"name": "WorldWeaver",
"type": "FabricMod",
"artifact": {
"size": 2999556,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/fabricmods/required/worldweaver-26.301.2.jar",
"MD5": "defce770e3f9661853c1d343fc870f7f"
}
},
{
"id": "FallPack_26.3_v3.1.zip",
"name": "FallPack_26.3_v3.1.zip",
"type": "File",
"artifact": {
"size": 3060739,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/files/resourcepacks/FallPack_26.3_v3.1.zip",
"MD5": "920b09c92b88b0d879076883fb48df83",
"path": "resourcepacks/FallPack_26.3_v3.1.zip"
}
},
{
"id": "Revelation-dev.zip",
"name": "Revelation-dev.zip",
"type": "File",
"artifact": {
"size": 66424586,
"url": "https://mcpv.ihorizon.org/servers/SurviePote-26.3/files/shaderpacks/Revelation-dev.zip",
"MD5": "29e0725fe9c23ceade72ac3d6925aa0a",
"path": "shaderpacks/Revelation-dev.zip"
}
}
]
}
]
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "file:///home/kisakay/Documents/Code/HomeLabGitea/mc/atlas/root/schemas/DistroMetaSchema.schema.json",
"meta": {}
}
@@ -0,0 +1 @@
{"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#/definitions/DistroMetaSchema","definitions":{"DistroMetaSchema":{"type":"object","additionalProperties":false,"properties":{"$schema":{"type":"string","description":"URL to the JSON schema for this type of file. This is used by editors to validate and annotate the data."},"meta":{"type":"object","properties":{"rss":{"type":"string","description":"A URL to a RSS feed. Used for loading news."},"discord":{"type":"object","properties":{"clientId":{"type":"string","description":"Client ID for the Application registered with Discord."},"smallImageText":{"type":"string","description":"Tootltip for the smallImageKey."},"smallImageKey":{"type":"string","description":"Name of the uploaded image for the small profile artwork."}},"required":["clientId","smallImageText","smallImageKey"],"additionalProperties":false,"description":"Global settings for Discord Rich Presence."}},"required":["rss"],"additionalProperties":false,"description":"Distribution metadata to be forwarded to the distribution file."}},"required":["meta"]}}}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -0,0 +1,16 @@
{
"$schema": "file:///home/kisakay/Documents/Code/HomeLabGitea/mc/atlas/root/schemas/ServerMetaSchema.schema.json",
"meta": {
"version": "1.0.0",
"name": "SurviePote",
"description": "Serveur survie modd\u00e9e entre potes (Minecraft 26.3, Fabric).",
"icon": "How to set the server icon: https://github.com/dscalzi/Nebula#setting-the-server-icon",
"address": "mcpv.ihorizon.org:25565",
"mainServer": true,
"autoconnect": true
},
"fabric": {
"version": "0.20.0"
},
"untrackedFiles": []
}
+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)
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "Node16",
"lib": ["ES2024", "DOM"],
"sourceMap": true,
"outDir": "dist",
"strict": true,
"moduleResolution": "node16",
"esModuleInterop": true
}
}