diff --git a/Cargo.lock b/Cargo.lock index 5369386..4ae9f5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -753,12 +753,13 @@ checksum = "518ef76f2f87365916b142844c16d8fefd85039bc5699050210a7778ee1cd1de" [[package]] name = "mcapi" -version = "0.1.0" -source = "git+https://github.com/ParadigmMC/mcapi.git#9f30bf384d12314943c57ebd6b521b2e7131e6a1" +version = "0.2.0" +source = "git+https://github.com/ParadigmMC/mcapi.git#f33186b6b008769538d019cb1e0cacd0389f1e1f" dependencies = [ "os-version", "regex", "reqwest", + "roxmltree", "serde", "serde_json", "thiserror", @@ -766,7 +767,7 @@ dependencies = [ [[package]] name = "mcman" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "clap", @@ -1043,6 +1044,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "roxmltree" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f595a457b6b8c6cda66a48503e92ee8d19342f905948f29c383200ec9eb1d8" +dependencies = [ + "xmlparser", +] + [[package]] name = "rustix" version = "0.37.19" @@ -1833,6 +1843,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "xmlparser" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d25c75bf9ea12c4040a97f829154768bbbce366287e2dc044af160cd79a13fd" + [[package]] name = "zeroize" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index ba6bec0..0d8dfd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mcman" -version = "0.2.0" +version = "0.2.1" edition = "2021" authors = ["ParadigmMC"] repository = "https://github.com/ParadigmMC/mcman" diff --git a/DOCS.md b/DOCS.md index 93b53f7..90c34e4 100644 --- a/DOCS.md +++ b/DOCS.md @@ -13,11 +13,22 @@ Index: Here are a list of commands. You can type `mcman` or `mcman --help` for a basic list of it. -### `mcman init` +### `mcman init [--name ] [--mrpack ]` Initializes a new server in the current directory. -Example: +This command is interactive. Just run `mcman init`! + +The source is the same as one in [`mcman import mrpack`](#mcman-import-mrpack-src) + +Example using [Adrenaserver](https://modrinth.com/modpack/adrenaserver): + +```sh +# these are all identical +mcman init --mrpack mr:adrenaserver +mcman init --mrpack https://modrinth.com/modpack/adrenaserver +mcman init --mrpack https://cdn.modrinth.com/data/H9OFWiay/versions/2WXUgVhc/Adrenaserver-1.4.0%2B1.20.1.quilt.mrpack +``` ### `mcman version` @@ -72,6 +83,13 @@ Example render: Imports a plugin or a mod from a url. +Supports: + +- Modrinth +- Spigot +- Github (releases) +- If not those, will prompt with direct url or jenkins + Example usage: ```sh @@ -80,6 +98,36 @@ mcman import url https://modrinth.com/plugin/imageframe mcman import url https://www.spigotmc.org/resources/armorstandeditor-reborn.94503/ ``` +### `mcman import mrpack ` + +Imports a [mrpack](https://docs.modrinth.com/docs/modpacks/format_definition/) file (modrinth modpacks) + +**Note:** [`mcman init`](#mcman-init---name-name---mrpack-src) supports mrpacks + +The source can be: + +- A direct URL to a `.mrpack` file +- A local file path +- Modpack URL (`https://modrinth.com/modpack/{id}`) +- Modrinth project id prefixed with `mr:` + +Example usages: + +```sh +# direct link +mcman import mrpack https://cdn.modrinth.com/data/xldzprsQ/versions/xWFqQBjM/Create-Extra-full-1.1.0.mrpack +# only /modpack urls +mcman import mrpack https://modrinth.com/modpack/create-extra +# prefixed +mcman import mrpack mr:simply-skyblock +# local file +mcman import mrpack My-Pack.mrpack +``` + +### `mcman import customs` + +Utility tool for re-importing all custom url downloadables in a server. + ## Folder Structure In a normal server environment, everything is in one folder and a big giant mess to navigate. @@ -215,7 +263,7 @@ Default values aren't written back to config - except for `aikars_flags`, `proxy disable = false # false by default # adds your own args -jvm_args = "-Dhello=true" +jvm_args = "-exampleidk" game_args = "--world abc" # use aikar's flags @@ -227,13 +275,20 @@ proxy_flags = false # adds -Dcom.mojang.eula.agree=true # therefore you agree to mojang's eula +# writes eula.txt when on fabric/quilt eula_args = true -# adds --nogui to game args, should set to false on proxies... +# adds --nogui to game args nogui = true # specify -Xmx/-Xms (memory) memory = "2048M" + +# a table of properties +[launcher.properties] +hello="thing" +# ^ same as this: +# jvm_args = "-Dhello=thing" ``` ## Types @@ -244,11 +299,14 @@ Below are some types used in `server.toml` A downloadable is some source of a plugin, mod or a server jar. -Index of types: +Index of sources: - [Vanilla](#vanilla) -- [PaperMC](#papermc) +- [Fabric](#fabric) +- [Quilt](#quilt) +- [PaperMC](#papermc) (Paper, Waterfall and Velocity) - [PurpurMC](#purpurmc) +- [BungeeCord](#bungeecord) - [Modrinth](#modrinth) - [Spigot](#spigot) - [Github Releases](#github-releases) @@ -263,20 +321,53 @@ Used for a vanilla server jar. Has no properties type = "vanilla" ``` +#### Fabric + +The [Fabric](https://fabricmc.net/) mod loader + +**Options:** + +- `type` = `"fabric"` +- `installer`: string | `"latest"` - Installer version to use +- `loader`: string | `"latest"` - Loader version to use + +```toml +type = "fabric" +installer = "latest" +loader = "latest" +``` + +#### Quilt + +The [Quilt](https://quiltmc.org/) project - mod loader compatible with fabric + +Due to some complexities with quilt, `mcman` will need to run `java` to install the quilt server jar - keep this in mind. + +**Options:** + +- `type` = `"quilt"` +- `installer`: string | `"latest"` - Installer version to use +- `loader`: string | `"latest"` - Loader version to use + +```toml +type = "quilt" +installer = "latest" +loader = "latest" +``` + #### PaperMC Allows downloading a [PaperMC](https://papermc.io/) project. **Options:** -- `type` = `papermc` +- `type` = `"papermc"` - `project`: string - The project name - `build`: string | `"latest"` - Optional ```toml # Its recommended to use the shortcuts: type = "paper" -type = "folia" type = "waterfall" type = "velocity" @@ -286,7 +377,7 @@ project = "paper" # Optionally define the build if you dont want to use the latest: type = "papermc" -project = "folia" +project = "waterfall" build = "17" # Note: the shortcuts do not support the 'build' property ``` @@ -297,7 +388,7 @@ Downloads server jar from [PurpurMC](https://purpurmc.org/). **Options:** -- `type` = `purpur` +- `type` = `"purpur"` - `build`: string | `"latest"` - Optional ```toml @@ -308,13 +399,31 @@ build = "10" # if omitted, uses latest ``` +#### BungeeCord + +BungeeCord is just a shortcut to a [jenkins](#jenkins) downloadable: + +```toml +type = "bungeecord" +``` + +If you'd like to get a specific build, use this: + +```toml +type = "jenkins" +url = "https://ci.md-5.net" +job = "BungeeCord" +build = "latest" +artifact = "BungeeCord" +``` + #### Modrinth Downloads from [Modrinth](https://modrinth.com/)'s API **Options:** -- `type` = `modrinth` | `mr` +- `type` = `"modrinth"` | `"mr"` - `id`: string - id of the project or the slug - `version`: string | `"latest"` - Version ID, `"latest"` not recommended @@ -332,7 +441,7 @@ This uses [Spiget](https://spiget.org/)'s API. **Options:** -- `type` = `spigot` +- `type` = `"spigot"` - `id`: string - id of the project You can find the ID of the resource in the URL: @@ -355,7 +464,7 @@ Allows downloading from github releases **Options:** -- `type` = `ghrel` +- `type` = `"ghrel"` - `repo`: string - repository identifier, like `"ParadigmMC/mcman"` - `tag`: string | `"latest"` - The tag of the release - `asset`: string | `"first"` - The name of the asset (checks for inclusion) @@ -376,7 +485,7 @@ Use a jenkins server **Options:** -- `type` = `jenkins` +- `type` = `"jenkins"` - `url`: string - url of the jenkins server - `job`: string - The job - `build`: string | `"latest"` - The build number to use @@ -401,6 +510,13 @@ artifact = "first" Allows you to download from a defined URL. +**Options:** + +- `type` = `"url"` +- `url`: string - URL to the file +- `filename`: string? - Optional filename if you dont like the name from the url +- `desc`: string? - Optional description (shown in markdown tables) + ```toml [[mods]] type = "url" diff --git a/README.md b/README.md index e818987..4ee7dd8 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,20 @@ Powerful Minecraft Server Manager CLI. Easily install jars (server, plugins & mo - Always keep up to date with new serverjar builds! - No more manually downloading jars - mcman auto updates them according to your `server.toml` - Supports a variety of [sources](./DOCS.md#downloadable): - - Server jars: + - Servers: - Vanilla - - PaperMC (Paper, Folia, Waterfall and Velocity) + - Fabric + - Quilt + - Paper - PurpurMC + - Velocity + - Waterfall + - BungeeCord - Plugins/Mods: - Modrinth - Spigot - And even **Github Releases**, **Custom URL**s and **Jenkins!** + - Supports importing from [mrpack](./DOCS.md#mcman-import-mrpack-src)s! - Bootstraps your server configuration files - Allows you to use variables inside your config files - Environment variables for secrets @@ -37,6 +43,13 @@ View the [Documentation](./DOCS.md) here. ## Changelog +### `0.2.1` + +- Added **Fabric** support. +- Added **Quilt** support. +- Added `mcman import mrpack` command. +- `mcman init` now supports mrpacks + ### `0.2.0` - Wrote more [documentation](./DOCS.md) @@ -49,8 +62,6 @@ View the [Documentation](./DOCS.md) here. - Supports modrinth, modrinth's cdn, github, spigot, jenkins and custom urls. - Also wayy too interactive. For example, it'll ask for which release to use and suggest which asset to use. Similar thing in modrinth importing. - Added **BungeeCord** support. - - - Added **Jenkins** as a source. - Impoved `mcman init` command. It now has a little wizard! - Made mcman build look prettier diff --git a/res/default_readme b/res/default_readme index 83b087e..f151c33 100644 --- a/res/default_readme +++ b/res/default_readme @@ -2,6 +2,8 @@ [![mcman badge](https://img.shields.io/badge/uses-mcman-purple?logo=github)](https://github.com/ParadigmMC/mcman) + + diff --git a/src/bootstrapper/mod.rs b/src/bootstrapper/mod.rs index 3eec95b..fd65fb9 100644 --- a/src/bootstrapper/mod.rs +++ b/src/bootstrapper/mod.rs @@ -53,7 +53,10 @@ where continue; } - bootstrap_entry(ctx, &entry).context(format!("Bootstrapping [{}]", entry.path().to_string_lossy()))?; + bootstrap_entry(ctx, &entry).context(format!( + "Bootstrapping [{}]", + entry.path().to_string_lossy() + ))?; } Ok(()) @@ -64,8 +67,11 @@ fn bootstrap_entry(ctx: &BootstrapContext, entry: &DirEntry) -> Result<()> { let output_path = ctx.get_output_path(path); if let Some(parent) = output_path.parent() { - std::fs::create_dir_all(parent) - .context(format!("Creating parent directory of [{}], dir = [{}]", output_path.to_string_lossy(), parent.to_string_lossy()))?; + std::fs::create_dir_all(parent).context(format!( + "Creating parent directory of [{}], dir = [{}]", + output_path.to_string_lossy(), + parent.to_string_lossy() + ))?; } // bootstrap contents of some types if let Some(ext) = path.extension() { diff --git a/src/commands/build.rs b/src/commands/build.rs index bb6074b..f87f135 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -2,14 +2,16 @@ use std::{ collections::HashMap, env, fs::{self, OpenOptions}, - io::Write, + io::{BufRead, BufReader, Write}, path::{Path, PathBuf}, - time::Instant, + process::Stdio, + time::{Duration, Instant}, }; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use clap::{arg, value_parser, ArgMatches, Command}; use console::{style, Style}; +use indicatif::{ProgressBar, ProgressStyle}; use tokio::fs::File; use super::version::APP_USER_AGENT; @@ -31,7 +33,7 @@ pub fn cli() -> Command { .arg( arg!(--skip [stages] "Skip some stages") .value_delimiter(',') - .default_value("") + .default_value(""), ) } @@ -63,7 +65,7 @@ pub async fn run(matches: &ArgMatches) -> Result<()> { println!(" stage {stage_index}: {}", title.apply_to(stage_name)); stage_index += 1; }; - + let mark_stage_skipped = |id| { println!(" {}{id}", style("-> Skipping stage ").yellow().bold()); }; @@ -83,7 +85,7 @@ pub async fn run(matches: &ArgMatches) -> Result<()> { .await .context("Failed to download plugins")?; } - + // stage 3: mods if !server.mods.is_empty() { mark_stage("Mods"); @@ -120,6 +122,19 @@ pub async fn run(matches: &ArgMatches) -> Result<()> { "config", )?; + if server.launcher.eula_args { + match server.jar { + Downloadable::Quilt { .. } | Downloadable::Fabric { .. } => { + println!( + " {}", + style("=> eula.txt [eula_args unsupported]").dim() + ); + std::fs::File::create(output_dir.join("eula.txt"))?.write_all(b"eula=true")?; + } + _ => (), + } + } + println!(" {}", style("Bootstrapping complete").dim()); } else { mark_stage_skipped("bootstrap"); @@ -144,42 +159,140 @@ pub async fn run(matches: &ArgMatches) -> Result<()> { Ok(()) } +#[allow(clippy::too_many_lines)] async fn download_server_jar( server: &Server, http_client: &reqwest::Client, output_dir: &Path, ) -> Result { - let serverjar_name = server.jar.get_filename(server, http_client).await?; - if output_dir.join(serverjar_name.clone()).exists() { - println!( - " Skipping server jar ({})", - style(serverjar_name.clone()).dim() - ); - } else { - println!( - " Downloading server jar ({})", - style(serverjar_name.clone()).dim() - ); + let serverjar_name = match &server.jar { + Downloadable::Quilt { loader, .. } => { + let installerjar_name = server.jar.get_filename(server, http_client).await?; + if output_dir.join(installerjar_name.clone()).exists() { + println!( + " Quilt installer present ({})", + style(installerjar_name.clone()).dim() + ); + } else { + println!( + " Downloading quilt installer... ({})", + style(installerjar_name.clone()).dim() + ); + + let filename = &server.jar.get_filename(server, http_client).await?; + util::download_with_progress( + File::create(&output_dir.join(filename)) + .await + .context(format!("Failed to create output file for {filename}"))?, + filename, + &server.jar, + server, + http_client, + ) + .await?; + } - let filename = &server.jar.get_filename(server, http_client).await?; - util::download_with_progress( - File::create(&output_dir.join(filename)) - .await - .context(format!("Failed to create output file for {filename}"))?, - filename, - &server.jar, - server, - http_client, - ) - .await?; + let serverjar_name = + format!("quilt-server-launch-{}-{}.jar", server.mc_version, loader); + + if output_dir.join(serverjar_name.clone()).exists() { + println!( + " Skipping server jar ({})", + style(serverjar_name.clone()).dim() + ); + } else { + println!( + " Installing quilt server... ({})", + style(serverjar_name.clone()).dim() + ); + + let mut args = vec![ + "-jar", + &installerjar_name, + "install", + "server", + &server.mc_version, + ]; + + if loader != "latest" { + args.push(loader); + } + + args.push("--install-dir=."); + args.push("--download-server"); + + let mut child = std::process::Command::new("java") + .args(args) + .current_dir(output_dir) + .stdout(Stdio::piped()) + .spawn() + .context("Running quilt-server-installer")?; + + let spinner = ProgressBar::new_spinner().with_style(ProgressStyle::with_template( + " {spinner:.dim.bold} {msg}", + )?); + + spinner.enable_steady_tick(Duration::from_millis(200)); + + let prefix = style("[qsi]").bold(); + + for line in BufReader::new(child.stdout.take().unwrap()).lines() { + let line = line.unwrap(); + let stripped_line = line.trim(); + if !stripped_line.is_empty() { + spinner.set_message(format!("{prefix} {stripped_line}")); + } + } + + if !child.wait()?.success() { + bail!("Quilt server installer exited with non-zero code"); + } + + spinner.finish_and_clear(); + + println!( + " Renaming... ({})", + style("quilt-server-launch.jar => ".to_owned() + &serverjar_name).dim() + ); + + fs::rename( + output_dir.join("quilt-server-launch.jar"), + output_dir.join(&serverjar_name), + ) + .context("Renaming quilt-server-launch.jar")?; + } - match &server.jar { - Downloadable::Quilt { .. } | Downloadable::Fabric { .. } => { - todo!() + serverjar_name + } + dl => { + let serverjar_name = dl.get_filename(server, http_client).await?; + if output_dir.join(serverjar_name.clone()).exists() { + println!( + " Skipping server jar ({})", + style(serverjar_name.clone()).dim() + ); + } else { + println!( + " Downloading server jar ({})", + style(serverjar_name.clone()).dim() + ); + + let filename = &dl.get_filename(server, http_client).await?; + util::download_with_progress( + File::create(&output_dir.join(filename)) + .await + .context(format!("Failed to create output file for {filename}"))?, + filename, + dl, + server, + http_client, + ) + .await?; } - _ => (), + + serverjar_name } - } + }; Ok(serverjar_name) } @@ -247,11 +360,7 @@ async fn download_addons( Ok(()) } -fn create_scripts( - server: &Server, - serverjar_name: &str, - output_dir: &Path, -) -> Result<()> { +fn create_scripts(server: &Server, serverjar_name: &str, output_dir: &Path) -> Result<()> { fs::write( output_dir.join("start.bat"), server @@ -290,4 +399,4 @@ fn create_scripts( ); Ok(()) -} \ No newline at end of file +} diff --git a/src/commands/import/mod.rs b/src/commands/import/mod.rs index e9c2c3b..3c43043 100644 --- a/src/commands/import/mod.rs +++ b/src/commands/import/mod.rs @@ -2,6 +2,7 @@ use anyhow::Result; use clap::{ArgMatches, Command}; mod customs; +mod mrpack; mod url; pub fn cli() -> Command { @@ -12,11 +13,13 @@ pub fn cli() -> Command { .arg_required_else_help(true) .subcommand(url::cli()) .subcommand(customs::cli()) + .subcommand(mrpack::cli()) } pub async fn run(matches: &ArgMatches) -> Result<()> { match matches.subcommand() { Some(("url", sub_matches)) => url::run(sub_matches).await?, + Some(("mrpack", sub_matches)) => mrpack::run(sub_matches).await?, Some(("customs", _)) => customs::run().await?, _ => unreachable!(), } diff --git a/src/commands/import/mrpack.rs b/src/commands/import/mrpack.rs new file mode 100644 index 0000000..b9cd6bf --- /dev/null +++ b/src/commands/import/mrpack.rs @@ -0,0 +1,66 @@ +use std::{fs::File, path::PathBuf}; + +use anyhow::{Context, Result}; +use clap::{arg, ArgMatches, Command}; +use console::style; +use tempfile::Builder; + +use crate::{ + commands::version::APP_USER_AGENT, + model::Server, + util::{ + download_with_progress, + mrpack::{import_from_mrpack, resolve_mrpack_source}, + }, +}; + +pub fn cli() -> Command { + Command::new("mrpack") + .about("Import from .mrpack (modrinth modpacks)") + .arg(arg!( "File or url").required(true)) +} + +pub async fn run(matches: &ArgMatches) -> Result<()> { + let mut server = Server::load().context("Failed to load server.toml")?; + + let http_client = reqwest::Client::builder() + .user_agent(APP_USER_AGENT) + .build() + .context("Failed to create HTTP client")?; + + let src = matches.get_one::("source").unwrap(); + + let tmp_dir = Builder::new().prefix("mcman-mrpack-import").tempdir()?; + + let filename = if src.starts_with("http") || src.starts_with("mr:") { + let fname = tmp_dir.path().join("pack.mrpack"); + let file = tokio::fs::File::create(&fname).await?; + + let downloadable = resolve_mrpack_source(src, &http_client).await?; + + println!(" > {}", style("Downloading mrpack...").green()); + + download_with_progress( + file, + &format!("Downloading {src}..."), + &downloadable, + &server, + &http_client, + ) + .await?; + + fname + } else { + PathBuf::from(src) + }; + + let f = File::open(filename).context("opening file")?; + + import_from_mrpack(&mut server, &http_client, f).await?; + + server.save()?; + + println!(" > Imported!"); + + Ok(()) +} diff --git a/src/commands/init.rs b/src/commands/init.rs index 2ac8259..618569e 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,11 +1,15 @@ use console::style; use dialoguer::Confirm; use dialoguer::{theme::ColorfulTheme, Input, Select}; +use std::ffi::OsStr; use std::fs::File; use std::io::Write; -use std::{ffi::OsStr, path::PathBuf}; +use std::path::{Path, PathBuf}; +use tempfile::Builder; use crate::commands::markdown; +use crate::util::download_with_progress; +use crate::util::mrpack::{import_from_mrpack, resolve_mrpack_source}; use crate::{ commands::version::APP_USER_AGENT, downloadable::{sources::vanilla::fetch_latest_mcver, Downloadable}, @@ -18,8 +22,10 @@ pub fn cli() -> Command { Command::new("init") .about("Initializes a new MCMan-powered Minecraft server") .arg(arg!(--name "The name of the server").required(false)) + .arg(arg!(--mrpack "Import from a modrinth modpack").required(false)) } +#[allow(clippy::too_many_lines)] pub async fn run(matches: &ArgMatches) -> Result<()> { let http_client = reqwest::Client::builder() .user_agent(APP_USER_AGENT) @@ -57,6 +63,109 @@ pub async fn run(matches: &ArgMatches) -> Result<()> { .with_initial_text(&name) .interact_text()?; + if let Some(src) = matches.get_one::("mrpack") { + println!(" > {}", style("Importing from mrpack...").cyan()); + + let tmp_dir = Builder::new().prefix("mcman-mrpack-import").tempdir()?; + + let mut server = Server { + name, + ..Default::default() + }; + + let filename = if src.starts_with("http") || src.starts_with("mr:") { + let filename = tmp_dir.path().join("pack.mrpack"); + let file = tokio::fs::File::create(&filename).await?; + + let downloadable = resolve_mrpack_source(src, &http_client).await?; + + println!(" > {}", style("Downloading mrpack...").green()); + + download_with_progress( + file, + &format!("Downloading {src}..."), + &downloadable, + &server, + &http_client, + ) + .await?; + + filename + } else { + PathBuf::from(src) + }; + + let f = File::open(filename).context("opening file")?; + + let pack = import_from_mrpack(&mut server, &http_client, f).await?; + + server.mc_version = if let Some(v) = pack.dependencies.get("minecraft") { + v.clone() + } else { + let latest_ver = fetch_latest_mcver(&http_client) + .await + .context("Fetching latest version")?; + + Input::with_theme(&theme) + .with_prompt("Server version?") + .default(latest_ver) + .interact_text()? + }; + + server.jar = { + if let Some(ver) = pack.dependencies.get("quilt-loader") { + println!( + " > {} {}", + style("Using quilt loader").cyan(), + style(ver).bold() + ); + Downloadable::Quilt { + loader: ver.clone(), + installer: "latest".to_owned(), + } + } else if let Some(ver) = pack.dependencies.get("fabric-loader") { + println!( + " > {} {}", + style("Using fabric loader").cyan(), + style(ver).bold() + ); + Downloadable::Fabric { + loader: ver.clone(), + installer: "latest".to_owned(), + } + } else { + Downloadable::select_modded_jar_interactive()? + } + }; + + println!(" > {}", style("Imported .mrpack!").green()); + + initialize_environment(false).context("Initializing environment")?; + server.save()?; + + let write_readme = if Path::new("./README.md").exists() { + Confirm::with_theme(&theme) + .default(true) + .with_prompt("Overwrite README.md?") + .interact()? + } else { + true + }; + + if write_readme { + markdown::initialize_readme(&server).context("Initializing readme")?; + } + + println!(" > {}", style("Server has been initialized!").cyan()); + println!( + " > {} {}", + style("Build using").cyan(), + style("mcman build").bold() + ); + + return Ok(()); + } + let serv_type = Select::with_theme(&theme) .with_prompt("Type of server?") .default(0) @@ -108,10 +217,10 @@ pub async fn run(matches: &ArgMatches) -> Result<()> { ..Default::default() }; - initialize_environment(is_proxy)?; + initialize_environment(is_proxy).context("Initializing environment")?; server.save()?; - let write_readme = if PathBuf::from("./README.md").exists() { + let write_readme = if Path::new("./README.md").exists() { Confirm::with_theme(&theme) .default(true) .with_prompt("Overwrite README.md?") @@ -121,7 +230,7 @@ pub async fn run(matches: &ArgMatches) -> Result<()> { }; if write_readme { - markdown::initialize_readme(&server)?; + markdown::initialize_readme(&server).context("Initializing readme")?; } println!(" > {}", style("Server has been initialized!").cyan()); diff --git a/src/downloadable/import_url.rs b/src/downloadable/import_url.rs index ee22329..3180561 100644 --- a/src/downloadable/import_url.rs +++ b/src/downloadable/import_url.rs @@ -1,4 +1,5 @@ use anyhow::{anyhow, bail, Result}; +use console::style; use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; use reqwest::Url; @@ -34,6 +35,8 @@ impl Downloadable { Err(invalid_url())?; } + println!(" > {} Modrinth/{id}", style("Imported:").green()); + Ok(Self::Modrinth { id: id.to_owned().to_owned(), version: version.to_owned().to_owned(), @@ -81,7 +84,7 @@ impl Downloadable { } let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Which version?") + .with_prompt(" Which version?") .default(0) .items( &versions @@ -100,6 +103,8 @@ impl Downloadable { versions[selection].clone() }; + println!(" > {} Modrinth/{id}", style("Imported:").green()); + Ok(Self::Modrinth { id, version: version.id, @@ -121,6 +126,8 @@ impl Downloadable { .get(1) .ok_or_else(|| anyhow!("Invalid Spigot Resource URL"))?; + println!(" > {} Spigot/{id}", style("Imported:").green()); + Ok(Downloadable::Spigot { id: id.to_owned().to_owned(), }) @@ -154,7 +161,7 @@ impl Downloadable { tag_opt = Some(tag.to_owned()); - println!("> Using release {tag}"); + println!(" > Implied release: {tag}"); } Some("download") => { let invalid_url = || anyhow!("Invalid github release download url"); @@ -163,13 +170,13 @@ impl Downloadable { tag_opt = Some(tag.to_owned()); - println!("> Using release '{tag}'"); + println!(" > Implied release: '{tag}'"); let file = segments.next().ok_or_else(invalid_url)?; file_opt = Some(file); - println!("> Using asset '{tag}'"); + println!(" > Implied asset: '{tag}'"); } Some(p) => bail!("No idea what to do with releases/{p}"), None => {} @@ -188,7 +195,7 @@ impl Downloadable { } let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Which release to use?") + .with_prompt(" Which release to use?") .items(&items) .default(0) .interact_opt()? @@ -230,7 +237,7 @@ impl Downloadable { let str_list: Vec = items.iter().map(|t| t.1.clone()).collect(); let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Which asset to use?") + .with_prompt(" Which asset to use?") .items(&str_list) .default(idx) .interact_opt()? @@ -241,7 +248,7 @@ impl Downloadable { let inferred = file_opt.unwrap_or(""); let input: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Asset name?") + .with_prompt(" Asset name?") .with_initial_text(inferred) .default(inferred.into()) .interact_text()?; @@ -252,12 +259,18 @@ impl Downloadable { a => a.to_owned(), }; + println!( + " > {} Github/{repo}/{tag}/{asset}", + style("Imported:").green() + ); + Ok(Self::GithubRelease { repo, tag, asset }) } Some(_) | None => { let items = vec!["Add as Custom URL", "Add as Jenkins", "Nevermind, cancel"]; let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt(" How would you like to import this URL?") .items(&items) .default(0) .interact_opt()?; @@ -273,15 +286,17 @@ impl Downloadable { .unwrap(); let input: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Filename?") + .with_prompt(" Filename?") .with_initial_text(inferred) .default(inferred.into()) .interact_text()?; let desc: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Optional description/comment?") + .with_prompt(" Optional description/comment?") .interact_text()?; + println!(" > {} as URL", style("Imported:").green()); + Ok(Self::Url { url: urlstr.to_owned(), filename: Some(input), @@ -289,16 +304,19 @@ impl Downloadable { }) } Some(1) => { - // TODO: make it better - println!(" >>> https://{}", url.domain().unwrap()); + // TODO: make it better..? let j_url = if Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt("Is this the correct jenkins server url?") + .with_prompt( + " Is this the correct jenkins server url?\n > https://" + .to_owned() + + url.domain().unwrap(), + ) .interact()? { "https://".to_owned() + url.domain().unwrap() } else { Input::::with_theme(&ColorfulTheme::default()) - .with_prompt("Jenkins URL:") + .with_prompt(" Jenkins URL:") .with_initial_text(urlstr) .default(urlstr.into()) .interact_text()? @@ -329,23 +347,25 @@ impl Downloadable { }; let job: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Job?") + .with_prompt(" Jenkins Job:") .with_initial_text(&inferred_job) .default(inferred_job) .interact_text()?; let build: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Build:") + .with_prompt(" Jenkins Build:") .with_initial_text("latest") .default("latest".into()) .interact_text()?; let artifact: String = Input::with_theme(&ColorfulTheme::default()) - .with_prompt("Artifact:") + .with_prompt(" Jenkins Artifact:") .with_initial_text("first") .default("first".into()) .interact_text()?; + println!(" > {} Jenkins/{job}", style("Imported:").green()); + Ok(Self::Jenkins { url: j_url, job, diff --git a/src/downloadable/interactive.rs b/src/downloadable/interactive.rs index 4f9dabe..3e0b5f8 100644 --- a/src/downloadable/interactive.rs +++ b/src/downloadable/interactive.rs @@ -39,7 +39,7 @@ impl Downloadable { let items_str: Vec = items.iter().map(|v| v.1.to_owned()).collect(); let jar_type = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Which server software to use?") + .with_prompt("Which (modded) server software to use?") .default(0) .items(&items_str) .interact()?; diff --git a/src/downloadable/markdown.rs b/src/downloadable/markdown.rs index 0354c3a..c249ab9 100644 --- a/src/downloadable/markdown.rs +++ b/src/downloadable/markdown.rs @@ -293,5 +293,10 @@ static SANITIZE_R1: &str = "<(?:\"[^\"]*\"['\"]*|'[^']*'['\"]*|[^'\">])+>"; fn sanitize(s: &str) -> Result { let re = Regex::new(SANITIZE_R1)?; - Ok(re.replace_all(&s.replace('\n', " ").replace('\r', "").replace("
", " "), "").to_string()) + Ok(re + .replace_all( + &s.replace('\n', " ").replace('\r', "").replace("
", " "), + "", + ) + .to_string()) } diff --git a/src/downloadable/mod.rs b/src/downloadable/mod.rs index 3c3aeb6..a31c478 100644 --- a/src/downloadable/mod.rs +++ b/src/downloadable/mod.rs @@ -13,7 +13,7 @@ use self::sources::{ modrinth::{download_modrinth, fetch_modrinth_filename}, papermc::{download_papermc_build, fetch_papermc_build}, purpur::{download_purpurmc_build, fetch_purpurmc_builds}, - quilt::{download_quilt_installer, get_quilt_filename}, + quilt::{download_quilt_installer, get_installer_filename}, spigot::{download_spigot_resource, fetch_spigot_resource_latest_ver}, vanilla::fetch_vanilla, }; @@ -169,9 +169,7 @@ impl Downloadable { Ok(download_fabric(client, &mcver, loader, installer).await?) } - Self::Quilt { loader, installer } => { - Ok(download_quilt_installer(client, &mcver, loader, installer).await?) - } + Self::Quilt { installer, .. } => Ok(download_quilt_installer(client, installer).await?), } } @@ -266,7 +264,7 @@ impl Downloadable { )) } - Self::Quilt { loader, .. } => Ok(get_quilt_filename(client, &mcver, loader).await?), + Self::Quilt { installer, .. } => Ok(get_installer_filename(client, installer).await?), } } } diff --git a/src/downloadable/sources/github.rs b/src/downloadable/sources/github.rs index a659562..1a3faff 100644 --- a/src/downloadable/sources/github.rs +++ b/src/downloadable/sources/github.rs @@ -10,7 +10,9 @@ async fn wait_ratelimit(res: reqwest::Response) -> Result { if let Some(h) = res.headers().get("x-ratelimit-remaining") { if String::from_utf8_lossy(h.as_bytes()) == "1" { let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let ratelimit_reset = String::from_utf8_lossy(res.headers()["x-ratelimit-reset"].as_bytes()).parse::()?; + let ratelimit_reset = + String::from_utf8_lossy(res.headers()["x-ratelimit-reset"].as_bytes()) + .parse::()?; let amount = ratelimit_reset - now; println!(" (!) Github ratelimit exceeded. sleeping for {amount} seconds..."); sleep(Duration::from_secs(amount)).await; @@ -37,13 +39,16 @@ pub async fn fetch_github_releases( repo: &str, client: &reqwest::Client, ) -> Result> { - let releases: Vec = wait_ratelimit(client - .get("https://api.github.com/repos/".to_owned() + repo + "/releases") - .send() - .await? - .error_for_status()?).await? - .json() - .await?; + let releases: Vec = wait_ratelimit( + client + .get("https://api.github.com/repos/".to_owned() + repo + "/releases") + .send() + .await? + .error_for_status()?, + ) + .await? + .json() + .await?; Ok(releases) } @@ -80,7 +85,9 @@ pub async fn fetch_github_release_filename( asset: &str, client: &reqwest::Client, ) -> Result { - Ok(fetch_github_release_asset(repo, tag, asset, client).await?.name) + Ok(fetch_github_release_asset(repo, tag, asset, client) + .await? + .name) } // youre delusional, this doesnt exist @@ -109,22 +116,28 @@ pub async fn download_github_release( ) -> Result { let fetched_asset = fetch_github_release_asset(repo, tag, asset, client).await?; - Ok(wait_ratelimit(client - .get(fetched_asset.url) - .header("Accept", "application/octet-stream") - .send() - .await?).await? - .error_for_status()?) + Ok(wait_ratelimit( + client + .get(fetched_asset.url) + .header("Accept", "application/octet-stream") + .send() + .await?, + ) + .await? + .error_for_status()?) } pub async fn fetch_repo_description(client: &reqwest::Client, repo: &str) -> Result { - let desc = wait_ratelimit(client - .get("https://api.github.com/repos/".to_owned() + repo) - .send() - .await?).await? - .error_for_status()? - .json::() - .await?["description"] + let desc = wait_ratelimit( + client + .get("https://api.github.com/repos/".to_owned() + repo) + .send() + .await?, + ) + .await? + .error_for_status()? + .json::() + .await?["description"] .as_str() .unwrap_or_default() .to_owned(); diff --git a/src/downloadable/sources/quilt.rs b/src/downloadable/sources/quilt.rs index 2737b38..6af453a 100644 --- a/src/downloadable/sources/quilt.rs +++ b/src/downloadable/sources/quilt.rs @@ -2,6 +2,40 @@ #![allow(unused)] use anyhow::{anyhow, Result}; +use mcapi::quilt::{self, InstallerVariant}; + +pub async fn download_quilt_installer( + client: &reqwest::Client, + installer: &str, +) -> Result { + let v = match installer { + "latest" => fetch_latest_quilt_installer(client).await?, + id => id.to_owned(), + }; + + Ok(quilt::download_installer(client, &InstallerVariant::Universal, &v).await?) +} + +pub async fn fetch_latest_quilt_installer(client: &reqwest::Client) -> Result { + Ok( + mcapi::quilt::fetch_installer_versions(client, &InstallerVariant::Universal) + .await? + .last() + .expect("latest quilt installer version to be present") + .clone(), + ) +} + +pub async fn get_installer_filename(client: &reqwest::Client, installer: &str) -> Result { + let v = match installer { + "latest" => fetch_latest_quilt_installer(client).await?, + id => id.to_owned(), + }; + + Ok(format!("quilt-installer-{v}.jar")) +} + +/* pub async fn fetch_quilt_latest_loader(client: &reqwest::Client) -> Result { let loaders = mcapi::quilt::fetch_loaders(client).await?; @@ -53,3 +87,4 @@ pub async fn get_quilt_filename( Ok(format!("quilt-server-{mcver}-{l}-launch.jar")) } + */ diff --git a/src/model/server.rs b/src/model/server.rs index c43d3c5..d764cec 100644 --- a/src/model/server.rs +++ b/src/model/server.rs @@ -201,7 +201,7 @@ impl Default for Server { let mut vars = HashMap::new(); vars.insert("PORT".to_owned(), "25565".to_owned()); Self { - path: PathBuf::from("./server.toml"), + path: PathBuf::from("."), name: String::new(), mc_version: "latest".to_owned(), jar: Downloadable::Vanilla {}, diff --git a/src/util/md.rs b/src/util/md.rs index d264ea0..3590a09 100644 --- a/src/util/md.rs +++ b/src/util/md.rs @@ -92,7 +92,7 @@ impl MarkdownTable { lines.join("\n") } - pub fn render_ascii(&self) -> String { + pub fn render_ascii_lines(&self, headers: bool) -> Vec { let mut col_lengths = vec![]; for idx in 0..self.headers.len() { @@ -109,23 +109,25 @@ impl MarkdownTable { let mut lines = vec![]; - lines.push({ - let mut cols = vec![]; - for (idx, width) in col_lengths.iter().enumerate() { - cols.push(format!("{:width$}", self.headers[idx])); - } + if headers { + lines.push({ + let mut cols = vec![]; + for (idx, width) in col_lengths.iter().enumerate() { + cols.push(format!("{:width$}", self.headers[idx])); + } - cols.join(" ") - }); + cols.join(" ") + }); - lines.push({ - let mut cols = vec![]; - for length in &col_lengths { - cols.push(format!("{:-^width$}", "", width = length)); - } + lines.push({ + let mut cols = vec![]; + for length in &col_lengths { + cols.push(format!("{:-^width$}", "", width = length)); + } - cols.join(" ") - }); + cols.join(" ") + }); + } for row in &self.rows { lines.push({ @@ -138,6 +140,10 @@ impl MarkdownTable { }); } - lines.join("\n") + lines + } + + pub fn render_ascii(&self) -> String { + self.render_ascii_lines(true).join("\n") } } diff --git a/src/util/mod.rs b/src/util/mod.rs index d0fceaa..bf19b16 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,4 +1,5 @@ pub mod md; +pub mod mrpack; use anyhow::{Context, Result}; use futures::StreamExt; diff --git a/src/util/mrpack.rs b/src/util/mrpack.rs new file mode 100644 index 0000000..9c33c53 --- /dev/null +++ b/src/util/mrpack.rs @@ -0,0 +1,229 @@ +use anyhow::{Context, Result}; +use console::style; +use dialoguer::{theme::ColorfulTheme, Select}; +use indexmap::IndexMap; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + fs, + io::{Read, Seek}, + path::PathBuf, +}; +use zip::ZipArchive; + +use crate::{ + downloadable::{ + sources::modrinth::{fetch_modrinth_versions, ModrinthVersion}, + Downloadable, + }, + model::Server, +}; + +use super::md::MarkdownTable; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MRPackIndex { + pub game: String, + pub name: String, + pub version_id: String, + pub summary: Option, + pub files: Vec, + pub dependencies: HashMap, +} + +impl MRPackIndex { + pub async fn import_all( + &self, + server: &mut Server, + http_client: &reqwest::Client, + ) -> Result<()> { + for f in self.files.iter().filter(|f| { + f.env.is_none() || f.env.as_ref().unwrap().server != EnvSupport::Unsupported + }) { + let url = f.downloads.first().context("unwrap url from downloads")?; + + let dl = Downloadable::from_url_interactive(http_client, server, url).await?; + + server.mods.push(dl); + } + Ok(()) + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct MRPackFile { + path: String, + env: Option, + downloads: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Env { + pub client: EnvSupport, + pub server: EnvSupport, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum EnvSupport { + Required, + Optional, + Unsupported, +} + +pub async fn import_from_mrpack( + server: &mut Server, + http_client: &reqwest::Client, + reader: R, +) -> Result { + println!(" > {}", style("Importing mrpack...").cyan(),); + + let mut archive = ZipArchive::new(reader).context("reading mrpack zip archive")?; + + println!(" > {}", style("Reading index...").cyan()); + + let mut mr_index = archive.by_name("modrinth.index.json")?; + let mut zip_content = Vec::new(); + mr_index + .read_to_end(&mut zip_content) + .context("reading modrinth.index.json from zip file")?; + + let pack: MRPackIndex = serde_json::from_slice(&zip_content)?; + pack.import_all(server, http_client) + .await + .context("importing from mrpack index")?; + + drop(mr_index); + + println!(" > {}", style("Extracting overrides...").cyan()); + + let mut server_overrided = vec![]; + let mut queue = vec![]; + + for filename in archive.file_names() { + if filename.ends_with('/') { + continue; // folder + } + + let path = PathBuf::from(filename); + + let real_path = if path.starts_with("overrides") { + if server_overrided.contains(&path) { + continue; + } + + server + .path + .join("config") + .join(path.strip_prefix("overrides")?) + } else if path.starts_with("server-overrides") { + server_overrided.push(path.clone()); + server + .path + .join("config") + .join(path.strip_prefix("server-overrides")?) + } else { + continue; + }; + + queue.push((path, real_path)); + } + + for (path, real_path) in queue { + let mut zip_file = archive.by_name(&path.to_string_lossy())?; + let mut zip_content = Vec::new(); + zip_file + .read_to_end(&mut zip_content) + .context(format!("reading {} from zip file", path.display()))?; + + fs::create_dir_all(real_path.parent().unwrap()).context(format!( + "Creating parent folder for {}", + real_path.display() + ))?; + + fs::write(&real_path, zip_content).context(format!("Writing {}", real_path.display()))?; + + println!(" => {}", style(path.to_string_lossy()).dim()); + } + + Ok(pack) +} + +pub fn select_modrinth_version( + list: &[ModrinthVersion], + server: &Option, +) -> ModrinthVersion { + let mut table = MarkdownTable::new(); + + let list = list + .iter() + .filter(|v| { + if let Some(serv) = &server { + if !v.game_versions.contains(&serv.mc_version) { + return false; + } + } + true + }) + .collect::>(); + + for v in &list { + let mut map = IndexMap::new(); + + map.insert("num".to_owned(), v.version_number.clone()); + map.insert("name".to_owned(), v.name.clone()); + map.insert("compat".to_owned(), v.loaders.join(",")); + + table.add_from_map(&map); + } + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt(" Which version?") + .default(0) + .items(&table.render_ascii_lines(false)) + .interact() + .unwrap(); + + list[selection].clone() +} + +pub async fn resolve_mrpack_source( + src: &str, + http_client: &reqwest::Client, +) -> Result { + println!(" > {}", style("Resolving mrpack...").green()); + + let modpack_id = if src.starts_with("mr:") { + Some(src.strip_prefix("mr:").unwrap().to_owned()) + } else { + let url = Url::parse(src)?; + + if url.domain() == Some("modrinth.com") && url.path().starts_with("/modpack") { + url.path().strip_prefix("/modpack/").map(str::to_owned) + } else { + None + } + }; + + let downloadable = if let Some(id) = modpack_id { + let versions: Vec = + fetch_modrinth_versions(http_client, &id, None).await?; + + let version = select_modrinth_version(&versions, &None); + + Downloadable::Modrinth { + id: id.clone(), + version: version.id, + } + } else { + Downloadable::Url { + url: src.to_owned(), + filename: None, + desc: None, + } + }; + + Ok(downloadable) +}