From d2d6b7c42182a36abdc80f761f99d7d9d85d8a10 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 1 May 2026 23:33:06 +0300 Subject: [PATCH] cli: make `credentials test` interactive; add `credentials test` command Signed-off-by: NotAShelf Change-Id: Ia87db118ca58c66841265f34253b08ed6a6a6964 --- src/cli.rs | 2 + src/cli/commands/credentials_set.rs | 227 ++++++++++++++++++++++----- src/cli/commands/credentials_test.rs | 151 ++++++++++++++++++ src/cli/commands/mod.rs | 1 + src/main.rs | 4 + src/ui_utils.rs | 17 ++ 6 files changed, 360 insertions(+), 42 deletions(-) create mode 100644 src/cli/commands/credentials_test.rs diff --git a/src/cli.rs b/src/cli.rs index e222046..cd48900 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -433,6 +433,8 @@ pub struct CredentialsArgs { pub enum CredentialsSubcommand { /// Set API credentials Set(CredentialsSetArgs), + /// Test stored API credentials + Test, } #[derive(Args)] diff --git a/src/cli/commands/credentials_set.rs b/src/cli/commands/credentials_set.rs index 8cd240e..0945309 100644 --- a/src/cli/commands/credentials_set.rs +++ b/src/cli/commands/credentials_set.rs @@ -1,68 +1,153 @@ +use std::time::Duration; + use crate::{ error::{PakkerError, Result}, + http, model::{PakkerCredentialsFile, set_keyring_secret}, + ui_utils::{prompt_secret, prompt_yes_no}, }; -pub fn execute( +pub async fn execute( curseforge_api_key: Option, modrinth_token: Option, github_access_token: Option, ) -> Result<()> { - let mut creds = PakkerCredentialsFile::load()?; - let updated_any = curseforge_api_key.is_some() - || modrinth_token.is_some() - || github_access_token.is_some(); + let mut cf_key = curseforge_api_key; + let mut mr_token = modrinth_token; + let mut gh_token = github_access_token; - if let Some(key) = curseforge_api_key { - let key = key.trim().to_string(); - if key.is_empty() { - return Err(PakkerError::InternalError( - "CurseForge API key cannot be empty".to_string(), - )); + let any_cli_args = + cf_key.is_some() || mr_token.is_some() || gh_token.is_some(); + + // Enter interactive mode when no CLI args provided + if !any_cli_args { + println!("No credentials provided via command line."); + println!(); + + if let Some(key) = + prompt_secret("CurseForge API key (press Enter to skip)")? + { + cf_key = Some(key); } - println!("Setting CurseForge API key..."); - set_keyring_secret("curseforge_api_key", &key)?; - creds.curseforge_api_key = Some(key); - } - - if let Some(token) = modrinth_token { - let token = token.trim().to_string(); - if token.is_empty() { - return Err(PakkerError::InternalError( - "Modrinth token cannot be empty".to_string(), - )); + if let Some(token) = prompt_secret("Modrinth token (press Enter to skip)")? + { + mr_token = Some(token); } - println!("Setting Modrinth token..."); - set_keyring_secret("modrinth_token", &token)?; - creds.modrinth_token = Some(token); - } - - if let Some(token) = github_access_token { - let token = token.trim().to_string(); - if token.is_empty() { - return Err(PakkerError::InternalError( - "GitHub access token cannot be empty".to_string(), - )); + if let Some(token) = + prompt_secret("GitHub access token (press Enter to skip)")? + { + gh_token = Some(token); } - - println!("Setting GitHub access token..."); - set_keyring_secret("github_access_token", &token)?; - creds.github_access_token = Some(token); } + let updated_any = + cf_key.is_some() || mr_token.is_some() || gh_token.is_some(); + if !updated_any { - println!( - "No credentials provided. Use --cf-api-key, --modrinth-token, or \ - --gh-access-token." - ); + println!("No credentials to save."); return Ok(()); } + // Verify credentials before saving + let client = http::create_http_client(); + let mut verified = Vec::new(); + + if let Some(ref key) = cf_key { + print!("Verifying CurseForge API key... "); + match verify_curseforge(&client, key).await { + Ok(()) => { + println!("valid"); + verified.push("CurseForge"); + }, + Err(e) => { + println!("failed ({e})"); + if !prompt_yes_no( + "CurseForge key appears invalid. Save anyway?", + false, + false, + )? { + cf_key = None; + } + }, + } + } + + if let Some(ref token) = mr_token { + print!("Verifying Modrinth token... "); + match verify_modrinth(&client, token).await { + Ok(()) => { + println!("valid"); + verified.push("Modrinth"); + }, + Err(e) => { + println!("failed ({e})"); + if !prompt_yes_no( + "Modrinth token appears invalid. Save anyway?", + false, + false, + )? { + mr_token = None; + } + }, + } + } + + if let Some(ref token) = gh_token { + print!("Verifying GitHub access token... "); + match verify_github(&client, token).await { + Ok(()) => { + println!("valid"); + verified.push("GitHub"); + }, + Err(e) => { + println!("failed ({e})"); + if !prompt_yes_no( + "GitHub token appears invalid. Save anyway?", + false, + false, + )? { + gh_token = None; + } + }, + } + } + + let mut creds = PakkerCredentialsFile::load()?; + + if let Some(key) = cf_key { + let key = key.trim().to_string(); + if !key.is_empty() { + set_keyring_secret("curseforge_api_key", &key)?; + creds.curseforge_api_key = Some(key); + } + } + + if let Some(token) = mr_token { + let token = token.trim().to_string(); + if !token.is_empty() { + set_keyring_secret("modrinth_token", &token)?; + creds.modrinth_token = Some(token); + } + } + + if let Some(token) = gh_token { + let token = token.trim().to_string(); + if !token.is_empty() { + set_keyring_secret("github_access_token", &token)?; + creds.github_access_token = Some(token); + } + } + creds.save()?; - println!("Credentials saved."); + println!(); + if verified.is_empty() { + println!("Credentials saved (unverified)."); + } else { + println!("Credentials saved and verified: {}", verified.join(", ")); + } println!( "Credentials file: {}", PakkerCredentialsFile::get_path()?.display() @@ -71,3 +156,61 @@ pub fn execute( Ok(()) } + +async fn verify_curseforge( + client: &reqwest::Client, + api_key: &str, +) -> Result<()> { + let response = client + .get("https://api.curseforge.com/v1/mods/238222") + .header("x-api-key", api_key) + .timeout(Duration::from_secs(10)) + .send() + .await?; + + if response.status().is_success() { + Ok(()) + } else { + Err(PakkerError::PlatformApiError(format!( + "HTTP {}", + response.status() + ))) + } +} + +async fn verify_modrinth(client: &reqwest::Client, token: &str) -> Result<()> { + let response = client + .get("https://api.modrinth.com/v2/user") + .header("Authorization", token) + .timeout(Duration::from_secs(10)) + .send() + .await?; + + if response.status().is_success() { + Ok(()) + } else { + Err(PakkerError::PlatformApiError(format!( + "HTTP {}", + response.status() + ))) + } +} + +async fn verify_github(client: &reqwest::Client, token: &str) -> Result<()> { + let response = client + .get("https://api.github.com/user") + .header("Authorization", format!("Bearer {token}")) + .header("User-Agent", "pakker") + .timeout(Duration::from_secs(10)) + .send() + .await?; + + if response.status().is_success() { + Ok(()) + } else { + Err(PakkerError::PlatformApiError(format!( + "HTTP {}", + response.status() + ))) + } +} diff --git a/src/cli/commands/credentials_test.rs b/src/cli/commands/credentials_test.rs new file mode 100644 index 0000000..7c3227b --- /dev/null +++ b/src/cli/commands/credentials_test.rs @@ -0,0 +1,151 @@ +use std::time::Duration; + +use indicatif::{ProgressBar, ProgressStyle}; +use yansi::Paint; + +use crate::{error::Result, http, model::credentials::ResolvedCredentials}; + +pub async fn execute() -> Result<()> { + let creds = ResolvedCredentials::load(); + let client = http::create_http_client(); + + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.green} {msg}") + .expect("spinner template is valid"), + ); + spinner.enable_steady_tick(Duration::from_millis(80)); + spinner.set_message("Testing credentials..."); + + let mut all_valid = true; + let mut results = Vec::new(); + + // Test CurseForge + if let Some(key) = creds.curseforge_api_key() { + spinner.set_message("Testing CurseForge API key..."); + match test_curseforge(&client, key).await { + Ok(()) => results.push(("CurseForge API Key", true, None)), + Err(e) => { + results.push(("CurseForge API Key", false, Some(e.to_string()))); + all_valid = false; + }, + } + } else { + results.push(("CurseForge API Key", false, None)); + } + + // Test Modrinth + if let Some(token) = creds.modrinth_token() { + spinner.set_message("Testing Modrinth token..."); + match test_modrinth(&client, token).await { + Ok(()) => results.push(("Modrinth Token", true, None)), + Err(e) => { + results.push(("Modrinth Token", false, Some(e.to_string()))); + all_valid = false; + }, + } + } else { + results.push(("Modrinth Token", false, None)); + } + + // Test GitHub + if let Some(token) = creds.github_access_token() { + spinner.set_message("Testing GitHub access token..."); + match test_github(&client, token).await { + Ok(()) => results.push(("GitHub Access Token", true, None)), + Err(e) => { + results.push(("GitHub Access Token", false, Some(e.to_string()))); + all_valid = false; + }, + } + } else { + results.push(("GitHub Access Token", false, None)); + } + + spinner.finish_and_clear(); + + println!("{}", "Credential Test Results:".cyan().bold()); + println!(); + + for (name, valid, error) in results { + if let Some(err) = error { + println!( + " {} {} ({err})", + format!("{name}:").yellow(), + "invalid".red() + ); + } else if valid { + println!(" {} {}", format!("{name}:").yellow(), "valid".green()); + } else { + println!(" {} {}", format!("{name}:").yellow(), "not configured"); + } + } + + println!(); + + if all_valid { + println!("{}", "All configured credentials are valid.".green()); + } else { + println!("{}", "Some credentials are invalid or expired.".red()); + println!("Use 'pakker credentials set' to update them."); + } + + Ok(()) +} + +async fn test_curseforge( + client: &reqwest::Client, + api_key: &str, +) -> Result<()> { + // Use a well-known mod (JEI) to verify key works for mod lookups + let response = client + .get("https://api.curseforge.com/v1/mods/238222") + .header("x-api-key", api_key) + .send() + .await?; + + if response.status().is_success() { + Ok(()) + } else { + Err(crate::error::PakkerError::PlatformApiError(format!( + "HTTP {}", + response.status() + ))) + } +} + +async fn test_modrinth(client: &reqwest::Client, token: &str) -> Result<()> { + let response = client + .get("https://api.modrinth.com/v2/user") + .header("Authorization", token) + .send() + .await?; + + if response.status().is_success() { + Ok(()) + } else { + Err(crate::error::PakkerError::PlatformApiError(format!( + "HTTP {}", + response.status() + ))) + } +} + +async fn test_github(client: &reqwest::Client, token: &str) -> Result<()> { + let response = client + .get("https://api.github.com/user") + .header("Authorization", format!("Bearer {token}")) + .header("User-Agent", "pakker") + .send() + .await?; + + if response.status().is_success() { + Ok(()) + } else { + Err(crate::error::PakkerError::PlatformApiError(format!( + "HTTP {}", + response.status() + ))) + } +} diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index 17cf859..97335d8 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -4,6 +4,7 @@ pub mod cfg; pub mod cfg_prj; pub mod credentials; pub mod credentials_set; +pub mod credentials_test; pub mod diff; pub mod export; pub mod fetch; diff --git a/src/main.rs b/src/main.rs index d451206..cf40834 100644 --- a/src/main.rs +++ b/src/main.rs @@ -188,6 +188,10 @@ async fn main() -> Result<(), PakkerError> { set_args.modrinth_token, set_args.gh_access_token, ) + .await + }, + Some(cli::CredentialsSubcommand::Test) => { + cli::commands::credentials_test::execute().await }, None => { cli::commands::credentials::execute( diff --git a/src/ui_utils.rs b/src/ui_utils.rs index 92647d8..2ca0afc 100644 --- a/src/ui_utils.rs +++ b/src/ui_utils.rs @@ -193,6 +193,23 @@ pub fn prompt_curseforge_api_key( } } +/// Prompt for a generic secret/token using a secure password input. +/// Returns the secret if provided, None if empty or cancelled. +pub fn prompt_secret(prompt: &str) -> io::Result> { + use dialoguer::Password; + + let secret: String = Password::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt) + .interact() + .map_err(io::Error::other)?; + + if secret.is_empty() { + Ok(None) + } else { + Ok(Some(secret)) + } +} + #[cfg(test)] mod tests { use super::*;