From 378c1f7c0d1c004d504ad8496d136ec381688f10 Mon Sep 17 00:00:00 2001 From: Maaz Khokhar Date: Fri, 10 Jul 2026 21:22:31 -0500 Subject: [PATCH] added credential management to keyring. cursor. --- Cargo.toml | 2 +- src/cli.rs | 218 ++++++++++++++++++++++++++++++++++++++++ src/config.rs | 163 ++++++++++++++++++++++++++---- src/credentials.rs | 65 ++++++++++++ src/main.rs | 69 ++----------- src/providers/github.rs | 35 ++++--- 6 files changed, 458 insertions(+), 94 deletions(-) create mode 100644 src/cli.rs create mode 100644 src/credentials.rs diff --git a/Cargo.toml b/Cargo.toml index a15499e..c9558a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,5 +13,5 @@ octocrab = "0.54.0" dialoguer = "0.12" keyring = "4.1.4" colored = "3.1.1" -toml = "1.1.2+spec-1.1.0" +toml = "1.1.2" dirs = "6.0.0" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..450c78b --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,218 @@ +use clap::{Parser, Subcommand}; +use colored::*; + +use crate::{ + config::{parse_config, Provider}, + credentials, + providers::Provider as ProviderTrait, + tui::user_select_repo, + CONFIG_PATH, +}; + +#[derive(Parser)] +#[command(name = "clonee", about = "Clone repositories from GitHub or Gitea")] +pub struct Cli { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand)] +pub enum Command { + /// Manage provider API tokens + Auth { + #[command(subcommand)] + command: AuthCommand, + }, +} + +#[derive(Subcommand)] +pub enum AuthCommand { + /// Store an API token in the system keychain + Set { + /// Friendly name of the provider from config.toml + friendly_name: String, + }, + /// Remove a stored API token from the system keychain + Remove { + /// Friendly name of the provider from config.toml + friendly_name: String, + }, +} + +pub async fn run(cli: Cli) { + match cli.command { + Some(Command::Auth { command }) => run_auth(command).await, + None => run_interactive().await, + } +} + +async fn run_auth(command: AuthCommand) { + let config_file = format!("{CONFIG_PATH}/config.toml"); + let config = match parse_config(config_file.clone()).await { + Ok(c) => c, + Err(e) => { + eprintln!("{}: {}", "Failed to parse config".red(), e); + std::process::exit(1); + } + }; + + match command { + AuthCommand::Set { friendly_name } => { + let provider = match find_provider(&config.provider, &friendly_name) { + Some(p) => p, + None => { + eprintln!( + "{}: no provider named '{}'", + "Error".red(), + friendly_name + ); + std::process::exit(1); + } + }; + + let token = match credentials::prompt_for_token(&friendly_name) { + Ok(t) => t, + Err(e) => { + eprintln!("{}: {}", "Failed to read token".red(), e); + std::process::exit(1); + } + }; + + if token.is_empty() { + eprintln!("{}", "Token cannot be empty.".red()); + std::process::exit(1); + } + + if let Err(e) = + credentials::store_token(provider.provider_type(), &friendly_name, &token) + { + eprintln!("{}: {}", "Failed to store token".red(), e); + std::process::exit(1); + } + + println!( + "Stored token for '{}' in the system keychain.", + friendly_name + ); + } + AuthCommand::Remove { friendly_name } => { + let provider = match find_provider(&config.provider, &friendly_name) { + Some(p) => p, + None => { + eprintln!( + "{}: no provider named '{}'", + "Error".red(), + friendly_name + ); + std::process::exit(1); + } + }; + + if let Err(e) = + credentials::delete_token(provider.provider_type(), &friendly_name) + { + eprintln!("{}: {}", "Failed to remove token".red(), e); + std::process::exit(1); + } + + println!( + "Removed token for '{}' from the system keychain.", + friendly_name + ); + } + } +} + +fn find_provider<'a>(providers: &'a [Provider], friendly_name: &str) -> Option<&'a Provider> { + providers + .iter() + .find(|p| p.friendly_name() == friendly_name) +} + +fn print_repositories_error(err: &dyn std::error::Error, friendly_name: &str) { + eprintln!("{}", "Failed to fetch repositories.".red()); + + let details = err.to_string(); + let is_auth_error = details.contains("401") + || details.contains("403") + || details.contains("Unauthorized") + || details.contains("Forbidden"); + + if is_auth_error { + eprintln!( + "{}", + "Your stored token may be invalid or expired.".yellow() + ); + eprintln!( + "Remove it with: clonee auth remove \"{friendly_name}\"" + ); + eprintln!("Or set a new one with: clonee auth set \"{friendly_name}\""); + } + + eprintln!("{}", details.dimmed()); +} + +pub async fn run_interactive() { + let config_file = format!("{CONFIG_PATH}/config.toml"); + let config = match parse_config(config_file.clone()).await { + Ok(c) => c, + Err(e) => { + let top = "Failed to parse config.".red(); + let bottom = "Maybe there's an error in the config?".dimmed(); + + println!("{}\n{}", top, bottom); + eprintln!("Error at {}: {}", config_file, e); + std::process::exit(1); + } + }; + + let selected_provider = crate::ask_option("Choose a provider:", config.provider); + let friendly_name = selected_provider.friendly_name().to_string(); + + let provider = match selected_provider.into_provider_client().await { + Ok(p) => p, + Err(e) => { + eprintln!("{}: {}", "Failed to initialize provider".red(), e); + std::process::exit(1); + } + }; + + let repos = match provider.repositories().await { + Ok(repos) => repos, + Err(e) => { + print_repositories_error(&*e, &friendly_name); + std::process::exit(1); + } + }; + + let selection = match user_select_repo(repos).await { + Ok(r) => r, + Err(_) => { + println!("{}", "No repository was selected!".red()); + std::process::exit(1); + } + }; + + match crate::ask_yes_no("Clone the repo?", Some(crate::DefaultAnswer::Yes)) { + true => { + let folder: Option = + if crate::ask_yes_no("Use a custom folder?", Some(crate::DefaultAnswer::No)) { + let custom = crate::get_input("Folder name: "); + Some(custom) + } else { + None + }; + + match provider.clone(selection, folder, false).await { + Ok(_) => println!("Repo cloned!"), + Err(_) => { + println!("{}", "Error cloning repo.".red()) + } + } + } + false => { + println!("Exiting..."); + std::process::exit(0); + } + } +} diff --git a/src/config.rs b/src/config.rs index 8058531..1ea18e3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,16 +1,15 @@ use std::path::PathBuf; +use crate::credentials; use crate::providers::Result; use serde::{Deserialize, Serialize}; use tokio::fs; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct Config { pub provider: Vec, } -// Added #[serde(tag = "type")] so Serde knows to look for a "type" field -// instead of nesting the entire structure inside the variant name. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type")] pub enum Provider { @@ -18,6 +17,23 @@ pub enum Provider { Github { friendly_name: String, username: String, + }, + #[serde(rename = "gitea")] + Gitea { + friendly_name: String, + base_url: String, + username: String, + }, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type")] +enum LegacyProvider { + #[serde(rename = "github")] + Github { + friendly_name: String, + username: String, + #[serde(default)] token: Option, }, #[serde(rename = "gitea")] @@ -25,10 +41,16 @@ pub enum Provider { friendly_name: String, base_url: String, username: String, + #[serde(default)] token: Option, }, } +#[derive(Deserialize)] +struct LegacyConfig { + provider: Vec, +} + pub enum ProviderClient { Github(crate::providers::github::Github), Gitea(crate::providers::gitea::Gitea), @@ -63,21 +85,45 @@ impl Provider { } } - pub fn into_provider_trait(self) -> ProviderClient { + pub fn provider_type(&self) -> &str { match self { - Provider::Github { - username, token, .. - } => { - todo!("Return ProviderClient::Github(...)") - } + Provider::Github { .. } => "github", + Provider::Gitea { .. } => "gitea", + } + } + + pub async fn resolve_token(&self) -> Result> { + let provider_type = self.provider_type(); + let friendly_name = self.friendly_name(); + + if let Some(token) = credentials::get_token(provider_type, friendly_name)? { + return Ok(Some(token)); + } + + if crate::ask_yes_no( + "Would you like to add a token? (to view private repos)", + Some(crate::DefaultAnswer::No), + ) { + credentials::prompt_and_store_token(provider_type, friendly_name) + } else { + Ok(None) + } + } + + pub async fn into_provider_client(self) -> Result { + let token = self.resolve_token().await?; + + match self { + Provider::Github { username, .. } => Ok(ProviderClient::Github( + crate::providers::github::Github::new(username, token), + )), Provider::Gitea { base_url, username, - token, .. - } => ProviderClient::Gitea(crate::providers::gitea::Gitea::new( + } => Ok(ProviderClient::Gitea(crate::providers::gitea::Gitea::new( base_url, username, token, - )), + ))), } } } @@ -88,18 +134,97 @@ impl std::fmt::Display for Provider { } } -pub async fn parse_config(file_path: String) -> Result { +impl From for Provider { + fn from(legacy: LegacyProvider) -> Self { + match legacy { + LegacyProvider::Github { + friendly_name, + username, + .. + } => Provider::Github { + friendly_name, + username, + }, + LegacyProvider::Gitea { + friendly_name, + base_url, + username, + .. + } => Provider::Gitea { + friendly_name, + base_url, + username, + }, + } + } +} + +fn expand_path(file_path: &str) -> PathBuf { let mut path = PathBuf::from(file_path); - if path.starts_with("~") { - if let Some(home_dir) = dirs::home_dir() { - // Strip the "~" and join the rest of the path to the home directory - path = home_dir.join(path.strip_prefix("~").unwrap()); + if path.starts_with("~") + && let Some(home_dir) = dirs::home_dir() + { + path = home_dir.join(path.strip_prefix("~").unwrap()); + } + + path +} + +async fn migrate_legacy_tokens(path: &PathBuf, legacy: LegacyConfig) -> Result { + let mut migrated = false; + + for provider in &legacy.provider { + let (provider_type, friendly_name, token) = match provider { + LegacyProvider::Github { + friendly_name, + token, + .. + } => ("github", friendly_name.as_str(), token), + LegacyProvider::Gitea { + friendly_name, + token, + .. + } => ("gitea", friendly_name.as_str(), token), + }; + + if let Some(token) = token + && !token.is_empty() + { + credentials::store_token(provider_type, friendly_name, token)?; + println!("Migrated token for '{friendly_name}' to system keychain."); + migrated = true; } } - let config = fs::read_to_string(&path).await?; - let config: Config = toml::from_str(&config)?; + let config = Config { + provider: legacy.provider.into_iter().map(Provider::from).collect(), + }; + + if migrated { + let contents = toml::to_string_pretty(&config)?; + fs::write(path, contents).await?; + } Ok(config) } + +pub async fn parse_config(file_path: String) -> Result { + let path = expand_path(&file_path); + let contents = fs::read_to_string(&path).await?; + let legacy: LegacyConfig = toml::from_str(&contents)?; + + let has_plaintext_tokens = legacy.provider.iter().any(|provider| match provider { + LegacyProvider::Github { token, .. } | LegacyProvider::Gitea { token, .. } => { + token.as_ref().is_some_and(|t| !t.is_empty()) + } + }); + + if has_plaintext_tokens { + return migrate_legacy_tokens(&path, legacy).await; + } + + Ok(Config { + provider: legacy.provider.into_iter().map(Provider::from).collect(), + }) +} diff --git a/src/credentials.rs b/src/credentials.rs new file mode 100644 index 0000000..350de9c --- /dev/null +++ b/src/credentials.rs @@ -0,0 +1,65 @@ +use dialoguer::{theme::ColorfulTheme, Password}; +use keyring::{Error, Entry}; + +const SERVICE: &str = "clonee"; + +pub fn account_key(provider_type: &str, friendly_name: &str) -> String { + format!("{provider_type}:{friendly_name}") +} + +fn entry(provider_type: &str, friendly_name: &str) -> keyring::Result { + Entry::new(SERVICE, &account_key(provider_type, friendly_name)) +} + +pub fn store_token( + provider_type: &str, + friendly_name: &str, + token: &str, +) -> Result<(), Box> { + entry(provider_type, friendly_name)?.set_password(token)?; + Ok(()) +} + +pub fn get_token( + provider_type: &str, + friendly_name: &str, +) -> Result, Box> { + match entry(provider_type, friendly_name)?.get_password() { + Ok(token) if token.trim().is_empty() => Ok(None), + Ok(token) => Ok(Some(token)), + Err(Error::NoEntry) => Ok(None), + Err(e) => Err(e.into()), + } +} + +pub fn delete_token( + provider_type: &str, + friendly_name: &str, +) -> Result<(), Box> { + match entry(provider_type, friendly_name)?.delete_credential() { + Ok(()) => Ok(()), + Err(Error::NoEntry) => Ok(()), + Err(e) => Err(e.into()), + } +} + +pub fn prompt_for_token(friendly_name: &str) -> Result> { + let token = Password::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("Enter API token for {friendly_name}")) + .interact()?; + + Ok(token) +} + +pub fn prompt_and_store_token( + provider_type: &str, + friendly_name: &str, +) -> Result, Box> { + let token = prompt_for_token(friendly_name)?; + if token.is_empty() { + return Ok(None); + } + + store_token(provider_type, friendly_name, &token)?; + Ok(Some(token)) +} diff --git a/src/main.rs b/src/main.rs index d8986a6..f48b157 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,22 @@ -use crate::{config::parse_config, providers::Provider, tui::user_select_repo}; -use colored::*; +use clap::Parser; use dialoguer::{Select, theme::ColorfulTheme}; use std::io::Write; -use std::{io, process}; +use std::io; +mod cli; mod config; +mod credentials; mod providers; mod tui; -const CONFIG_PATH: &str = "~/.config/clonee"; +pub(crate) const CONFIG_PATH: &str = "~/.config/clonee"; -enum DefaultAnswer { +pub(crate) enum DefaultAnswer { Yes, No, } -fn ask_option(message: &str, options: Vec) -> T +pub(crate) fn ask_option(message: &str, options: Vec) -> T where T: std::fmt::Display + Clone, { @@ -29,14 +30,13 @@ where options[selection].clone() } -fn ask_yes_no(message: &str, default: Option) -> bool { +pub(crate) fn ask_yes_no(message: &str, default: Option) -> bool { let options: Vec<&str> = match default { Some(DefaultAnswer::Yes) => vec!["Yes", "No"], Some(DefaultAnswer::No) => vec!["No", "Yes"], None => vec!["Yes", "No"], }; - // &str implements both Display and Copy! match ask_option(message, options) { "Yes" => true, "No" => false, @@ -44,7 +44,7 @@ fn ask_yes_no(message: &str, default: Option) -> bool { } } -fn get_input(message: &str) -> String { +pub(crate) fn get_input(message: &str) -> String { print!("{}", message); io::stdout().flush().unwrap(); @@ -56,53 +56,6 @@ fn get_input(message: &str) -> String { #[tokio::main] async fn main() { - let config_file = format!("{}/config.toml", CONFIG_PATH); - let config = match parse_config(config_file.clone()).await { - Ok(c) => c, - Err(e) => { - let top = "Failed to parse config.".red(); - let bottom = "Maybe there's an error in the config?".dimmed(); - - println!("{}\n{}", top, bottom); - eprintln!("Error at {}: {}", config_file, e); - process::exit(1); - } - }; - - let selected_provider = ask_option("Choose a provider:", config.provider); - - let provider: crate::config::ProviderClient = selected_provider.into_provider_trait(); - - let repos = provider.repositories().await.expect("Failed to get repos."); - - let selection = match user_select_repo(repos).await { - Ok(r) => r, - Err(_) => { - println!("{}", "No repository was selected!".red()); - process::exit(1); - } - }; - - match ask_yes_no("Clone the repo?", Some(DefaultAnswer::Yes)) { - true => { - let folder: Option = - if ask_yes_no("Use a custom folder?", Some(DefaultAnswer::No)) { - let custom = get_input("Folder name: "); - Some(custom) - } else { - None - }; - - match provider.clone(selection, folder, false).await { - Ok(_) => println!("Repo cloned!"), - Err(_) => { - println!("{}", "Error cloning repo.".red()) - } - } - } - false => { - println!("Exiting..."); - process::exit(0); - } - } + let cli = cli::Cli::parse(); + cli::run(cli).await; } diff --git a/src/providers/github.rs b/src/providers/github.rs index eff69ce..730071c 100644 --- a/src/providers/github.rs +++ b/src/providers/github.rs @@ -3,26 +3,24 @@ use serde::Deserialize; use crate::providers::Provider; -// TODO: add email and password authentication for viewing private repos. pub struct Github { pub username: String, + pub token: Option, } impl Github { - fn new(username: String) -> Self { - Self { username } + pub fn new(username: String, token: Option) -> Self { + Self { username, token } } } -// 1. This struct matches GitHub's exact API shape. -// It remains completely hidden inside your GitHub provider module. #[derive(Deserialize, Debug)] struct GithubRepoDto { id: u64, name: String, owner: GithubOwnerDto, ssh_url: Option, - clone_url: String, // Or html_url + clone_url: String, stargazers_count: u64, description: Option, } @@ -32,7 +30,6 @@ struct GithubOwnerDto { login: String, } -// 2. Map the GitHub DTO to your general Repository struct impl From for Repository { fn from(gh: GithubRepoDto) -> Self { Repository { @@ -53,18 +50,25 @@ impl Provider for Github { let mut page = 1; let mut repos: Vec = Vec::new(); - loop { - let url = format!( - "https://api.github.com/users/{}/repos?per_page=100&page={}", - &self.username, page - ); + let base_url = match self.token.is_some() { + true => "https://api.github.com/user/repos".to_string(), + false => format!("https://api.github.com/users/{}/repos", self.username), + }; - let response = client + loop { + let url = format!("{}?per_page=100&page={}", base_url, page); + + let mut request = client .get(&url) .header("User-Agent", "my-app") - .send() - .await?; + .header("Accept", "application/vnd.github+json"); // Best practice for GitHub API + // Apply token authentication if it's available + if let Some(ref token) = self.token { + request = request.bearer_auth(token); + } + + let response = request.send().await?; let status = response.status(); if !status.is_success() { @@ -79,7 +83,6 @@ impl Provider for Github { break; } - // Convert the GitHub DTOs seamlessly into your clean domain structs let domain_repos = gh_page.into_iter().map(Repository::from); repos.extend(domain_repos);