cli: make credentials test interactive; add credentials test command

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia87db118ca58c66841265f34253b08ed6a6a6964
This commit is contained in:
raf 2026-05-01 23:33:06 +03:00
commit d2d6b7c421
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
6 changed files with 357 additions and 39 deletions

View file

@ -433,6 +433,8 @@ pub struct CredentialsArgs {
pub enum CredentialsSubcommand { pub enum CredentialsSubcommand {
/// Set API credentials /// Set API credentials
Set(CredentialsSetArgs), Set(CredentialsSetArgs),
/// Test stored API credentials
Test,
} }
#[derive(Args)] #[derive(Args)]

View file

@ -1,68 +1,153 @@
use std::time::Duration;
use crate::{ use crate::{
error::{PakkerError, Result}, error::{PakkerError, Result},
http,
model::{PakkerCredentialsFile, set_keyring_secret}, model::{PakkerCredentialsFile, set_keyring_secret},
ui_utils::{prompt_secret, prompt_yes_no},
}; };
pub fn execute( pub async fn execute(
curseforge_api_key: Option<String>, curseforge_api_key: Option<String>,
modrinth_token: Option<String>, modrinth_token: Option<String>,
github_access_token: Option<String>, github_access_token: Option<String>,
) -> Result<()> { ) -> Result<()> {
let mut creds = PakkerCredentialsFile::load()?; let mut cf_key = curseforge_api_key;
let updated_any = curseforge_api_key.is_some() let mut mr_token = modrinth_token;
|| modrinth_token.is_some() let mut gh_token = github_access_token;
|| github_access_token.is_some();
if let Some(key) = curseforge_api_key { let any_cli_args =
let key = key.trim().to_string(); cf_key.is_some() || mr_token.is_some() || gh_token.is_some();
if key.is_empty() {
return Err(PakkerError::InternalError( // Enter interactive mode when no CLI args provided
"CurseForge API key cannot be empty".to_string(), 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..."); if let Some(token) = prompt_secret("Modrinth token (press Enter to skip)")?
set_keyring_secret("curseforge_api_key", &key)?; {
creds.curseforge_api_key = Some(key); mr_token = Some(token);
}
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(),
));
} }
println!("Setting Modrinth token..."); if let Some(token) =
set_keyring_secret("modrinth_token", &token)?; prompt_secret("GitHub access token (press Enter to skip)")?
creds.modrinth_token = Some(token); {
} gh_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(),
));
} }
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 { if !updated_any {
println!( println!("No credentials to save.");
"No credentials provided. Use --cf-api-key, --modrinth-token, or \
--gh-access-token."
);
return Ok(()); 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()?; creds.save()?;
println!("Credentials saved."); println!();
if verified.is_empty() {
println!("Credentials saved (unverified).");
} else {
println!("Credentials saved and verified: {}", verified.join(", "));
}
println!( println!(
"Credentials file: {}", "Credentials file: {}",
PakkerCredentialsFile::get_path()?.display() PakkerCredentialsFile::get_path()?.display()
@ -71,3 +156,61 @@ pub fn execute(
Ok(()) 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()
)))
}
}

View file

@ -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()
)))
}
}

View file

@ -4,6 +4,7 @@ pub mod cfg;
pub mod cfg_prj; pub mod cfg_prj;
pub mod credentials; pub mod credentials;
pub mod credentials_set; pub mod credentials_set;
pub mod credentials_test;
pub mod diff; pub mod diff;
pub mod export; pub mod export;
pub mod fetch; pub mod fetch;

View file

@ -188,6 +188,10 @@ async fn main() -> Result<(), PakkerError> {
set_args.modrinth_token, set_args.modrinth_token,
set_args.gh_access_token, set_args.gh_access_token,
) )
.await
},
Some(cli::CredentialsSubcommand::Test) => {
cli::commands::credentials_test::execute().await
}, },
None => { None => {
cli::commands::credentials::execute( cli::commands::credentials::execute(

View file

@ -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<Option<String>> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;