add buildtwitch command and docs
This commit is contained in:
parent
86560f8e19
commit
3de0fc53b9
|
@ -1,5 +1,6 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"mpt",
|
||||
"addonscript",
|
||||
"mpt",
|
||||
"twitch",
|
||||
]
|
||||
|
|
|
@ -13,6 +13,19 @@ pub struct Manifest {
|
|||
pub manifest_type: ManifestType,
|
||||
pub versions: Vec<Version>,
|
||||
pub repositories: Vec<Repository>,
|
||||
pub meta: Meta,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct Meta {
|
||||
pub name: String,
|
||||
pub contributors: Vec<Contributor>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
pub struct Contributor {
|
||||
pub name: String,
|
||||
pub roles: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
|
@ -23,7 +36,7 @@ pub struct Repository {
|
|||
pub url: Url,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[derive(Deserialize, Serialize, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum RepositoryType {
|
||||
Maven,
|
||||
|
|
|
@ -10,7 +10,7 @@ path = "src/main.rs"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
addonscript = { path = "../addonscript"}
|
||||
addonscript = { path = "../addonscript" }
|
||||
anyhow = "1.0.43"
|
||||
async-trait = "0.1.51"
|
||||
crossterm = "0.21.0"
|
||||
|
@ -25,4 +25,7 @@ structopt = "0.3.22"
|
|||
thiserror = "1.0.28"
|
||||
tokio = { version = "1.10.1", features = ["rt-multi-thread", "macros", "fs"] }
|
||||
toml = "0.5.8"
|
||||
twitch = { path = "../twitch" }
|
||||
url = "2.2.2"
|
||||
walkdir = "2.3.2"
|
||||
zip = "0.5.13"
|
||||
|
|
|
@ -0,0 +1,281 @@
|
|||
use crate::{
|
||||
config::Config,
|
||||
downloader::{
|
||||
Callback,
|
||||
CallbackStatus,
|
||||
DownloadError,
|
||||
DownloadInfo,
|
||||
Downloader,
|
||||
FileToDownload,
|
||||
},
|
||||
forge,
|
||||
util::{self, CliStyle},
|
||||
};
|
||||
use addonscript::manifest::{
|
||||
installer::Installer,
|
||||
link::Link,
|
||||
File,
|
||||
FileOpt,
|
||||
Manifest,
|
||||
RelationType,
|
||||
Repository,
|
||||
RepositoryType,
|
||||
};
|
||||
use anyhow::{bail, Context};
|
||||
use async_trait::async_trait;
|
||||
use indicatif::ProgressBar;
|
||||
use reqwest::Client;
|
||||
use std::{collections::HashMap, io::Write, path::Path, sync::Arc};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use twitch::manifest::Manifest as TwManifest;
|
||||
use walkdir::WalkDir;
|
||||
use zip::{CompressionMethod, ZipWriter};
|
||||
|
||||
pub async fn run((config, mut manifest): (Config, Manifest), all: bool) -> anyhow::Result<()> {
|
||||
util::make_tmp_dir(&config).await?;
|
||||
let twitch_dir = config.locations.temp_dir.join("twitch");
|
||||
|
||||
let mut version = manifest
|
||||
.versions
|
||||
.pop()
|
||||
.context("Manifest has no versions!")?;
|
||||
let repos = util::repo_map(manifest.repositories);
|
||||
|
||||
let mut modloader = None;
|
||||
let mut cf_rels = vec![];
|
||||
let mut link_rels = vec![];
|
||||
|
||||
for rel in version.relations {
|
||||
if !all && !rel.options.contains(&FileOpt::Included) {
|
||||
println!(
|
||||
"{}",
|
||||
format!("Skipping non-included relation {:?}", &rel).info()
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if rel.relation_type == RelationType::Modloader {
|
||||
if modloader.is_some() {
|
||||
bail!("Found multiple modloaders in manifest! Only one is supported!");
|
||||
}
|
||||
|
||||
let version = rel
|
||||
.versions
|
||||
.context("Modloader is missing `versions` field!")?;
|
||||
let version =
|
||||
forge::parse_version(&version).context("Couldn't parse forge version!")?;
|
||||
modloader = Some(format!("forge-{}", version));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
let file = if let Some(file) = rel.file {
|
||||
file
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
format!("Skipping relation {:?} with no files", &rel).info()
|
||||
);
|
||||
continue;
|
||||
};
|
||||
sort_file(file, &repos, &mut link_rels, &mut cf_rels)?;
|
||||
}
|
||||
|
||||
for file in version.files {
|
||||
sort_file(file, &repos, &mut link_rels, &mut cf_rels)?;
|
||||
}
|
||||
|
||||
let mut to_download = vec![];
|
||||
for (installer, link) in link_rels {
|
||||
match link {
|
||||
Link::Http(url) => {
|
||||
let file_name = util::url_file_name(&url)?;
|
||||
let dir = if let Installer::Dir(p) = installer {
|
||||
p
|
||||
} else {
|
||||
bail!("Relation uses non-dir installer over http!")
|
||||
};
|
||||
|
||||
to_download.push(FileToDownload {
|
||||
url,
|
||||
target: twitch_dir.join("overrides").join(dir).join(file_name),
|
||||
});
|
||||
},
|
||||
Link::File(path) => {
|
||||
println!(
|
||||
"{}",
|
||||
format!("Copying local file {}", path.to_string_lossy()).info()
|
||||
);
|
||||
|
||||
match installer {
|
||||
Installer::Dir(dir) => {
|
||||
tokio::fs::copy(config.locations.src.join(path), twitch_dir.join(dir))
|
||||
.await?;
|
||||
},
|
||||
|
||||
Installer::Override => {
|
||||
let path = config.locations.src.join(path);
|
||||
if !path.is_dir() {
|
||||
bail!("File with override installer is not directory!");
|
||||
}
|
||||
|
||||
util::copy_dir(path, twitch_dir.join("overrides")).await?;
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
struct Cb {
|
||||
pb: ProgressBar,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Callback for Cb {
|
||||
type EndRes = Result<(), DownloadError>;
|
||||
type StopInfo = DownloadError;
|
||||
|
||||
async fn on_download_complete(
|
||||
&mut self,
|
||||
res: Result<DownloadInfo, DownloadError>,
|
||||
) -> CallbackStatus<Self::StopInfo> {
|
||||
self.pb.inc(1);
|
||||
|
||||
match res {
|
||||
Ok(i) => {
|
||||
self.pb.println(i.to_colored_text());
|
||||
|
||||
CallbackStatus::Continue
|
||||
},
|
||||
|
||||
Err(e) => CallbackStatus::Stop(e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_completed(self, stop_info: Option<Self::StopInfo>) -> Self::EndRes {
|
||||
self.pb.finish();
|
||||
|
||||
match stop_info {
|
||||
None => Ok(()),
|
||||
Some(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("{}", "Downloading remote files.".info());
|
||||
|
||||
Downloader {
|
||||
callback: Cb {
|
||||
pb: ProgressBar::new(to_download.len() as u64)
|
||||
.with_prefix("Downloading")
|
||||
.with_style(util::progress_style()),
|
||||
},
|
||||
files: to_download,
|
||||
parellel_count: config.downloads.max_threads,
|
||||
client: Arc::new(Client::new()),
|
||||
}
|
||||
.download()
|
||||
.await?;
|
||||
|
||||
println!("{}", "Creating manifest.".info());
|
||||
|
||||
let tw_manifest = TwManifest::create(
|
||||
cf_rels,
|
||||
&manifest
|
||||
.meta
|
||||
.contributors
|
||||
.into_iter()
|
||||
.map(|c| c.name)
|
||||
.collect::<Vec<_>>(),
|
||||
manifest.meta.name.clone(),
|
||||
version.version.clone(),
|
||||
version.mcversion.pop().context("mcversion is empty!")?,
|
||||
modloader,
|
||||
);
|
||||
|
||||
let json = serde_json::to_vec(&tw_manifest).context("Failed to serialize twitch manifest")?;
|
||||
|
||||
tokio::fs::write(twitch_dir.join("manifest.json"), json).await?;
|
||||
|
||||
tokio::fs::create_dir_all("build").await?;
|
||||
|
||||
println!("{}", "Zipping pack.".info());
|
||||
|
||||
let mut zip = ZipWriter::new(
|
||||
// I don't think we can make this async :(
|
||||
std::fs::File::create(Path::new(&format!(
|
||||
"build/{}-{}-twitch.zip",
|
||||
manifest.meta.name, version.version
|
||||
)))
|
||||
.context("Failed to open zip file!")?,
|
||||
);
|
||||
|
||||
let options =
|
||||
zip::write::FileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
|
||||
let mut buf = vec![];
|
||||
for entry in WalkDir::new(&twitch_dir) {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let to = path.strip_prefix(&twitch_dir)?;
|
||||
|
||||
if path.is_file() {
|
||||
zip.start_file(to.to_string_lossy(), options)?;
|
||||
|
||||
tokio::fs::File::open(path)
|
||||
.await?
|
||||
.read_to_end(&mut buf)
|
||||
.await?;
|
||||
|
||||
zip.write_all(&buf)?;
|
||||
|
||||
buf.clear();
|
||||
} else if !to.as_os_str().is_empty() {
|
||||
zip.add_directory(to.to_string_lossy(), options)?;
|
||||
}
|
||||
}
|
||||
zip.finish()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sort_file(
|
||||
file: File,
|
||||
repos: &HashMap<String, Repository>,
|
||||
link_rels: &mut Vec<(Installer, Link)>,
|
||||
cf_rels: &mut Vec<(u32, u32)>,
|
||||
) -> anyhow::Result<()> {
|
||||
match file {
|
||||
File::Link {
|
||||
installer, link, ..
|
||||
} => link_rels.push((installer, link)),
|
||||
|
||||
File::Maven {
|
||||
installer,
|
||||
artifact,
|
||||
repository,
|
||||
} => {
|
||||
let repo = repos
|
||||
.get(&repository)
|
||||
.with_context(|| format!("File references unknown repository {}", &repository))?;
|
||||
match repo.repo_type {
|
||||
RepositoryType::Maven => {
|
||||
let url = util::mvn_artifact_to_url(&artifact, &repo)
|
||||
.context("Failed to convert maven artifact to url")?;
|
||||
|
||||
link_rels.push((installer, Link::Http(url)));
|
||||
},
|
||||
RepositoryType::Curseforge => {
|
||||
let (p_id, f_id) = util::parse_curseforge_artifact(&artifact)?;
|
||||
cf_rels.push((
|
||||
p_id.parse()
|
||||
.context("Couldn't parse curseforge project ID!")?,
|
||||
f_id.parse().context("Couldn't parse curseforge file ID!")?,
|
||||
));
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
|
@ -8,7 +8,7 @@ use crate::{
|
|||
Downloader,
|
||||
FileToDownload,
|
||||
},
|
||||
util::{mvn_artifact_to_url, progress_style, CliStyle},
|
||||
util::{self, mvn_artifact_to_url, progress_style, CliStyle},
|
||||
};
|
||||
use addonscript::manifest::{
|
||||
installer::Installer,
|
||||
|
@ -47,11 +47,7 @@ pub async fn run(
|
|||
} = manifest;
|
||||
let version = versions.pop().context("Manifest has no Versions!")?;
|
||||
|
||||
let mut repos = HashMap::new();
|
||||
|
||||
for repo in repositories {
|
||||
repos.insert(repo.id.clone(), repo);
|
||||
}
|
||||
let repos = util::repo_map(repositories);
|
||||
|
||||
let pb = ProgressBar::new(version.relations.len() as u64)
|
||||
.with_prefix("Resolving")
|
||||
|
@ -131,7 +127,9 @@ pub async fn run(
|
|||
.context("File uses empty URL!")?;
|
||||
let file = percent_decode(file.as_bytes()).decode_utf8_lossy();
|
||||
to.push(Borrow::<str>::borrow(&file));
|
||||
files.push(FileToDownload { url, target: to });
|
||||
if !to.exists() {
|
||||
files.push(FileToDownload { url, target: to });
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -152,12 +150,7 @@ pub async fn run(
|
|||
self.pb.inc(1);
|
||||
match res {
|
||||
Ok(i) => {
|
||||
self.pb.println(format!(
|
||||
"{} {} => {}",
|
||||
i.status,
|
||||
i.from.as_str().cyan().bold(),
|
||||
i.to.to_string_lossy().cyan().bold()
|
||||
));
|
||||
self.pb.println(i.to_colored_text());
|
||||
|
||||
CallbackStatus::Continue
|
||||
},
|
||||
|
@ -220,13 +213,7 @@ async fn process_file(
|
|||
url
|
||||
},
|
||||
RepositoryType::Curseforge => {
|
||||
let mut splits = artifact.split(':').skip(1);
|
||||
let p_id = splits
|
||||
.next()
|
||||
.context("Couldn't parse curseforge artifact!")?;
|
||||
let f_id = splits
|
||||
.next()
|
||||
.context("Couldn't parse curseforge artifact!")?;
|
||||
let (p_id, f_id) = util::parse_curseforge_artifact(&artifact)?;
|
||||
|
||||
let url = format!(
|
||||
"https://addons-ecs.forgesvc.net/api/v2/addon/{}/file/{}/download-url",
|
||||
|
|
|
@ -3,9 +3,11 @@ use addonscript::{
|
|||
manifest::{
|
||||
installer::Installer,
|
||||
link::Link,
|
||||
Contributor,
|
||||
File,
|
||||
Manifest,
|
||||
ManifestType,
|
||||
Meta,
|
||||
Relation,
|
||||
RelationType,
|
||||
Repository,
|
||||
|
@ -37,7 +39,11 @@ use crate::config::Config;
|
|||
|
||||
const DEFAULT_CONFIG: &[u8] = include_bytes!("../../assets/modpacktoolsconfig.toml");
|
||||
|
||||
pub async fn run(modpack_name: String, mcversion: String) -> anyhow::Result<()> {
|
||||
pub async fn run(
|
||||
modpack_name: String,
|
||||
author_name: String,
|
||||
mcversion: String,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut stdout = std::io::stdout();
|
||||
|
||||
execute!(
|
||||
|
@ -135,7 +141,8 @@ pub async fn run(modpack_name: String, mcversion: String) -> anyhow::Result<()>
|
|||
tokio::fs::create_dir_all(path).await?;
|
||||
|
||||
let data = serde_json::to_vec_pretty(&Manifest {
|
||||
id: modpack_name,
|
||||
// TODO rename this to snake_case
|
||||
id: modpack_name.clone(),
|
||||
manifest_type: ManifestType::Modpack,
|
||||
versions: vec![Version {
|
||||
version: "1.0".into(),
|
||||
|
@ -153,6 +160,13 @@ pub async fn run(modpack_name: String, mcversion: String) -> anyhow::Result<()>
|
|||
repo_type: RepositoryType::Curseforge,
|
||||
url: Url::parse("https://cursemaven.com").unwrap(), // unwrap is ok on fixed value
|
||||
}],
|
||||
meta: Meta {
|
||||
name: modpack_name,
|
||||
contributors: vec![Contributor {
|
||||
roles: vec!["Owner".to_owned()],
|
||||
name: author_name,
|
||||
}],
|
||||
},
|
||||
})?;
|
||||
|
||||
tokio::fs::write(path.join("modpack.json5"), data).await?;
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
pub mod buildtwitch;
|
||||
pub mod downloadmods;
|
||||
pub mod init;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
|
@ -10,8 +11,8 @@ pub struct Config {
|
|||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Locations {
|
||||
pub src: String,
|
||||
pub temp_dir: String,
|
||||
pub src: PathBuf,
|
||||
pub temp_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use async_trait::async_trait;
|
||||
use crossterm::style::Stylize;
|
||||
use futures::{stream, StreamExt};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
@ -6,14 +7,23 @@ use thiserror::Error;
|
|||
use tokio::{fs::File, io::AsyncWriteExt};
|
||||
use url::Url;
|
||||
|
||||
/// A struct that can download multiple files over http parallely using tokio
|
||||
/// and reqwest
|
||||
pub struct Downloader<C: Callback> {
|
||||
/// A callback object defined by the user containing methods that will be
|
||||
/// called when progress is made or the process completes
|
||||
pub callback: C,
|
||||
/// The files the downloader will download
|
||||
pub files: Vec<FileToDownload>,
|
||||
/// The maximum amount of files the downloader will download at once
|
||||
pub parellel_count: usize,
|
||||
/// The HTTP client to use to download files
|
||||
pub client: Arc<Client>,
|
||||
}
|
||||
|
||||
impl<C: Callback> Downloader<C> {
|
||||
/// Starts the downloading process and returns the value that is returned
|
||||
/// from the callback in the on_completed method
|
||||
pub async fn download(self) -> C::EndRes {
|
||||
let Self {
|
||||
mut callback,
|
||||
|
@ -42,6 +52,7 @@ impl<C: Callback> Downloader<C> {
|
|||
callback.on_completed(stop_info).await
|
||||
}
|
||||
|
||||
/// Downloads a single file. Used intenally by the Downloader
|
||||
async fn download_one(
|
||||
client: Arc<Client>,
|
||||
url: Url,
|
||||
|
@ -69,17 +80,37 @@ impl<C: Callback> Downloader<C> {
|
|||
}
|
||||
}
|
||||
|
||||
/// A file to be downloaded by a Downloader
|
||||
pub struct FileToDownload {
|
||||
/// The url to HTTP GET the file from
|
||||
pub url: Url,
|
||||
/// The file to save to
|
||||
pub target: PathBuf,
|
||||
}
|
||||
|
||||
/// A struct containing information about a file that is passed to the callback
|
||||
/// once it has finished downloading
|
||||
pub struct DownloadInfo {
|
||||
/// the URL the file has been downloaded from
|
||||
pub from: Url,
|
||||
/// The path the file has been saved to
|
||||
pub to: PathBuf,
|
||||
/// The HTTP status code returned by the server
|
||||
pub status: StatusCode,
|
||||
}
|
||||
|
||||
impl DownloadInfo {
|
||||
pub fn to_colored_text(&self) -> String {
|
||||
format!(
|
||||
"{} {} => {}",
|
||||
self.status.as_str().red(),
|
||||
self.from.as_str().cyan().bold(),
|
||||
self.to.to_string_lossy().cyan().bold()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// An error that can occur while a file is being downloaded
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DownloadError {
|
||||
#[error("HTTP Error: {0}")]
|
||||
|
@ -88,20 +119,39 @@ pub enum DownloadError {
|
|||
FilesystemError(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Returned by the on_download_complete method in the callback to either
|
||||
/// continue or stop the downloader. If the downloader is stopped, a StopInfo
|
||||
/// object is also sent which will be received by the on_completed function if
|
||||
/// the download was stopped.
|
||||
pub enum CallbackStatus<I> {
|
||||
Stop(I),
|
||||
Continue,
|
||||
}
|
||||
|
||||
/// A callback driven by a Downloader
|
||||
#[async_trait]
|
||||
pub trait Callback {
|
||||
/// The type returned by the file downloader from the on_completed callback
|
||||
/// function.
|
||||
type EndRes;
|
||||
/// The type sent to the on_completed function when the downloader was
|
||||
/// interrupted by the on_download_complete function.
|
||||
type StopInfo;
|
||||
|
||||
/// Called by the downloader once a file has been downloaded or failed to
|
||||
/// download
|
||||
///
|
||||
/// * `res` - The result of the file download operation
|
||||
async fn on_download_complete(
|
||||
&mut self,
|
||||
res: Result<DownloadInfo, DownloadError>,
|
||||
) -> CallbackStatus<Self::StopInfo>;
|
||||
|
||||
/// Called by the Downloader once it has completed. The return value of this
|
||||
/// function will also be returned by the file downloader.
|
||||
///
|
||||
/// * `stop_info` - Normally `None`, unless the on_download_complete
|
||||
/// function stopped the
|
||||
/// downloader, in which case the value returned by it will be provided.
|
||||
async fn on_completed(self, stop_info: Option<Self::StopInfo>) -> Self::EndRes;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ struct ForgeVersionResponse {
|
|||
promos: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Queries the newest version of forge for a given minecraft version from the
|
||||
/// forge api
|
||||
pub async fn newest_forge_version(
|
||||
http: &Client,
|
||||
mcversion: &str,
|
||||
|
@ -24,7 +26,21 @@ pub async fn newest_forge_version(
|
|||
Ok(resp.promos.remove(&format!("{}-latest", mcversion)))
|
||||
}
|
||||
|
||||
/// Parses the strange forge version format found in modpack.json files to the
|
||||
/// actual forge version number which is used in other places such as the twitch
|
||||
/// manifest.
|
||||
#[inline]
|
||||
pub fn parse_version(version: &str) -> Option<&str> {
|
||||
version.split('-').nth(1)
|
||||
version.split('-').nth(1).map(|s| s.trim_end_matches(']'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_version_valid() {
|
||||
assert_eq!(parse_version("[1.16.5-420.69-1.16.5]"), Some("420.69"));
|
||||
assert_eq!(parse_version("[1.16.5-420.69]"), Some("420.69"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ enum Command {
|
|||
Init {
|
||||
#[structopt(help = "The name of the modpack")]
|
||||
modpack_name: String,
|
||||
#[structopt(help = "Name of the modpack author")]
|
||||
name: String,
|
||||
#[structopt(help = "The minecraft version of the modpack")]
|
||||
mcversion: String,
|
||||
},
|
||||
|
@ -29,6 +31,16 @@ enum Command {
|
|||
#[structopt(short, long, help = "Download all relations and not just mods")]
|
||||
all: bool,
|
||||
},
|
||||
|
||||
#[structopt(name = "buildtwitch")]
|
||||
BuildTwitch {
|
||||
#[structopt(
|
||||
short,
|
||||
long,
|
||||
help = "Downloads all relations instead of just required ones"
|
||||
)]
|
||||
all: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
@ -38,12 +50,17 @@ async fn main() -> anyhow::Result<()> {
|
|||
match cmd {
|
||||
Command::Init {
|
||||
modpack_name,
|
||||
name,
|
||||
mcversion,
|
||||
} => commands::init::run(modpack_name, mcversion).await?,
|
||||
} => commands::init::run(modpack_name, name, mcversion).await?,
|
||||
|
||||
Command::DownloadMods { dir, all } => {
|
||||
commands::downloadmods::run(util::parse_config_and_manifest().await?, dir, all).await?
|
||||
},
|
||||
|
||||
Command::BuildTwitch { all } => {
|
||||
commands::buildtwitch::run(util::parse_config_and_manifest().await?, all).await?
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
196
mpt/src/util.rs
196
mpt/src/util.rs
|
@ -1,17 +1,29 @@
|
|||
use addonscript::manifest::{Manifest, Repository};
|
||||
use anyhow::Context;
|
||||
use crossterm::style::{Attribute, Attributes, Color, ContentStyle, Stylize};
|
||||
use indicatif::ProgressStyle;
|
||||
use std::path::Path;
|
||||
use percent_encoding::percent_decode;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
string::FromUtf8Error,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// reads and parses the config from the current working directory
|
||||
pub async fn parse_config() -> anyhow::Result<Config> {
|
||||
let conf = tokio::fs::read("modpacktoolsconfig.toml").await?;
|
||||
Ok(toml::from_slice(&conf)?)
|
||||
}
|
||||
|
||||
/// parses the config from the current working directory, reads the location of
|
||||
/// the manifest file from it, parses the manifest and returns both the conig
|
||||
/// and the manifest.
|
||||
pub async fn parse_config_and_manifest() -> anyhow::Result<(Config, Manifest)> {
|
||||
let config = parse_config().await?;
|
||||
let src = Path::new(&config.locations.src);
|
||||
|
@ -29,20 +41,26 @@ pub async fn parse_config_and_manifest() -> anyhow::Result<(Config, Manifest)> {
|
|||
Ok((config, manifest))
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MvnArtifactUrlError {
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum MavenParseError {
|
||||
#[error("Maven Artifact specifier has invalid format!")]
|
||||
InvalidFormat,
|
||||
#[error("Url parse error while processing maven artifact: {0}")]
|
||||
UrlParseError(#[from] url::ParseError),
|
||||
}
|
||||
|
||||
pub fn mvn_artifact_to_url(art: &str, repo: &Repository) -> Result<Url, MvnArtifactUrlError> {
|
||||
/// parses a maven artifact specifier and generates a URL to the artifact in a
|
||||
/// given maven repo.
|
||||
pub fn mvn_artifact_to_url(art: &str, repo: &Repository) -> Result<Url, MavenParseError> {
|
||||
let mut splits = art.split(':');
|
||||
|
||||
let group_id = splits.next().ok_or(MvnArtifactUrlError::InvalidFormat)?;
|
||||
let artifact_id = splits.next().ok_or(MvnArtifactUrlError::InvalidFormat)?;
|
||||
let version = splits.next().ok_or(MvnArtifactUrlError::InvalidFormat)?;
|
||||
let group_id = splits.next().ok_or(MavenParseError::InvalidFormat)?;
|
||||
let artifact_id = splits.next().ok_or(MavenParseError::InvalidFormat)?;
|
||||
let version = splits.next().ok_or(MavenParseError::InvalidFormat)?;
|
||||
|
||||
if splits.count() != 0 {
|
||||
return Err(MavenParseError::InvalidFormat);
|
||||
}
|
||||
|
||||
let mut url = repo.url.clone();
|
||||
if !url.path().ends_with('/') {
|
||||
|
@ -59,6 +77,108 @@ pub fn mvn_artifact_to_url(art: &str, repo: &Repository) -> Result<Url, MvnArtif
|
|||
Ok(url)
|
||||
}
|
||||
|
||||
/// convenience trait to color strings with given preset styles for the
|
||||
/// modpacktools CLI
|
||||
pub trait CliStyle: Stylize {
|
||||
/// formats the object with the style used for information messages
|
||||
fn info(self) -> Self::Styled;
|
||||
}
|
||||
|
||||
impl<T: Stylize> CliStyle for T
|
||||
where
|
||||
T::Styled: AsRef<ContentStyle> + AsMut<ContentStyle>,
|
||||
{
|
||||
fn info(self) -> T::Styled {
|
||||
let mut s = self.stylize();
|
||||
s.as_mut().foreground_color = Some(Color::Cyan);
|
||||
s.as_mut().attributes = Attributes::from(Attribute::Italic);
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// the default style for progress bars
|
||||
pub fn progress_style() -> ProgressStyle {
|
||||
ProgressStyle::default_bar()
|
||||
.template("{prefix:.bold} [{wide_bar:.green}] {pos}/{len}")
|
||||
.progress_chars("█▇▆▅▄▃▂▁ ")
|
||||
}
|
||||
|
||||
/// creates the modpacktools temporary directory (set in the config)
|
||||
pub async fn make_tmp_dir(config: &Config) -> anyhow::Result<()> {
|
||||
tokio::fs::create_dir_all(&config.locations.temp_dir)
|
||||
.await
|
||||
.context("Failed to create temporary directory")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts a vec of addonscript repositories into a map of the ID and the
|
||||
/// repo.
|
||||
#[inline]
|
||||
pub fn repo_map(repos: Vec<Repository>) -> HashMap<String, Repository> {
|
||||
repos.into_iter().map(|r| (r.id.clone(), r)).collect()
|
||||
}
|
||||
|
||||
/// Parses a curseforge artifact found in modpack.json files into the project id
|
||||
/// and file id.
|
||||
pub fn parse_curseforge_artifact(artifact: &str) -> Result<(&str, &str), MavenParseError> {
|
||||
let mut splits = artifact.split(':').skip(1);
|
||||
let p_id = splits.next().ok_or(MavenParseError::InvalidFormat)?;
|
||||
let f_id = splits.next().ok_or(MavenParseError::InvalidFormat)?;
|
||||
|
||||
if splits.count() != 0 {
|
||||
return Err(MavenParseError::InvalidFormat);
|
||||
}
|
||||
|
||||
Ok((p_id, f_id))
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum UrlFileNameError {
|
||||
#[error("URL is base URL without file name!")]
|
||||
BaseUrl,
|
||||
#[error("URL file name is invalid utf8: {0}")]
|
||||
InvalidUtf8(#[from] FromUtf8Error),
|
||||
}
|
||||
|
||||
/// Gets the last path segment (file name) of a URL
|
||||
pub fn url_file_name(url: &Url) -> Result<String, UrlFileNameError> {
|
||||
let file_name = url
|
||||
.path_segments()
|
||||
.map(Iterator::last)
|
||||
.flatten()
|
||||
.ok_or(UrlFileNameError::BaseUrl)?;
|
||||
|
||||
if file_name.is_empty() {
|
||||
return Err(UrlFileNameError::BaseUrl);
|
||||
}
|
||||
|
||||
let bytes = percent_decode(file_name.as_bytes()).collect::<Vec<_>>();
|
||||
Ok(String::from_utf8(bytes)?)
|
||||
}
|
||||
|
||||
/// Copies a directory inclding all files
|
||||
pub async fn copy_dir(from: PathBuf, to: PathBuf) -> io::Result<()> {
|
||||
for file in WalkDir::new(&from) {
|
||||
let file = file?;
|
||||
|
||||
let is_dir = file.path().is_dir();
|
||||
let target = to.join(
|
||||
file.path()
|
||||
.strip_prefix(&from)
|
||||
// this should be safe
|
||||
.expect("Couldn't strip base path"),
|
||||
);
|
||||
|
||||
if is_dir {
|
||||
tokio::fs::create_dir_all(target).await?;
|
||||
} else {
|
||||
tokio::fs::copy(file.path(), target).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use addonscript::manifest::RepositoryType;
|
||||
|
@ -75,32 +195,50 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn artifact_to_url_valid() {
|
||||
let res = mvn_artifact_to_url("de.mzte:test:0.1", &repo()).unwrap();
|
||||
assert_eq!(
|
||||
res,
|
||||
Url::parse("https://example.com/maven/de/mzte/test/0.1/test-0.1.jar").unwrap()
|
||||
mvn_artifact_to_url("de.mzte:test:0.1", &repo()).unwrap(),
|
||||
Url::parse("https://example.com/maven/de/mzte/test/0.1/test-0.1.jar").unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
mvn_artifact_to_url("test:test:test", &repo()).unwrap(),
|
||||
Url::parse("https://example.com/maven/test/test/test/test-test.jar").unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub trait CliStyle: Stylize {
|
||||
fn info(self) -> Self::Styled;
|
||||
}
|
||||
#[test]
|
||||
fn artifact_to_url_invalid() {
|
||||
assert!(mvn_artifact_to_url("test", &repo()).is_err());
|
||||
assert!(mvn_artifact_to_url("test:test", &repo()).is_err());
|
||||
assert!(mvn_artifact_to_url("test:test:test:test", &repo()).is_err());
|
||||
}
|
||||
|
||||
impl<T: Stylize> CliStyle for T
|
||||
where
|
||||
T::Styled: AsRef<ContentStyle> + AsMut<ContentStyle>,
|
||||
{
|
||||
fn info(self) -> T::Styled {
|
||||
let mut s = self.stylize();
|
||||
s.as_mut().foreground_color = Some(Color::Cyan);
|
||||
s.as_mut().attributes = Attributes::from(Attribute::Italic);
|
||||
s
|
||||
#[test]
|
||||
fn url_file_name_valid() {
|
||||
assert_eq!(
|
||||
url_file_name(&Url::parse("https://example.com/test.txt").unwrap()),
|
||||
Ok("test.txt".to_string())
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url_file_name(&Url::parse("https://example.com/test").unwrap()),
|
||||
Ok("test".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_file_name_invalid() {
|
||||
assert!(url_file_name(&Url::parse("https://example.com").unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curse_artifact_valid() {
|
||||
assert_eq!(parse_curseforge_artifact("x:a:b"), Ok(("a", "b")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curse_artifact_invalid() {
|
||||
assert!(parse_curseforge_artifact("x").is_err());
|
||||
assert!(parse_curseforge_artifact("x:x:x:x").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn progress_style() -> ProgressStyle {
|
||||
ProgressStyle::default_bar()
|
||||
.template("{prefix:.bold} [{wide_bar:.green}] {pos}/{len}")
|
||||
.progress_chars("█▇▆▅▄▃▂▁ ")
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "twitch"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
addonscript = { path = "../addonscript" }
|
||||
serde = { version = "1.0.130", features = ["derive"] }
|
||||
thiserror = "1.0.28"
|
|
@ -0,0 +1 @@
|
|||
pub mod manifest;
|
|
@ -0,0 +1,78 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Manifest {
|
||||
pub author: String,
|
||||
pub files: Vec<File>,
|
||||
pub manifest_type: ManifestType,
|
||||
pub manifest_version: u8,
|
||||
pub minecraft: Minecraft,
|
||||
pub name: String,
|
||||
pub overrides: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Creates a twitch manifest with the given data.
|
||||
/// Useful for converting from addonscript manifests
|
||||
pub fn create(
|
||||
files: Vec<(u32, u32)>,
|
||||
contributors: &[String],
|
||||
name: String,
|
||||
version: String,
|
||||
mcversion: String,
|
||||
modloader: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
overrides: "overrides".to_owned(),
|
||||
author: contributors.join(", "),
|
||||
files: files
|
||||
.into_iter()
|
||||
.map(|f| File {
|
||||
project_id: f.0,
|
||||
file_id: f.1,
|
||||
required: true,
|
||||
})
|
||||
.collect(),
|
||||
manifest_type: ManifestType::MinecraftModpack,
|
||||
manifest_version: 1,
|
||||
minecraft: Minecraft {
|
||||
version: mcversion,
|
||||
mod_loaders: modloader
|
||||
.map(|id| vec![ModLoader { id, primary: true }])
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
name,
|
||||
version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct File {
|
||||
#[serde(rename = "projectID")]
|
||||
pub project_id: u32,
|
||||
#[serde(rename = "fileID")]
|
||||
pub file_id: u32,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ManifestType {
|
||||
MinecraftModpack,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Minecraft {
|
||||
version: String,
|
||||
mod_loaders: Vec<ModLoader>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ModLoader {
|
||||
pub id: String,
|
||||
pub primary: bool,
|
||||
}
|
Loading…
Reference in New Issue