diff options
| author | hachem <im@hachem.wtf> | 2025-12-09 21:55:02 +0100 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2025-12-09 21:55:02 +0100 |
| commit | 50e62e246fef7975866c244e08ead7c681d50349 (patch) | |
| tree | b863e4b818d5bfa4fbbdcb774351b094d323e3d1 | |
| parent | ee977e942c8deabde89f7979fc00850922062926 (diff) | |
[add]: prompt+banner+refactor
| -rw-r--r-- | README.md | 89 | ||||
| -rw-r--r-- | bot/app.js | 63 | ||||
| -rw-r--r-- | config/config.json | 2 | ||||
| -rw-r--r-- | lib/config.js | 62 | ||||
| -rw-r--r-- | lib/modrinth.js | 69 | ||||
| -rw-r--r-- | package.json | 10 | ||||
| -rw-r--r-- | updater/cli.js | 393 | ||||
| -rw-r--r-- | updater/updater.py | 171 |
8 files changed, 611 insertions, 248 deletions
@@ -1,43 +1,92 @@ # Benson Mod Manager from hell, for Willy World -## Running the Discord Bot -1. DM me to get the Discord bot token. -2. Place the token in `config/api.json` like this: +## Setup -```json -{ - "token": "your-bot-token-here" -} +```bash +npm install ``` -3. **Set the Discord channel ID** +## Configuration -Open `config/config.json` and update the `channel_id` field to the channel where the bot should post updates: +### `config/config.json` ```json { "channel_id": "YOUR_CHANNEL_ID_HERE", + "check_interval_minutes": 60, + "minecraft_version": "1.21.10", + "fabric_loader_version": "0.17.3", + "loader_meta_version": "1.1.0", "modrinth_mods": [ - "example-mod", - "another-mod" - ], - // ... + "fabric-api", + "lithium", + "simple-voice-chat" + ] +} +``` + +### `config/api.json` (for Discord bot) + +```json +{ + "token": "your-bot-token-here" } ``` -If you don't set this, the bot **will not find the channel** and won't send any messages. +## Discord Bot -4. Start the bot: +Monitors Modrinth for mod updates and posts compatibility reports to a Discord channel. ```bash -npm i -node . +npm run bot ``` -## Running the Updater -> Make sure to run this from the **project root**, **not** inside the `updater` folder. +## Server Manager CLI + +Interactive CLI for managing mods and server files. ```bash -python updater/updater.py +npm run benson +``` + +``` + ╔══════════════════════════════════════╗ + ║ BENSON CLI v1.0 ║ + ║ Fabric Mod Manager for MC 1.21.10 ║ + ╚══════════════════════════════════════╝ + + Type 'help' for available commands. + +benson> _ +``` + +### Commands + +| Command | Description | +|---------|-------------| +| `list-mods` | List configured mods | +| `generate` | Download mods + server, run to generate files | +| `merge` | (coming soon) | +| `clean generated` | Remove generated server files | +| `clean all` | (coming soon) | +| `help` | Show available commands | +| `exit` | Exit the CLI | + +## Project Structure + +``` +Benson/ +├── lib/ +│ ├── modrinth.js # Shared Modrinth API functions +│ └── config.js # Shared config/cache utilities +├── bot/ +│ └── app.js # Discord bot +├── updater/ +│ └── cli.js # Interactive CLI +├── config/ +│ ├── config.json # Main configuration +│ └── api.json # Discord token (not in repo) +├── cache/ # Version cache (auto-generated) +└── server/ # Server files (auto-generated) ``` @@ -5,26 +5,22 @@ const GatewayIntentBits } = require("discord.js") -const axios = require("axios") -const fs = require("fs-extra") -const path = require("path") +const { LoadConfig, LoadApi, LoadCache, SaveCache } = require("../lib/config") +const { GetModVersions, GetLatestVersion, GetCompatibleVersion } = require("../lib/modrinth") -const config = require("../config/config.json") -const api = require("../config/api.json") +const config = LoadConfig() +const api = LoadApi() const { channel_id, check_interval_minutes, minecraft_version, - fabric_loader_version, - loader_meta_version, modrinth_mods } = config const { token } = api -const cacheFile = path.join(__dirname, "../cache/mod_versions.json") const client = new Client ({ intents: @@ -34,47 +30,6 @@ const client = new Client ] }) -const LoadCache = async () => -{ - try - { - if (await fs.pathExists(cacheFile)) - return JSON.parse(await fs.readFile(cacheFile, "utf8")) - } - catch (err) - { - console.error("[Cache] Failed to load:", err) - } - return {} -} - -const SaveCache = async cache => -{ - try - { - await fs.ensureDir(path.dirname(cacheFile)) - await fs.writeFile(cacheFile, JSON.stringify(cache, null, 2)) - } - catch (err) - { - console.error("[Cache] Failed to save:", err) - } -} - -const GetModVersions = async slug => -{ - try - { - const response = await axios.get(`https://api.modrinth.com/v2/project/${slug}/version`) - return response.data - } - catch (err) - { - console.error(`[Modrinth] Failed for ${slug}:`, err.message) - return null - } -} - const CheckMods = async () => { const cache = await LoadCache() @@ -90,8 +45,8 @@ const CheckMods = async () => if (!versions) continue - const latest = versions.sort((a, b) => new Date(b.date_published) - new Date(a.date_published))[0] - const compatible = versions.find(v => v.game_versions.includes(minecraft_version)) || null + const latest = GetLatestVersion(versions) + const compatible = GetCompatibleVersion(versions, minecraft_version) const cached = cache[slug] || { @@ -102,7 +57,7 @@ const CheckMods = async () => newCache[slug] = { compatible: compatible ? compatible.id : null, - latest: latest.id + latest: latest ? latest.id : null } if (!compatible) @@ -117,7 +72,7 @@ const CheckMods = async () => continue } - if (latest.id !== cached.latest) + if (latest && latest.id !== cached.latest) stillCompatibleUpdate.push(slug) } @@ -204,4 +159,4 @@ client.once(Events.ClientReady, async c => }, check_interval_minutes * 60 * 1000) }) -client.login(token)
\ No newline at end of file +client.login(token) diff --git a/config/config.json b/config/config.json index 6db2f86..67011f9 100644 --- a/config/config.json +++ b/config/config.json @@ -4,6 +4,8 @@ "minecraft_version": "1.21.10", "fabric_loader_version": "0.17.3", "loader_meta_version": "1.1.0", + "java_path": "C:/Program Files/Java/jdk-25/bin/java.exe", + "modrinth_mods": [ "audioplayer", "c2me-fabric", diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..9d2ed39 --- /dev/null +++ b/lib/config.js @@ -0,0 +1,62 @@ +const path = require("path") +const fs = require("fs-extra") + +const configPath = path.join(__dirname, "../config/config.json") +const apiPath = path.join(__dirname, "../config/api.json") +const cachePath = path.join(__dirname, "../cache/mod_versions.json") + +const LoadConfig = () => +{ + return require(configPath) +} + +const LoadApi = () => +{ + try + { + return require(apiPath) + } + catch + { + return {} + } +} + +const LoadCache = async () => +{ + try + { + if (await fs.pathExists(cachePath)) + return JSON.parse(await fs.readFile(cachePath, "utf8")) + } + catch (err) + { + console.error("[Cache] Failed to load:", err) + } + return {} +} + +const SaveCache = async cache => +{ + try + { + await fs.ensureDir(path.dirname(cachePath)) + await fs.writeFile(cachePath, JSON.stringify(cache, null, 2)) + } + catch (err) + { + console.error("[Cache] Failed to save:", err) + } +} + +module.exports = +{ + configPath, + apiPath, + cachePath, + LoadConfig, + LoadApi, + LoadCache, + SaveCache +} + diff --git a/lib/modrinth.js b/lib/modrinth.js new file mode 100644 index 0000000..cf0f697 --- /dev/null +++ b/lib/modrinth.js @@ -0,0 +1,69 @@ +const axios = require("axios") + +const MODRINTH_API = "https://api.modrinth.com/v2" + +const GetModVersions = async slug => +{ + try + { + const response = await axios.get(`${MODRINTH_API}/project/${slug}/version`) + return response.data + } + catch (err) + { + console.error(`[Modrinth] Failed for ${slug}:`, err.message) + return null + } +} + +const GetLatestVersion = versions => +{ + if (!versions || !versions.length) + return null + return versions.sort((a, b) => new Date(b.date_published) - new Date(a.date_published))[0] +} + +const GetCompatibleVersion = (versions, minecraftVersion, loader = null) => +{ + if (!versions || !versions.length) + return null + + const compatible = versions.filter(v => + { + const hasVersion = v.game_versions.includes(minecraftVersion) + const hasLoader = loader ? v.loaders.includes(loader) : true + return hasVersion && hasLoader + }) + + if (!compatible.length) + return null + + return compatible.sort((a, b) => new Date(b.date_published) - new Date(a.date_published))[0] +} + +const DownloadFile = async (url, destPath) => +{ + const fs = require("fs-extra") + + try + { + const response = await axios.get(url, { responseType: "arraybuffer" }) + await fs.writeFile(destPath, response.data) + return true + } + catch (err) + { + console.error(`[Download] Failed:`, err.message) + return false + } +} + +module.exports = +{ + MODRINTH_API, + GetModVersions, + GetLatestVersion, + GetCompatibleVersion, + DownloadFile +} + diff --git a/package.json b/package.json index f2a0b0c..560bb6a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "benson", - "version": "1.0.0", - "description": " Mod manager for Willy World", + "version": "1.1.0", + "description": "A server mod manager from hell.", "homepage": "https://github.com/le-hachem/Benson#readme", "bugs": { "url": "https://github.com/le-hachem/Benson/issues" @@ -15,11 +15,15 @@ "type": "commonjs", "main": "bot/app.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "bot": "node bot/app.js", + "benson": "node updater/cli.js" }, "dependencies": { "axios": "^1.12.2", "discord.js": "^14.23.2", "fs-extra": "^11.3.2" + }, + "bin": { + "benson": "./updater/cli.js" } } diff --git a/updater/cli.js b/updater/cli.js new file mode 100644 index 0000000..8731ab6 --- /dev/null +++ b/updater/cli.js @@ -0,0 +1,393 @@ +#!/usr/bin/env node + +const readline = require("readline") +const fs = require("fs-extra") +const path = require("path") +const { spawn } = require("child_process") + +const { LoadConfig } = require("../lib/config") +const { GetModVersions, GetCompatibleVersion, DownloadFile } = require("../lib/modrinth") + +const pkg = require("../package.json") +const config = LoadConfig() + +const +{ + minecraft_version, + fabric_loader_version, + loader_meta_version, + java_path, + modrinth_mods +} = config + +const javaCmd = java_path || "java" + +const serverDir = path.join(__dirname, "../server") +const modsDir = path.join(serverDir, "mods") + +const deleteList = +[ + "audioplayer_uploads", + "config", + "cache", + "world", + "logs", + "banned-players.json", + "server.properties", + "banned-ips.json", + "usercache.json", + "whitelist.json", + "ops.json", + "eula.txt" +] + + +const EnsureDirs = async () => +{ + await fs.ensureDir(serverDir) + await fs.ensureDir(modsDir) +} + +const DownloadModrinthMods = async (modList, mcVersion) => +{ + const downloadedFiles = [] + const failedMods = [] + + for (const slug of modList) + { + try + { + const versions = await GetModVersions(slug) + if (!versions) + { + console.log(` [Modrinth] Could not get versions for ${slug}`) + failedMods.push(slug) + continue + } + + const compatible = GetCompatibleVersion(versions, mcVersion, "fabric") + if (!compatible) + { + console.log(` [Modrinth] No Fabric version for ${slug} on MC ${mcVersion}`) + failedMods.push(slug) + continue + } + + const fileInfo = compatible.files[0] + const jarUrl = fileInfo.url + const filename = fileInfo.filename + const destPath = path.join(modsDir, filename) + + console.log(` [Modrinth] Downloading ${slug} -> ${filename} ...`) + + const success = await DownloadFile(jarUrl, destPath) + if (!success) + { + console.log(` [Modrinth] Couldn't download jar for ${slug}`) + failedMods.push(slug) + continue + } + + downloadedFiles.push(destPath) + console.log(` [Modrinth] Downloaded: ${filename}`) + } + catch (err) + { + console.error(` [Modrinth] Error downloading ${slug}:`, err.message) + failedMods.push(slug) + } + } + + return { downloadedFiles, failedMods } +} + +const DownloadFabricServer = async (mcVersion, loaderVersion, metaVersion) => +{ + const url = `https://meta.fabricmc.net/v2/versions/loader/${mcVersion}/${loaderVersion}/${metaVersion}/server/jar` + const filename = "server.jar" + const destPath = path.join(serverDir, filename) + + console.log(` [Fabric] Downloading Fabric server for MC ${mcVersion} ...`) + + const success = await DownloadFile(url, destPath) + if (!success) + { + console.log(` [Fabric] Failed to download Fabric server JAR`) + return null + } + + console.log(` [Fabric] Downloaded: ${filename}`) + return destPath +} + +const RunServerAutoStop = (rl) => +{ + return new Promise((resolve) => + { + const jarPath = path.join(serverDir, "server.jar") + + if (!fs.existsSync(jarPath)) + { + console.log(" Server JAR not found.") + resolve(false) + return + } + + const eulaPath = path.join(serverDir, "eula.txt") + if (!fs.existsSync(eulaPath)) + { + console.log(" Accepting EULA...") + fs.writeFileSync(eulaPath, "eula=true\n") + } + + console.log(" Starting server (will auto-stop after loading)...\n") + + rl.pause() + + let hadError = false + + const serverProcess = spawn(javaCmd, ["-jar", "server.jar", "nogui"], + { + cwd: serverDir, + stdio: ["pipe", "pipe", "pipe"] + }) + + serverProcess.stdout.on("data", (data) => + { + const line = data.toString() + process.stdout.write(line) + + if (line.includes("Done")) + { + console.log("\n Server loaded, stopping...") + serverProcess.stdin.write("stop\n") + } + }) + + serverProcess.stderr.on("data", (data) => + { + process.stderr.write(data.toString()) + }) + + serverProcess.on("error", err => + { + hadError = true + console.error(" Failed to start server:", err.message) + rl.resume() + resolve(false) + }) + + serverProcess.on("close", () => + { + if (!hadError) + { + console.log("\n Server stopped.") + rl.resume() + resolve(true) + } + }) + }) +} + +const DeletePostGenerate = async () => +{ + console.log(" Cleaning up server-generated files...") + + let removed = 0 + for (const item of deleteList) + { + const itemPath = path.join(serverDir, item) + + try + { + if (await fs.pathExists(itemPath)) + { + await fs.remove(itemPath) + removed++ + } + } + catch (err) + { + console.error(` Failed to remove ${item}:`, err.message) + } + } + + console.log(` Removed ${removed} items.`) +} + +const CleanGenerated = async () => +{ + try + { + if (await fs.pathExists(serverDir)) + { + await fs.remove(serverDir) + console.log(" Removed server directory.") + } + else + { + console.log(" Server directory doesn't exist.") + } + } + catch (err) + { + console.error(" Failed to remove server directory:", err.message) + } +} + +const CleanAll = async () => +{ + // Placeholder - will be implemented later + console.log(" clean all - not implemented yet") +} + +const ListMods = () => +{ + console.log(`\n Mods for MC ${minecraft_version}:\n`) + for (const slug of modrinth_mods) + console.log(` - ${slug}`) + console.log(`\n Total: ${modrinth_mods.length} mods\n`) +} + +const Generate = async (rl) => +{ + await EnsureDirs() + + console.log(`\n Generating server for MC ${minecraft_version}...\n`) + + const { downloadedFiles, failedMods } = await DownloadModrinthMods(modrinth_mods, minecraft_version) + console.log(`\n ✅ Downloaded ${downloadedFiles.length} mods`) + if (failedMods.length) + console.log(` ❌ Failed: ${failedMods.join(", ")}`) + + const serverFile = await DownloadFabricServer(minecraft_version, fabric_loader_version, loader_meta_version) + + if (serverFile) + { + console.log("") + await RunServerAutoStop(rl) + await DeletePostGenerate() + } + + console.log("") +} + +const Merge = async () => +{ + // Placeholder - will be implemented later + console.log(" merge - not implemented yet") +} + +const ShowHelp = () => +{ + console.log(` + Commands: + + list-mods List configured mods + generate Download mods + server, run to generate files + merge (coming soon) + clean generated Remove generated server files + clean all (coming soon) + help Show this help + exit Exit +`) +} + +const ProcessCommand = async (input, rl) => +{ + const parts = input.trim().toLowerCase().split(/\s+/) + const cmd = parts[0] + const arg = parts[1] + + switch (cmd) + { + case "list-mods": + ListMods() + break + + case "generate": + await Generate(rl) + break + + case "merge": + await Merge() + break + + case "clean": + if (arg === "generated") + await CleanGenerated() + else if (arg === "all") + await CleanAll() + else + console.log(" Usage: clean generated | clean all") + break + + case "help": + case "?": + ShowHelp() + break + + case "exit": + case "quit": + case "q": + console.log(" Goodbye!\n") + process.exit(0) + + case "": + break + + default: + console.log(` Unknown command: ${cmd}. Type 'help' for commands.`) + } +} + +const DrawBox = (lines) => +{ + const maxLen = Math.max(...lines.map(l => l.length)) + const pad = (str) => str + " ".repeat(maxLen - str.length) + + const top = "╔" + "═".repeat(maxLen + 2) + "╗" + const bot = "╚" + "═".repeat(maxLen + 2) + "╝" + const mid = lines.map(l => "║ " + pad(l) + " ║").join("\n ") + + return ` ${top}\n ${mid}\n ${bot}` +} + +const Main = () => +{ + const title = `BENSON CLI v${pkg.version}` + const subtitle = `Fabric Server for MC ${minecraft_version}` + + const box = DrawBox([title, subtitle]) + + console.log(` +${box} + + Type 'help' for commands. +`) + + const rl = readline.createInterface + ({ + input: process.stdin, + output: process.stdout + }) + + const Prompt = () => + { + rl.question("benson> ", async (input) => + { + await ProcessCommand(input, rl) + Prompt() + }) + } + + rl.on("close", () => + { + console.log("\n Goodbye!\n") + process.exit(0) + }) + + Prompt() +} + +Main() diff --git a/updater/updater.py b/updater/updater.py deleted file mode 100644 index 7f065eb..0000000 --- a/updater/updater.py +++ /dev/null @@ -1,171 +0,0 @@ -from typing import List -import os -import httpx -import shutil -import asyncio -import subprocess -import json - -server_dir = "server" -mods_dir = f"{server_dir}/mods" -os.makedirs(server_dir, exist_ok=True) -os.makedirs(mods_dir, exist_ok=True) - -delete_list = [ - "audioplayer_uploads/", - "config/", - "cache/", - "world/", - "logs/", - - "banned-players.json", - "server.properties", - "banned-ips.json", - "usercache.json", - "whitelist.json", - "ops.json", - "eula.txt", -] - -with open("config/config.json", "r") as f: - config = json.load(f) - -mc_version = config.get("minecraft_version", "1.21.10") -fabric_loader_version = config.get("fabric_loader_version", "0.17.3") -loader_meta_version = config.get("loader_meta_version", "1.1.0") -modrinth_mods = config.get("modrinth_mods", []) - -async def download_modrinth(mod_list: List[str], mc_ver: str): - downloaded_files = [] - failed_mods = [] - async with httpx.AsyncClient(timeout=60) as client: - for slug in mod_list: - try: - versions_url = f"https://api.modrinth.com/v2/project/{slug}/version" - resp = await client.get(versions_url) - if resp.status_code != 200: - print(f"[Modrinth] Could not get versions for {slug}") - failed_mods.append(slug) - continue - - versions = resp.json() - compatible_versions = [ - v for v in versions - if "fabric" in v.get("loaders", []) and mc_ver in v.get("game_versions", []) - ] - - if not compatible_versions: - print(f"[Modrinth] No Fabric versions found for {slug} on MC {mc_ver}") - failed_mods.append(slug) - continue - - latest = max(compatible_versions, key=lambda v: v["date_published"]) - file_info = latest.get("files", [])[0] - jar_url = file_info["url"] - filename = file_info["filename"] - - print(f"[Modrinth] Downloading {slug} -> {filename} ...") - jar_resp = await client.get(jar_url) - if jar_resp.status_code != 200: - print(f"[Modrinth] Couldn't download jar for {slug}") - failed_mods.append(slug) - continue - - path = os.path.join(mods_dir, filename) - with open(path, "wb") as f: - f.write(jar_resp.content) - - downloaded_files.append(path) - print(f"[Modrinth] Successfully downloaded: {filename}") - - except Exception as e: - print(f"[Modrinth] Error downloading {slug}: {e}") - failed_mods.append(slug) - - return downloaded_files, failed_mods - -async def download_fabric_server(mc_ver: str, loader_ver: str, meta_ver: str): - url = f"https://meta.fabricmc.net/v2/versions/loader/{mc_ver}/{loader_ver}/{meta_ver}/server/jar" - filename = "server.jar" - path = os.path.join(server_dir, filename) - - async with httpx.AsyncClient(timeout=60) as client: - try: - print(f"[Fabric] Downloading Fabric server for MC {mc_ver} ...") - resp = await client.get(url) - if resp.status_code != 200: - print(f"[Fabric] Failed to download Fabric server JAR: {resp.status_code}") - return None - - with open(path, "wb") as f: - f.write(resp.content) - - print(f"[Fabric] Successfully downloaded Fabric server: {filename}") - return path - except Exception as e: - print(f"[Fabric] Error downloading Fabric server: {e}") - return None - -async def run_fabric_server(): - jar_path = os.path.join(server_dir, "server.jar") - - if not os.path.exists(jar_path): - print("[Server] Server JAR not found, cannot start server.") - return - - eula_path = os.path.join(server_dir, "eula.txt") - if not os.path.exists(eula_path): - print("[Server] Accepting EULA automatically...") - with open(eula_path, "w") as f: - f.write("eula=true\n") - - print(f"[Server] Starting Fabric server from {jar_path} ...") - - process = subprocess.Popen( - ["java", "-jar", "server.jar", "nogui"], - cwd=server_dir - ) - process.wait() - print("[Server] Fabric server stopped.") - -def cleanup_server(delete_list: List[str]): - print("[Cleanup] Cleaning up server files...") - for item in delete_list: - path = os.path.join(server_dir, item) - if os.path.exists(path): - try: - if os.path.isdir(path): - shutil.rmtree(path) - print(f"[Cleanup] Removed folder: {item}") - else: - os.remove(path) - print(f"[Cleanup] Removed file: {item}") - except Exception as e: - print(f"[Cleanup] Failed to remove {item}: {e}") - else: - print(f"[Cleanup] {item} does not exist, skipping.") - print("[Cleanup] Cleanup complete.") - -async def main(): - print(f"Starting Benson Mod Updater for Fabric MC {mc_version}...\n") - files, failed_mods = await download_modrinth(modrinth_mods, mc_version) - server_file = await download_fabric_server(mc_version, fabric_loader_version, loader_meta_version) - - print("\nAll downloads complete!") - print(f"Downloaded {len(files)} mods:") - for f in files: - print(f" - {f}") - - if failed_mods: - print("\nThe following mods failed to download:") - for mod in failed_mods: - print(f" - {mod}") - else: - print("\nAll mods downloaded successfully!") - - if server_file: - await run_fabric_server() - cleanup_server(delete_list) - -if __name__ == "__main__": - asyncio.run(main()) |
