added credential management to keyring.

cursor.
This commit is contained in:
2026-07-10 21:22:31 -05:00
parent 8348946c3c
commit 378c1f7c0d
6 changed files with 458 additions and 94 deletions
+1 -1
View File
@@ -13,5 +13,5 @@ octocrab = "0.54.0"
dialoguer = "0.12" dialoguer = "0.12"
keyring = "4.1.4" keyring = "4.1.4"
colored = "3.1.1" colored = "3.1.1"
toml = "1.1.2+spec-1.1.0" toml = "1.1.2"
dirs = "6.0.0" dirs = "6.0.0"
+218
View File
@@ -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<Command>,
}
#[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<String> =
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);
}
}
}
+142 -17
View File
@@ -1,16 +1,15 @@
use std::path::PathBuf; use std::path::PathBuf;
use crate::credentials;
use crate::providers::Result; use crate::providers::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::fs; use tokio::fs;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct Config { pub struct Config {
pub provider: Vec<Provider>, pub provider: Vec<Provider>,
} }
// 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)] #[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum Provider { pub enum Provider {
@@ -18,6 +17,23 @@ pub enum Provider {
Github { Github {
friendly_name: String, friendly_name: String,
username: 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<String>, token: Option<String>,
}, },
#[serde(rename = "gitea")] #[serde(rename = "gitea")]
@@ -25,10 +41,16 @@ pub enum Provider {
friendly_name: String, friendly_name: String,
base_url: String, base_url: String,
username: String, username: String,
#[serde(default)]
token: Option<String>, token: Option<String>,
}, },
} }
#[derive(Deserialize)]
struct LegacyConfig {
provider: Vec<LegacyProvider>,
}
pub enum ProviderClient { pub enum ProviderClient {
Github(crate::providers::github::Github), Github(crate::providers::github::Github),
Gitea(crate::providers::gitea::Gitea), 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 { match self {
Provider::Github { Provider::Github { .. } => "github",
username, token, .. Provider::Gitea { .. } => "gitea",
} => {
todo!("Return ProviderClient::Github(...)")
} }
}
pub async fn resolve_token(&self) -> Result<Option<String>> {
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<ProviderClient> {
let token = self.resolve_token().await?;
match self {
Provider::Github { username, .. } => Ok(ProviderClient::Github(
crate::providers::github::Github::new(username, token),
)),
Provider::Gitea { Provider::Gitea {
base_url, base_url,
username, username,
token,
.. ..
} => ProviderClient::Gitea(crate::providers::gitea::Gitea::new( } => Ok(ProviderClient::Gitea(crate::providers::gitea::Gitea::new(
base_url, username, token, base_url, username, token,
)), ))),
} }
} }
} }
@@ -88,18 +134,97 @@ impl std::fmt::Display for Provider {
} }
} }
pub async fn parse_config(file_path: String) -> Result<Config> { impl From<LegacyProvider> 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); let mut path = PathBuf::from(file_path);
if path.starts_with("~") { if path.starts_with("~")
if let Some(home_dir) = dirs::home_dir() { && 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()); path = home_dir.join(path.strip_prefix("~").unwrap());
} }
path
}
async fn migrate_legacy_tokens(path: &PathBuf, legacy: LegacyConfig) -> Result<Config> {
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 {
let config: Config = toml::from_str(&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) Ok(config)
} }
pub async fn parse_config(file_path: String) -> Result<Config> {
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(),
})
}
+65
View File
@@ -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> {
Entry::new(SERVICE, &account_key(provider_type, friendly_name))
}
pub fn store_token(
provider_type: &str,
friendly_name: &str,
token: &str,
) -> Result<(), Box<dyn std::error::Error>> {
entry(provider_type, friendly_name)?.set_password(token)?;
Ok(())
}
pub fn get_token(
provider_type: &str,
friendly_name: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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<Option<String>, Box<dyn std::error::Error>> {
let token = prompt_for_token(friendly_name)?;
if token.is_empty() {
return Ok(None);
}
store_token(provider_type, friendly_name, &token)?;
Ok(Some(token))
}
+11 -58
View File
@@ -1,21 +1,22 @@
use crate::{config::parse_config, providers::Provider, tui::user_select_repo}; use clap::Parser;
use colored::*;
use dialoguer::{Select, theme::ColorfulTheme}; use dialoguer::{Select, theme::ColorfulTheme};
use std::io::Write; use std::io::Write;
use std::{io, process}; use std::io;
mod cli;
mod config; mod config;
mod credentials;
mod providers; mod providers;
mod tui; mod tui;
const CONFIG_PATH: &str = "~/.config/clonee"; pub(crate) const CONFIG_PATH: &str = "~/.config/clonee";
enum DefaultAnswer { pub(crate) enum DefaultAnswer {
Yes, Yes,
No, No,
} }
fn ask_option<T>(message: &str, options: Vec<T>) -> T pub(crate) fn ask_option<T>(message: &str, options: Vec<T>) -> T
where where
T: std::fmt::Display + Clone, T: std::fmt::Display + Clone,
{ {
@@ -29,14 +30,13 @@ where
options[selection].clone() options[selection].clone()
} }
fn ask_yes_no(message: &str, default: Option<DefaultAnswer>) -> bool { pub(crate) fn ask_yes_no(message: &str, default: Option<DefaultAnswer>) -> bool {
let options: Vec<&str> = match default { let options: Vec<&str> = match default {
Some(DefaultAnswer::Yes) => vec!["Yes", "No"], Some(DefaultAnswer::Yes) => vec!["Yes", "No"],
Some(DefaultAnswer::No) => vec!["No", "Yes"], Some(DefaultAnswer::No) => vec!["No", "Yes"],
None => vec!["Yes", "No"], None => vec!["Yes", "No"],
}; };
// &str implements both Display and Copy!
match ask_option(message, options) { match ask_option(message, options) {
"Yes" => true, "Yes" => true,
"No" => false, "No" => false,
@@ -44,7 +44,7 @@ fn ask_yes_no(message: &str, default: Option<DefaultAnswer>) -> bool {
} }
} }
fn get_input(message: &str) -> String { pub(crate) fn get_input(message: &str) -> String {
print!("{}", message); print!("{}", message);
io::stdout().flush().unwrap(); io::stdout().flush().unwrap();
@@ -56,53 +56,6 @@ fn get_input(message: &str) -> String {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let config_file = format!("{}/config.toml", CONFIG_PATH); let cli = cli::Cli::parse();
let config = match parse_config(config_file.clone()).await { cli::run(cli).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<String> =
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);
}
}
} }
+19 -16
View File
@@ -3,26 +3,24 @@ use serde::Deserialize;
use crate::providers::Provider; use crate::providers::Provider;
// TODO: add email and password authentication for viewing private repos.
pub struct Github { pub struct Github {
pub username: String, pub username: String,
pub token: Option<String>,
} }
impl Github { impl Github {
fn new(username: String) -> Self { pub fn new(username: String, token: Option<String>) -> Self {
Self { username } Self { username, token }
} }
} }
// 1. This struct matches GitHub's exact API shape.
// It remains completely hidden inside your GitHub provider module.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
struct GithubRepoDto { struct GithubRepoDto {
id: u64, id: u64,
name: String, name: String,
owner: GithubOwnerDto, owner: GithubOwnerDto,
ssh_url: Option<String>, ssh_url: Option<String>,
clone_url: String, // Or html_url clone_url: String,
stargazers_count: u64, stargazers_count: u64,
description: Option<String>, description: Option<String>,
} }
@@ -32,7 +30,6 @@ struct GithubOwnerDto {
login: String, login: String,
} }
// 2. Map the GitHub DTO to your general Repository struct
impl From<GithubRepoDto> for Repository { impl From<GithubRepoDto> for Repository {
fn from(gh: GithubRepoDto) -> Self { fn from(gh: GithubRepoDto) -> Self {
Repository { Repository {
@@ -53,18 +50,25 @@ impl Provider for Github {
let mut page = 1; let mut page = 1;
let mut repos: Vec<Repository> = Vec::new(); let mut repos: Vec<Repository> = Vec::new();
loop { let base_url = match self.token.is_some() {
let url = format!( true => "https://api.github.com/user/repos".to_string(),
"https://api.github.com/users/{}/repos?per_page=100&page={}", false => format!("https://api.github.com/users/{}/repos", self.username),
&self.username, page };
);
let response = client loop {
let url = format!("{}?per_page=100&page={}", base_url, page);
let mut request = client
.get(&url) .get(&url)
.header("User-Agent", "my-app") .header("User-Agent", "my-app")
.send() .header("Accept", "application/vnd.github+json"); // Best practice for GitHub API
.await?;
// 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(); let status = response.status();
if !status.is_success() { if !status.is_success() {
@@ -79,7 +83,6 @@ impl Provider for Github {
break; break;
} }
// Convert the GitHub DTOs seamlessly into your clean domain structs
let domain_repos = gh_page.into_iter().map(Repository::from); let domain_repos = gh_page.into_iter().map(Repository::from);
repos.extend(domain_repos); repos.extend(domain_repos);