generated from pure_sagacity/rust-starter
added credential management to keyring.
cursor.
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user