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"
keyring = "4.1.4"
colored = "3.1.1"
toml = "1.1.2+spec-1.1.0"
toml = "1.1.2"
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 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<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)]
#[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<String>,
},
#[serde(rename = "gitea")]
@@ -25,10 +41,16 @@ pub enum Provider {
friendly_name: String,
base_url: String,
username: String,
#[serde(default)]
token: Option<String>,
},
}
#[derive(Deserialize)]
struct LegacyConfig {
provider: Vec<LegacyProvider>,
}
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<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 {
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<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);
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
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<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 = 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<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 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<T>(message: &str, options: Vec<T>) -> T
pub(crate) fn ask_option<T>(message: &str, options: Vec<T>) -> T
where
T: std::fmt::Display + Clone,
{
@@ -29,14 +30,13 @@ where
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 {
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<DefaultAnswer>) -> 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<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);
}
}
let cli = cli::Cli::parse();
cli::run(cli).await;
}
+19 -16
View File
@@ -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<String>,
}
impl Github {
fn new(username: String) -> Self {
Self { username }
pub fn new(username: String, token: Option<String>) -> 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<String>,
clone_url: String, // Or html_url
clone_url: String,
stargazers_count: u64,
description: Option<String>,
}
@@ -32,7 +30,6 @@ struct GithubOwnerDto {
login: String,
}
// 2. Map the GitHub DTO to your general Repository struct
impl From<GithubRepoDto> for Repository {
fn from(gh: GithubRepoDto) -> Self {
Repository {
@@ -53,18 +50,25 @@ impl Provider for Github {
let mut page = 1;
let mut repos: Vec<Repository> = 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);