diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b61bd1a..468d5164e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ #### 🚀 Updates +- Added a `proto tools` command for listing all installed tools and their versions. - Added an `http` setting to `~/.proto/config.toml` to control proxies and certificates when making http/https requests, primarily for downloading tools. - New `allow-invalid-certs` setting for allowing invalid certificates (be careful). - New `proxies` setting for customizing internal proxy URLs. diff --git a/Cargo.lock b/Cargo.lock index 0248f5267..5a2ebd3c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -445,9 +445,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.30" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "defd4e7873dbddba6c7c91e199c7fcb946abc4a6a4ac3195400bcfb01b5de877" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" dependencies = [ "android-tzdata", "iana-time-zone", @@ -2314,6 +2314,7 @@ dependencies = [ name = "proto_cli" version = "0.17.1" dependencies = [ + "chrono", "clap 4.4.3", "clap_complete", "convert_case", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1f51b51ee..1e3860a33 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -31,6 +31,7 @@ proto_core = { version = "0.17.4", path = "../core" } proto_pdk_api = { version = "0.7.1", path = "../pdk-api" } proto_schema_plugin = { version = "0.11.4", path = "../schema-plugin" } proto_wasm_plugin = { version = "0.6.5", path = "../wasm-plugin" } +chrono = "0.4.31" clap = { workspace = true, features = ["derive", "env"] } clap_complete = { workspace = true } convert_case = { workspace = true } diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 658cf8bf8..b138c83c9 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -1,7 +1,8 @@ use crate::commands::{ AddPluginArgs, AliasArgs, BinArgs, CleanArgs, CompletionsArgs, GlobalArgs, InstallArgs, InstallGlobalArgs, ListArgs, ListGlobalArgs, ListRemoteArgs, LocalArgs, PluginsArgs, - RemovePluginArgs, RunArgs, SetupArgs, UnaliasArgs, UninstallArgs, UninstallGlobalArgs, + RemovePluginArgs, RunArgs, SetupArgs, ToolsArgs, UnaliasArgs, UninstallArgs, + UninstallGlobalArgs, }; use clap::builder::styling::{Color, Style, Styles}; use clap::{Parser, Subcommand, ValueEnum}; @@ -191,6 +192,9 @@ pub enum Commands { #[command(name = "setup", about = "Setup proto for your current shell.")] Setup(SetupArgs), + #[command(name = "tools", about = "List all installed tools and their versions.")] + Tools(ToolsArgs), + #[command(alias = "ua", name = "unalias", about = "Remove an alias from a tool.")] Unalias(UnaliasArgs), diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index cf101e21e..857bf6061 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -15,6 +15,7 @@ mod plugins; mod remove_plugin; mod run; mod setup; +mod tools; mod unalias; mod uninstall; mod uninstall_global; @@ -37,6 +38,7 @@ pub use plugins::*; pub use remove_plugin::*; pub use run::*; pub use setup::*; +pub use tools::*; pub use unalias::*; pub use uninstall::*; pub use uninstall_global::*; diff --git a/crates/cli/src/commands/plugins.rs b/crates/cli/src/commands/plugins.rs index 3d7f37755..5b4af2ab0 100644 --- a/crates/cli/src/commands/plugins.rs +++ b/crates/cli/src/commands/plugins.rs @@ -1,22 +1,14 @@ +use crate::helpers::load_configured_tools; use clap::Args; use miette::IntoDiagnostic; -use proto_core::{ - load_tool_from_locator, Id, PluginLocator, ProtoEnvironment, ToolsConfig, UserConfig, -}; +use proto_core::{Id, PluginLocator}; use serde::Serialize; use starbase::system; -use starbase_styles::color; +use starbase_styles::color::{self, OwoStyle}; use starbase_utils::json; -use std::collections::HashMap; -use tracing::debug; - -fn render_entry>(label: &str, value: V) { - println!( - " {} {}", - color::muted_light(format!("{label}:")), - value.as_ref() - ); -} +use std::collections::HashSet; +use std::io::{BufWriter, Write}; +use tracing::info; #[derive(Serialize)] pub struct PluginItem { @@ -34,30 +26,21 @@ pub struct PluginsArgs { #[system] pub async fn plugins(args: ArgsRef) { - let proto = ProtoEnvironment::new()?; - let user_config = UserConfig::load()?; - - let mut tools_config = ToolsConfig::load_upwards()?; - tools_config.inherit_builtin_plugins(); - - let mut plugins = HashMap::new(); - plugins.extend(&user_config.plugins); - plugins.extend(&tools_config.plugins); - - debug!("Loading plugins"); + if !args.json { + info!("Loading plugins..."); + } let mut items = vec![]; - for (id, locator) in plugins { - let tool = load_tool_from_locator(&id, &proto, &locator, &user_config).await?; - + load_configured_tools(HashSet::new(), |tool, locator| { items.push(PluginItem { - id: id.to_owned(), - locator: locator.to_owned(), + id: tool.id.to_owned(), + locator, name: tool.metadata.name, version: tool.metadata.plugin_version, }); - } + }) + .await?; items.sort_by(|a, d| a.id.cmp(&d.id)); @@ -67,36 +50,59 @@ pub async fn plugins(args: ArgsRef) { return Ok(()); } + let stdout = std::io::stdout(); + let mut buffer = BufWriter::new(stdout.lock()); + for item in items { - println!( - "{} {} {} {}", - color::id(item.id), + writeln!( + buffer, + "{} {} {}", + OwoStyle::new().bold().style(color::id(item.id)), color::muted("-"), - item.name, color::muted_light(if let Some(version) = item.version { - format!("v{version}") + format!("{} v{version}", item.name) } else { - "".into() + item.name }) - ); + ) + .unwrap(); match item.locator { PluginLocator::SourceFile { path, .. } => { - render_entry("Source", color::path(path.canonicalize().unwrap())); + writeln!( + buffer, + " Source: {}", + color::path(path.canonicalize().unwrap()) + ) + .unwrap(); } PluginLocator::SourceUrl { url } => { - render_entry("Source", color::url(url)); + writeln!(buffer, " Source: {}", color::url(url)).unwrap(); } PluginLocator::GitHub(github) => { - render_entry("GitHub", color::label(&github.repo_slug)); - render_entry("Tag", github.tag.as_deref().unwrap_or("latest")); + writeln!(buffer, " GitHub: {}", color::label(&github.repo_slug)).unwrap(); + + writeln!( + buffer, + " Tag: {}", + color::hash(github.tag.as_deref().unwrap_or("latest")), + ) + .unwrap(); } PluginLocator::Wapm(wapm) => { - render_entry("Package", color::label(&wapm.package_name)); - render_entry("Version", wapm.version.as_deref().unwrap_or("latest")); + writeln!(buffer, " Package: {}", color::label(&wapm.package_name)).unwrap(); + + writeln!( + buffer, + " Version: {}", + color::hash(wapm.version.as_deref().unwrap_or("latest")), + ) + .unwrap(); } }; - println!(); + writeln!(buffer).unwrap(); } + + buffer.flush().unwrap(); } diff --git a/crates/cli/src/commands/tools.rs b/crates/cli/src/commands/tools.rs new file mode 100644 index 000000000..efb78f74a --- /dev/null +++ b/crates/cli/src/commands/tools.rs @@ -0,0 +1,146 @@ +use crate::helpers::load_configured_tools; +use chrono::{DateTime, NaiveDateTime}; +use clap::Args; +use miette::IntoDiagnostic; +use proto_core::Id; +use starbase::system; +use starbase_styles::color::{self, OwoStyle}; +use starbase_utils::json; +use std::collections::{HashMap, HashSet}; +use std::io::{BufWriter, Write}; +use std::process; +use tracing::info; + +#[derive(Args, Clone, Debug)] +pub struct ToolsArgs { + #[arg(help = "IDs of tool to list")] + id: Vec, + + #[arg(long, help = "Print the list in JSON format")] + json: bool, +} + +#[system] +pub async fn tools(args: ArgsRef) { + if !args.json { + info!("Loading tools..."); + } + + let mut tools = vec![]; + + load_configured_tools(HashSet::from_iter(&args.id), |tool, _| { + if !tool.manifest.installed_versions.is_empty() { + tools.push(tool); + } + }) + .await?; + + tools.sort_by(|a, d| a.id.cmp(&d.id)); + + if tools.is_empty() { + eprintln!("No installed tools"); + process::exit(1); + } + + if args.json { + let items = tools + .into_iter() + .map(|t| (t.id, t.manifest)) + .collect::>(); + + println!("{}", json::to_string_pretty(&items).into_diagnostic()?); + + return Ok(()); + } + + let stdout = std::io::stdout(); + let mut buffer = BufWriter::new(stdout.lock()); + + for tool in tools { + writeln!( + buffer, + "{} {} {}", + OwoStyle::new().bold().style(color::id(&tool.id)), + color::muted("-"), + color::muted_light(&tool.metadata.name), + ) + .unwrap(); + + writeln!(buffer, " Store: {}", color::path(tool.get_inventory_dir())).unwrap(); + + if !tool.manifest.aliases.is_empty() { + writeln!(buffer, " Aliases:").unwrap(); + + for (alias, version) in &tool.manifest.aliases { + writeln!( + buffer, + " {} {} {}", + color::hash(version.to_string()), + color::muted("="), + color::label(alias), + ) + .unwrap(); + } + } + + if !tool.manifest.installed_versions.is_empty() { + writeln!(buffer, " Versions:").unwrap(); + + let mut versions = tool.manifest.installed_versions.iter().collect::>(); + versions.sort(); + + for version in versions { + let mut comments = vec![]; + let mut is_default = false; + + if let Some(meta) = &tool.manifest.versions.get(version) { + if let Some(at) = create_datetime(meta.installed_at) { + comments.push(format!("installed {}", at.format("%x"))); + } + + if let Some(last_used) = &meta.last_used_at { + if let Some(at) = create_datetime(*last_used) { + comments.push(format!("last used {}", at.format("%x"))); + } + } + } + + if tool + .manifest + .default_version + .as_ref() + .is_some_and(|dv| dv == &version.to_unresolved_spec()) + { + comments.push("default version".into()); + is_default = true; + } + + if comments.is_empty() { + writeln!(buffer, " {}", color::hash(version.to_string())).unwrap(); + } else { + writeln!( + buffer, + " {} {} {}", + if is_default { + color::symbol(version.to_string()) + } else { + color::hash(version.to_string()) + }, + color::muted("-"), + color::muted_light(comments.join(", ")) + ) + .unwrap(); + } + } + } + + writeln!(buffer).unwrap(); + } + + buffer.flush().unwrap(); +} + +fn create_datetime(millis: u128) -> Option { + DateTime::from_timestamp((millis / 1000) as i64, ((millis % 1000) * 1_000_000) as u32) + .map(|dt| dt.naive_local()) +} diff --git a/crates/cli/src/helpers.rs b/crates/cli/src/helpers.rs index 2a544d5ee..5a01ddb82 100644 --- a/crates/cli/src/helpers.rs +++ b/crates/cli/src/helpers.rs @@ -1,8 +1,12 @@ use futures::StreamExt; use indicatif::{ProgressBar, ProgressStyle}; -use proto_core::{get_temp_dir, ProtoError}; +use proto_core::{ + get_temp_dir, load_tool_from_locator, Id, PluginLocator, ProtoEnvironment, ProtoError, Tool, + ToolsConfig, UserConfig, +}; use starbase_utils::fs; use std::cmp; +use std::collections::{HashMap, HashSet}; use std::env; use std::io::Write; use std::path::PathBuf; @@ -83,3 +87,31 @@ pub async fn download_to_temp_with_progress_bar( Ok(temp_file) } + +pub async fn load_configured_tools( + filter: HashSet<&Id>, + mut op: impl FnMut(Tool, PluginLocator), +) -> miette::Result<()> { + let proto = ProtoEnvironment::new()?; + let mut user_config = UserConfig::load()?; + + let mut tools_config = ToolsConfig::load_upwards()?; + tools_config.inherit_builtin_plugins(); + + let mut plugins = HashMap::new(); + plugins.extend(std::mem::take(&mut user_config.plugins)); + plugins.extend(tools_config.plugins); + + for (id, locator) in plugins { + if !filter.is_empty() && !filter.contains(&id) { + continue; + } + + op( + load_tool_from_locator(&id, &proto, &locator, &user_config).await?, + locator, + ); + } + + Ok(()) +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index ec08df6c3..5ec97ab4f 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -57,6 +57,7 @@ async fn main() -> MainResult { Commands::RemovePlugin(args) => app.execute_with_args(commands::remove_plugin, args), Commands::Run(args) => app.execute_with_args(commands::run, args), Commands::Setup(args) => app.execute_with_args(commands::setup, args), + Commands::Tools(args) => app.execute_with_args(commands::tools, args), Commands::Unalias(args) => app.execute_with_args(commands::unalias, args), Commands::Uninstall(args) => app.execute_with_args(commands::uninstall, args), Commands::UninstallGlobal(args) => app.execute_with_args(commands::uninstall_global, args),