diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..79d0679 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# ChangeLog +This is the changelog of **Godo**, a version manager for [Godot Engine](https://github.com/godotengine/godot). + +## [1.0.0] - 2024-1-6 +### Added +* Basic command support + * `install` - Install Godot Engine with specific version. + * `uninstall` - Uninstall Godot Engine with specific version. + * `available` - Show the list of the available Godot Engine versions. + * `list` - List the installed Godot Engines. + * `run` - Run Godot Engine with specific version. +* Automatic installing, uninstalling and running features. \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index f4da339..c7b300f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "godo" version = "0.1.0" +authors = ["Hamster5295 "] edition = "2021" +description = "A version manager for Godot Engine." +readme= "README.md" +repository = "https://github.com/Hamster5295/godo" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index 730d015..f171121 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,58 @@ # Godo -A command-line tool for managing Godot Engines on local machine. +A command-line tool for managing different versions of [Godot Engine](https://github.com/godotengine/godot)s. + Written in Rust. -**Currently Under Development** -## Expected Features -* Installing and uninstalling Godot by specified versions. -* Running Godot instances from Cli. \ No newline at end of file +> [info] +> Currently **Godo** supports Windows only, and will soon catch up with macos and linux :D + +## Quick Start +Install the latest stable version of Godot: +```Bash +godo install +``` + +...with **Mono** support: +```Bash +godo install -m +``` + +Install 3.x version: +```Bash +godo install 3 +``` + + +Run Godot with latest stable version: +```Bash +godo run +``` + +...with specified version: +```Bash +godo run 3 +``` + + +See what's available to install! +```Bash +godo available +``` + + +...with **prereleased** versions +```Bash +godo available -p +``` + + +What's already installed? +```Bash +godo list +``` + + +I don't want the version anymore! +```Bash +godo uninstall 4.2-stable +``` diff --git a/src/main.rs b/src/main.rs index 7f8c57c..9e09c1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,65 +1,140 @@ mod procedure; mod remote; mod utils; +mod version; -use std::fs; +use std::{fs, process::exit}; use clap::{Parser, Subcommand}; use console::Style; use dialoguer::Confirm; -use remote::get_download_info; +use remote::search_remote_version; use reqwest::Client; #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Args { #[command(subcommand)] - command: Command, + command: CliCommand, } #[derive(Subcommand, Clone)] -enum Command { +enum CliCommand { /// Install Godot with optional specific version. Install { - /// The version to install + /// The version to install. version: Option, - /// Whether to install the Mono version (with C# support) + /// Whether to install the Mono version (with C# support). #[arg(short, long)] mono: bool, }, - /// List all the available released versions + + /// Uninstall specific Godot version. + Uninstall { + /// The version to install. + version: Option, + + /// Whether to install the Mono version (with C# support). + #[arg(short, long)] + mono: bool, + }, + + /// List available Godot versions. Available { /// Whether to list prereleased versions #[arg(short, long)] prerelease: bool, }, - /// List all the installed versions + + /// List installed Godot versions. List, + + /// Run Godot with specific version. + Run { + /// The version to run. Automaticly runs the latest stable version when not specified. + version: Option, + + /// Whether to run the Mono version. + #[arg(short, long)] + mono: Option, + + /// Whether to run with console. Has no effect with Godot 3.x + #[arg(short, long)] + console: bool, + }, } #[tokio::main] async fn main() { let args = Args::parse(); - let cyan = Style::new().cyan().bold().bright(); + match &args.command { + CliCommand::Install { version, mono } => handle_install(version, mono).await, + CliCommand::Uninstall { version, mono } => handle_uninstall(version, mono), + CliCommand::Available { prerelease } => handle_available(prerelease).await, + CliCommand::List => handle_list(), + CliCommand::Run { + version, + mono, + console, + } => handle_run(version, mono, console).await, + } +} + +async fn handle_available(prerelease: &bool) { + let client = Client::new(); + remote::list_avail(&client, *prerelease).await; +} + +fn handle_list() { + let dim = Style::new().dim(); let yellow = Style::new().yellow().bold(); - let red = Style::new().red().bold(); - let green = Style::new().green().bold(); - match &args.command { - Command::Install { version, mono } => { - handle_install(version, mono); + println!("{}", yellow.apply_to("Installed")); + println!("{}", dim.apply_to("=".repeat(15))); + + let installed_dirs = utils::get_installed_dirs(); + if installed_dirs.len() > 0 { + for dir in installed_dirs { + if let Some(ver) = version::parse(dir) { + println!("{}", ver.short_name()); + } } - Command::Available { prerelease } => { - let client = Client::new(); - remote::list_avail(&client, *prerelease).await; + } else { + println!("{}", dim.apply_to("Nothing yet...")); + } +} + +async fn handle_run(version: &Option, mono: &Option, console: &bool) { + let red = Style::new().red().bold(); + + if let Some(ver) = utils::search_installed_version(version, *mono) { + if let Some(exec) = utils::get_executable(ver.dir_name(), *console) { + tokio::process::Command::new(exec).spawn().expect( + format!( + "{} {}", + red.apply_to("Failed to run"), + red.apply_to(ver.version_name()) + ) + .as_str(), + ); } - Command::List => { - let installed_dirs = utils::get_installed_dirs(); - for dir in installed_dirs { - println!("{}", dir); + } else { + println!("{}", red.apply_to("No installed version found.")); + if Confirm::new() + .with_prompt("Install a new version?") + .wait_for_newline(true) + .interact() + .unwrap() + { + if let Some(mono_flag) = mono { + handle_install(version, mono_flag).await; + } else { + handle_install(version, &false).await; } + } else { + println!("{}", red.apply_to("Aborted.")); } } } @@ -67,30 +142,40 @@ async fn main() { async fn handle_install(version: &Option, mono: &bool) { let cyan = Style::new().cyan().bold().bright(); let yellow = Style::new().yellow().bold(); + let cmd = Style::new().yellow().underlined().bold(); let red = Style::new().red().bold(); let green = Style::new().green().bold(); - let version_str: String; - match version { - Some(ver) => { - version_str = ver.clone(); - } - None => version_str = "4".to_string(), - } - + let proc = &mut procedure::new(5); let client = Client::new(); - match get_download_info(&client, version_str, *mono).await { - Some((tag, url)) => { - let proc = &mut procedure::new(4); - let version_name = utils::get_version_name(&tag, mono); - let file_name = utils::get_dir_name(&tag, mono); + proc.next("Searching for available versions...".to_string()); + match search_remote_version(&client, version, *mono).await { + Some((ver, url)) => { + proc.finish("Found!".to_string()); + let installed = utils::get_installed_versions(); + if installed.contains(&ver) { + println!( + "{} {}", + cyan.apply_to(ver.version_name()), + "has been installed already." + ); + println!( + "Use {} to list the installed versions.", + cmd.apply_to("godo list") + ); + println!("{}", red.apply_to("Aborted.")); + exit(0); + } + + let version_name = ver.version_name(); + let file_name = ver.dir_name(); // Confirm before download - proc.next("Please confirm your installation:".to_string()); + proc.next("The following version is about to be installed:".to_string()); println!("\t> {} <", cyan.apply_to(&version_name)); if Confirm::new() - .with_prompt("Do you want to proceed?") + .with_prompt("Proceed?") .default(true) .show_default(true) .wait_for_newline(true) @@ -104,7 +189,7 @@ async fn handle_install(version: &Option, mono: &bool) { // Unzip proc.next(format!("{}", yellow.apply_to("Unzipping..."))); - remote::unzip(&path); + remote::unzip(&path, ver.mono()); proc.finish("Unzipped!".to_string()); // Remove the original zipped file @@ -120,11 +205,39 @@ async fn handle_install(version: &Option, mono: &bool) { ); println!( "Use {} {} to start.", - yellow.apply_to("godo run"), - green.apply_to(&tag) + cmd.apply_to("godo run"), + green.apply_to(ver.tag()) ); + println!(); + + if ver.mono() { + let non_mono_ver = version::new(ver.tag(), false); + if installed.contains(&non_mono_ver) { + println!( + "Non-Mono version {} is detected.", + cyan.apply_to(non_mono_ver.short_name()) + ); + println!( + "Mono version contains {} within non-mono ones.", + yellow.apply_to("All Features") + ); + + if Confirm::new() + .with_prompt("Uninstall the non-mono version?") + .default(true) + .show_default(true) + .wait_for_newline(true) + .interact() + .unwrap() + { + handle_uninstall(&Some(non_mono_ver.tag()), &false); + } else { + println!("{}", green.apply_to("Done!")) + } + } + } } else { - println!("{}", red.apply_to("Installation aborted")) + println!("{}", red.apply_to("Aborted.")) } } None => { @@ -136,3 +249,30 @@ async fn handle_install(version: &Option, mono: &bool) { } }; } + +fn handle_uninstall(version: &Option, mono: &bool) { + let red = Style::new().red().bold(); + + if let Some(ver) = utils::search_installed_version(version, Some(*mono)) { + let mut proc = procedure::new(2); + + proc.next("The following version is about to be uninstalled:".to_string()); + println!("\t> {} <", red.apply_to(ver.version_name())); + if Confirm::new() + .with_prompt("Do you want to proceed?") + .default(true) + .show_default(true) + .wait_for_newline(true) + .interact() + .unwrap() + { + proc.next("Uninstalling".to_string()); + utils::uninstall_version(ver); + proc.finish("Uninstalled!".to_string()); + } else { + println!("{}", red.apply_to("Aborted.")) + } + } else { + println!("{}", red.apply_to("No installed version found.")); + } +} diff --git a/src/remote.rs b/src/remote.rs index cf2dc30..76481fd 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -3,7 +3,7 @@ use std::{ env::consts, fs, io::{self, BufReader, Write}, - iter, + path::{Path, PathBuf}, }; use console::{style, Style}; @@ -11,7 +11,10 @@ use indicatif::{ProgressBar, ProgressStyle}; use reqwest::Client; use serde::Deserialize; -use crate::utils; +use crate::{ + utils::{self}, + version::{self, Version}, +}; macro_rules! err { ($title:tt, $content:expr) => { @@ -96,13 +99,7 @@ pub async fn list_avail(client: &Client, prerelease: bool) { ) .unwrap(); writer - .write( - format!( - "{}", - dim.apply_to(iter::repeat("=").take(60).collect::() + "\n") - ) - .as_bytes(), - ) + .write(format!("{}", dim.apply_to("=".repeat(60) + "\n")).as_bytes()) .unwrap(); let mut ver4: Vec = vec![]; let mut ver4_pre: Vec = vec![]; @@ -170,13 +167,7 @@ pub async fn list_avail(client: &Client, prerelease: bool) { ) .unwrap(); writer - .write( - format!( - "{}", - dim.apply_to(iter::repeat("=").take(30).collect::() + "\n") - ) - .as_bytes(), - ) + .write(format!("{}", dim.apply_to("=".repeat(30) + "\n")).as_bytes()) .unwrap(); let mut ver4: Vec = vec![]; let mut ver3: Vec = vec![]; @@ -215,32 +206,36 @@ pub async fn list_avail(client: &Client, prerelease: bool) { writer.flush().unwrap(); } -pub async fn get_download_info( +pub async fn search_remote_version( client: &Client, - version: String, + version: &Option, mono: bool, -) -> Option<(String, String)> { - let releases = get_all_releases( - client, - version.contains("-") && !version.ends_with("stable"), - ) - .await; - let result = { - let mut idx = 0; - let mut flag = false; - for item in &releases { - if item.tag_name.starts_with(version.as_str()) { - flag = true; - break; +) -> Option<(Version, String)> { + let result: usize; + let releases: Vec; + if let Some(ver) = version { + releases = get_all_releases(client, ver.contains("-") && !ver.ends_with("stable")).await; + result = { + let mut idx = 0; + let mut flag = false; + for item in &releases { + if item.tag_name.starts_with(ver) { + flag = true; + break; + } + idx += 1; } - idx += 1; - } - if flag { - idx - } else { - return None; - } + if flag { + idx + } else { + return None; + } + }; + } else { + releases = get_all_releases(client, false).await; + result = 0; }; + for item in &releases[result].assets { if item.name.contains(match consts::OS { "windows" => "win", @@ -274,7 +269,7 @@ pub async fn get_download_info( }) && item.name.contains("mono") == mono { return Some(( - releases[result].tag_name.clone(), + version::new(releases[result].tag_name.to_string(), mono), item.browser_download_url.clone(), )); } @@ -346,7 +341,7 @@ pub async fn download(client: &Client, file_name: String, url: String) -> String path } -pub fn unzip(path: &String) { +pub fn unzip(path: &String, mono: bool) { let mut zip = zip::ZipArchive::new(BufReader::new( fs::OpenOptions::new() .read(true) @@ -354,6 +349,29 @@ pub fn unzip(path: &String) { .unwrap_or_else(|err| err!("Error with zipped file: ", err.to_string())), )) .unwrap(); - zip.extract(path.trim_end_matches(".zip")) + + let target_path = if !mono { + path.trim_end_matches(".zip").to_string() + } else { + format!("{}_temp", path.trim_end_matches(".zip")) + }; + zip.extract(&target_path) .unwrap_or_else(|err| err!("Error when unzipping: ", err.to_string())); + + if mono { + let mut subpath: Option = None; + for dir in fs::read_dir(&target_path).unwrap() { + let path = dir.unwrap().path(); + if path.is_dir() { + subpath = Some(path); + break; + } + } + + if let Some(sp) = subpath { + let root = Path::new(path.trim_end_matches(".zip")); + fs::rename(sp, root).unwrap_or_else(|err| panic!("{}", err.to_string())); + fs::remove_dir_all(target_path).unwrap(); + } + } } diff --git a/src/utils.rs b/src/utils.rs index bf641b6..6ae7f91 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,7 @@ use console::style; -use std::fs; +use std::{cmp::Ordering, fs, path::Path}; + +use crate::version::{self, Version}; pub const INSTALL_PATH: &str = "downloads"; @@ -13,22 +15,6 @@ macro_rules! err { }; } -pub fn get_dir_name(tag: &String, mono: &bool) -> String { - let mut result = format!("Godot_{}", tag); - if *mono { - result += "_mono"; - } - result -} - -pub fn get_version_name(tag: &String, mono: &bool) -> String { - let mut result = format!("Godot {}", tag); - if *mono { - result += " mono"; - } - result -} - pub fn create_install_path() { fs::create_dir_all(INSTALL_PATH) .unwrap_or_else(|err| err!("Unable to create install directory: ", err.to_string())); @@ -47,3 +33,117 @@ pub fn get_installed_dirs() -> Vec { } installs } + +pub fn get_installed_versions() -> Vec { + let mut vers = vec![]; + for dir in get_installed_dirs() { + if let Some(ver) = version::parse(dir) { + vers.push(ver); + } + } + vers +} + +pub fn get_executables(dir: String) -> Vec { + let mut files = vec![]; + + for dir_result in fs::read_dir(Path::new(INSTALL_PATH).join(dir)).unwrap() { + let dir = dir_result.unwrap(); + let path = dir.path(); + if path.is_file() { + files.push(path.to_str().unwrap().to_owned()); + } + } + + files +} + +pub fn get_executable(dir: String, console: bool) -> Option { + let files = get_executables(dir); + let mut result: Option = None; + for file in files { + if !file.ends_with(".exe") { + continue; + } + + if let Some(ref res) = result { + if console { + if res.contains("console") && !file.contains("console") { + result = Some(file); + } + } else { + if !res.contains("console") && file.contains("console") { + result = Some(file); + } + } + } else { + result = Some(file); + } + } + result +} + +pub fn search_installed_version(keyword: &Option, mono: Option) -> Option { + let dirs: Vec = get_installed_dirs(); + match keyword { + Some(version) => { + // Search based on the keyword + search_installed_version_with_dirs(mono, dirs, |ver| ver.tag().starts_with(version)) + } + None => { + // No keyword, find the latest stable version + search_installed_version_with_dirs(mono, dirs, |_ver| true) + } + } +} + +fn search_installed_version_with_dirs( + mono: Option, + dirs: Vec, + condition: F, +) -> Option +where + F: Fn(&Version) -> bool, +{ + let mut result: Option = None; + for dir in dirs { + if let Some(ver) = version::parse(dir) { + // Fit the keyword + if condition(&ver) { + if let Some(mono_flag) = mono { + if mono_flag != ver.mono() { + continue; + } + } + + if let Some(ref cur_ver) = result { + if cur_ver.tag().ends_with("stable") && !ver.tag().ends_with("stable") { + continue; + } + + match version::compare(ver.tag(), cur_ver.tag()) { + Ordering::Equal => { + if !cur_ver.mono() && ver.mono() { + result = Some(ver); + } + } + Ordering::Greater => { + result = Some(ver); + } + Ordering::Less => { + continue; + } + } + } else { + result = Some(ver); + } + } + } + } + result +} + +pub fn uninstall_version(version: Version) { + fs::remove_dir_all(Path::new(INSTALL_PATH).join(version.dir_name())) + .unwrap_or_else(|err| err!("Error when uninstalling:", err.to_string())); +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..860ad6f --- /dev/null +++ b/src/version.rs @@ -0,0 +1,78 @@ +use std::cmp::Ordering; + +#[derive(PartialEq)] +pub struct Version { + tag: String, + mono: bool, +} + +pub fn new(tag: String, mono: bool) -> Version { + Version { tag, mono } +} + +pub fn parse(name: String) -> Option { + let results: Vec<&str> = name.split(&[' ', '_']).collect(); + if results.len() < 2 { + None + } else { + Some(Version { + tag: results[1].to_string(), + mono: results.len() >= 3, + }) + } +} + +pub fn compare(tag1: String, tag2: String) -> Ordering { + let mut v1: Vec<&str> = tag1.split('.').collect(); + while v1.len() < 3 { + v1.push("0"); + } + let mut v2: Vec<&str> = tag2.split('.').collect(); + while v2.len() < 3 { + v2.push("0"); + } + + let mut cmp = v1[0].cmp(v2[0]); + if cmp != Ordering::Equal { + return cmp; + } + cmp = v1[1].cmp(v2[1]); + if cmp != Ordering::Equal { + return cmp; + } + cmp = v1[2].cmp(v2[2]); + if cmp != Ordering::Equal { + return cmp; + } + Ordering::Equal +} + +impl Version { + pub fn dir_name(&self) -> String { + let mut result = format!("Godot_{}", self.tag); + if self.mono { + result += "_mono"; + } + result + } + + pub fn version_name(&self) -> String { + format!("Godot {}", self.short_name()) + } + + pub fn short_name(&self) -> String { + let mut result = format!("{}", self.tag); + if self.mono { + result += " mono"; + } + result + } + + pub fn tag(&self) -> String { + self.tag.clone() + } + + pub fn mono(&self) -> bool { + self.mono + } +}