initial commit
This commit is contained in:
		
						commit
						6b2f7628d6
					
				
					 6 changed files with 2116 additions and 0 deletions
				
			
		
							
								
								
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
/target
 | 
			
		||||
							
								
								
									
										1482
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1482
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										21
									
								
								Cargo.toml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								Cargo.toml
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
[package]
 | 
			
		||||
name = "fj"
 | 
			
		||||
version = "0.1.0"
 | 
			
		||||
edition = "2021"
 | 
			
		||||
 | 
			
		||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 | 
			
		||||
 | 
			
		||||
[dependencies]
 | 
			
		||||
clap = { version = "4.3.11", features = ["derive"] }
 | 
			
		||||
directories = "5.0.1"
 | 
			
		||||
eyre = "0.6.8"
 | 
			
		||||
forgejo-api = { path = "./forgejo-api" }
 | 
			
		||||
futures = "0.3.28"
 | 
			
		||||
serde = { version = "1.0.170", features = ["derive"] }
 | 
			
		||||
serde_json = "1.0.100"
 | 
			
		||||
soft_assert = "0.1.1"
 | 
			
		||||
tokio = { version = "1.29.1", features = ["full"] }
 | 
			
		||||
url = "2.4.0"
 | 
			
		||||
 | 
			
		||||
[workspace]
 | 
			
		||||
members = ["forgejo-api"]
 | 
			
		||||
							
								
								
									
										15
									
								
								forgejo-api/Cargo.toml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								forgejo-api/Cargo.toml
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
[package]
 | 
			
		||||
name = "forgejo-api"
 | 
			
		||||
version = "0.1.0"
 | 
			
		||||
edition = "2021"
 | 
			
		||||
 | 
			
		||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 | 
			
		||||
 | 
			
		||||
[dependencies]
 | 
			
		||||
reqwest = { version = "0.11.18", features = ["json"] }
 | 
			
		||||
soft_assert = "0.1.1"
 | 
			
		||||
thiserror = "1.0.43"
 | 
			
		||||
tokio = { version = "1.29.1", features = ["net"] }
 | 
			
		||||
url = { version = "2.4.0", features = ["serde"] }
 | 
			
		||||
serde = { version = "1.0.168", features = ["derive"] }
 | 
			
		||||
time = { version = "0.3.22", features = ["parsing", "serde"] }
 | 
			
		||||
							
								
								
									
										191
									
								
								forgejo-api/src/lib.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								forgejo-api/src/lib.rs
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,191 @@
 | 
			
		|||
use serde::{de::DeserializeOwned, Serialize};
 | 
			
		||||
use url::Url;
 | 
			
		||||
use soft_assert::*;
 | 
			
		||||
use reqwest::{Client, StatusCode, Request};
 | 
			
		||||
 | 
			
		||||
pub struct Forgejo {
 | 
			
		||||
    url: Url,
 | 
			
		||||
    client: Client,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(thiserror::Error, Debug)]
 | 
			
		||||
pub enum ForgejoError {
 | 
			
		||||
    #[error("url must have a host")]
 | 
			
		||||
    HostRequired,
 | 
			
		||||
    #[error("scheme must be http or https")]
 | 
			
		||||
    HttpRequired,
 | 
			
		||||
    #[error("{0}")] // for some reason, you can't use `source` and `transparent` together
 | 
			
		||||
    ReqwestError(#[source] reqwest::Error),
 | 
			
		||||
    #[error("API key should be ascii")]
 | 
			
		||||
    KeyNotAscii,
 | 
			
		||||
    #[error("the response from forgejo was not properly structured")]
 | 
			
		||||
    BadStructure,
 | 
			
		||||
    #[error("unexpected status code {} {}", .0.as_u16(), .0.canonical_reason().unwrap_or(""))]
 | 
			
		||||
    UnexpectedStatusCode(StatusCode),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl From<reqwest::Error> for ForgejoError {
 | 
			
		||||
    fn from(e: reqwest::Error) -> Self {
 | 
			
		||||
        if e.is_decode() {
 | 
			
		||||
            ForgejoError::BadStructure
 | 
			
		||||
        } else {
 | 
			
		||||
            ForgejoError::ReqwestError(e)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl Forgejo {
 | 
			
		||||
    pub fn new(api_key: &str, url: Url) -> Result<Self, ForgejoError> { 
 | 
			
		||||
        Self::with_user_agent(api_key, url, "forgejo-api-rs")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub fn with_user_agent(api_key: &str, url: Url, user_agent: &str) -> Result<Self, ForgejoError> {
 | 
			
		||||
        soft_assert!(matches!(url.scheme(), "http" | "https"), Err(ForgejoError::HttpRequired));
 | 
			
		||||
 | 
			
		||||
        let mut headers = reqwest::header::HeaderMap::new();
 | 
			
		||||
        let mut key_header: reqwest::header::HeaderValue = format!("token {api_key}").try_into().map_err(|_| ForgejoError::KeyNotAscii)?;
 | 
			
		||||
        // key_header.set_sensitive(true);
 | 
			
		||||
        headers.insert("Authorization", key_header);
 | 
			
		||||
        let client = Client::builder().user_agent(user_agent).default_headers(headers).build()?;
 | 
			
		||||
        dbg!(&client);
 | 
			
		||||
        Ok(Self { 
 | 
			
		||||
            url,
 | 
			
		||||
            client,
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub async fn get_repo(&self, user: &str, repo: &str) -> Result<Option<Repo>, ForgejoError> {
 | 
			
		||||
        self.get_opt(&format!("repos/{user}/{repo}/")).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub async fn create_repo(&self, repo: CreateRepoOption) -> Result<Repo, ForgejoError> {
 | 
			
		||||
        self.post("user/repos", &repo).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Returns user info about the authorized user.
 | 
			
		||||
    pub async fn myself(&self) -> Result<User, ForgejoError> {
 | 
			
		||||
        self.get("user").await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub async fn get_user(&self, user: &str) -> Result<Option<User>, ForgejoError> {
 | 
			
		||||
        self.get_opt(&format!("users/{user}/")).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub async fn get_followers(&self, user: &str) -> Result<Option<Vec<User>>, ForgejoError> {
 | 
			
		||||
        self.get_opt(&format!("users/{user}/followers/")).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    pub async fn get_following(&self, user: &str) -> Result<Option<Vec<User>>, ForgejoError> {
 | 
			
		||||
        self.get_opt(&format!("users/{user}/following/")).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ForgejoError> {
 | 
			
		||||
        let url = self.url.join("api/v1/").unwrap().join(path).unwrap();
 | 
			
		||||
        let request = self.client.get(url).build()?;
 | 
			
		||||
        self.execute(request).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async fn get_opt<T: DeserializeOwned>(&self, path: &str) -> Result<Option<T>, ForgejoError> {
 | 
			
		||||
        let url = self.url.join("api/v1/").unwrap().join(path).unwrap();
 | 
			
		||||
        let request = self.client.get(url).build()?;
 | 
			
		||||
        self.execute_opt(request).await
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async fn post<T: Serialize, U: DeserializeOwned>(&self, path: &str, body: &T) -> Result<U, ForgejoError> {
 | 
			
		||||
        let url = self.url.join("api/v1/").unwrap().join(path).unwrap();
 | 
			
		||||
        let request = self.client.post(url).json(body).build()?;
 | 
			
		||||
        self.execute(request).await
 | 
			
		||||
    } 
 | 
			
		||||
 | 
			
		||||
    async fn execute<T: DeserializeOwned>(&self, request: Request) -> Result<T, ForgejoError> {
 | 
			
		||||
        let response = self.client.execute(dbg!(request)).await?;
 | 
			
		||||
        match response.status() {
 | 
			
		||||
            status if status.is_success() => Ok(response.json::<T>().await?),
 | 
			
		||||
            status => Err(ForgejoError::UnexpectedStatusCode(status))
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Like `execute`, but returns `Ok(None)` on 404.
 | 
			
		||||
    async fn execute_opt<T: DeserializeOwned>(&self, request: Request) -> Result<Option<T>, ForgejoError> {
 | 
			
		||||
        let response = self.client.execute(dbg!(request)).await?;
 | 
			
		||||
        match response.status() {
 | 
			
		||||
            status if status.is_success() => Ok(Some(response.json::<T>().await?)),
 | 
			
		||||
            StatusCode::NOT_FOUND => Ok(None),
 | 
			
		||||
            status => Err(ForgejoError::UnexpectedStatusCode(status))
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Deserialize, Debug, PartialEq)]
 | 
			
		||||
pub struct Repo {
 | 
			
		||||
    pub clone_url: Url,
 | 
			
		||||
    #[serde(with="time::serde::rfc3339")]
 | 
			
		||||
    pub created_at: time::OffsetDateTime,
 | 
			
		||||
    pub default_branch: String,
 | 
			
		||||
    pub description: String,
 | 
			
		||||
    pub fork: bool,
 | 
			
		||||
    pub forks_count: u64,
 | 
			
		||||
    pub full_name: String,
 | 
			
		||||
 | 
			
		||||
    pub owner: User,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Deserialize, Debug, PartialEq)]
 | 
			
		||||
pub struct User {
 | 
			
		||||
    pub active: bool,
 | 
			
		||||
    pub avatar_url: Url,
 | 
			
		||||
    #[serde(with="time::serde::rfc3339")]
 | 
			
		||||
    pub created: time::OffsetDateTime,
 | 
			
		||||
    pub description: String,
 | 
			
		||||
    pub email: String,
 | 
			
		||||
    pub followers_count: u64,
 | 
			
		||||
    pub following_count: u64,
 | 
			
		||||
    pub full_name: String,
 | 
			
		||||
    pub id: u64,
 | 
			
		||||
    pub is_admin: bool,
 | 
			
		||||
    pub language: String,
 | 
			
		||||
    #[serde(with="time::serde::rfc3339")]
 | 
			
		||||
    pub last_login: time::OffsetDateTime,
 | 
			
		||||
    pub location: String,
 | 
			
		||||
    pub login: String,
 | 
			
		||||
    pub login_name: String,
 | 
			
		||||
    pub prohibit_login: bool,
 | 
			
		||||
    pub restricted: bool,
 | 
			
		||||
    pub starred_repos_count: u64,
 | 
			
		||||
    pub website: String,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Deserialize, Debug, PartialEq)]
 | 
			
		||||
pub enum UserVisibility {
 | 
			
		||||
    #[serde(rename = "public")]
 | 
			
		||||
    Public,
 | 
			
		||||
    #[serde(rename = "limited")]
 | 
			
		||||
    Limited,
 | 
			
		||||
    #[serde(rename = "private")]
 | 
			
		||||
    Private,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Serialize, Debug, PartialEq)]
 | 
			
		||||
pub struct CreateRepoOption {
 | 
			
		||||
    pub auto_init: bool,
 | 
			
		||||
    pub default_branch: String,
 | 
			
		||||
    pub description: Option<String>,
 | 
			
		||||
    pub gitignores: String,
 | 
			
		||||
    pub issue_labels: String,
 | 
			
		||||
    pub license: String,
 | 
			
		||||
    pub name: String,
 | 
			
		||||
    pub private: bool,
 | 
			
		||||
    pub readme: String,
 | 
			
		||||
    pub template: bool,
 | 
			
		||||
    pub trust_model: TrustModel
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Serialize, Debug, PartialEq)]
 | 
			
		||||
pub enum TrustModel {
 | 
			
		||||
    Default,
 | 
			
		||||
    Collaborator,
 | 
			
		||||
    Committer,
 | 
			
		||||
    #[serde(rename = "collaboratorcommiter")]
 | 
			
		||||
    CollaboratorCommitter,
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										406
									
								
								src/main.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										406
									
								
								src/main.rs
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,406 @@
 | 
			
		|||
use std::{collections::BTreeMap, io::ErrorKind};
 | 
			
		||||
 | 
			
		||||
use clap::{Parser, Subcommand};
 | 
			
		||||
use eyre::{bail, eyre};
 | 
			
		||||
use forgejo_api::{CreateRepoOption, Forgejo};
 | 
			
		||||
use tokio::io::AsyncWriteExt;
 | 
			
		||||
use url::Url;
 | 
			
		||||
 | 
			
		||||
#[derive(Parser, Debug)]
 | 
			
		||||
pub struct App {
 | 
			
		||||
    #[clap(subcommand)]
 | 
			
		||||
    command: Command,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Subcommand, Clone, Debug)]
 | 
			
		||||
pub enum Command {
 | 
			
		||||
    #[clap(subcommand)]
 | 
			
		||||
    Repo(RepoCommand),
 | 
			
		||||
    User {
 | 
			
		||||
        #[clap(long, short)]
 | 
			
		||||
        host: Option<String>,
 | 
			
		||||
    },
 | 
			
		||||
    #[clap(subcommand)]
 | 
			
		||||
    Auth(AuthCommand),
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Subcommand, Clone, Debug)]
 | 
			
		||||
pub enum RepoCommand {
 | 
			
		||||
    Create { 
 | 
			
		||||
        host: String, 
 | 
			
		||||
        repo: String,
 | 
			
		||||
 | 
			
		||||
        // flags
 | 
			
		||||
        #[clap(long, short)]
 | 
			
		||||
        description: Option<String>,
 | 
			
		||||
        #[clap(long, short)]
 | 
			
		||||
        private: bool,
 | 
			
		||||
        /// Sets the new repo to be the `origin` remote of the current local repo.
 | 
			
		||||
        #[clap(long, short)]
 | 
			
		||||
        set_upstream: bool,
 | 
			
		||||
        /// Pushes the current branch to the default branch on the new repo.
 | 
			
		||||
        /// Implies `--set-upstream`
 | 
			
		||||
        #[clap(long, short)]
 | 
			
		||||
        push: bool
 | 
			
		||||
    },
 | 
			
		||||
    Info,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Subcommand, Clone, Debug)]
 | 
			
		||||
pub enum AuthCommand {
 | 
			
		||||
    Login,
 | 
			
		||||
    Logout {
 | 
			
		||||
        host: String,
 | 
			
		||||
        user: String,
 | 
			
		||||
    },
 | 
			
		||||
    Switch {
 | 
			
		||||
        /// The host to set the default account for.
 | 
			
		||||
        #[clap(short, long)]
 | 
			
		||||
        host: Option<String>,
 | 
			
		||||
        user: String,
 | 
			
		||||
    },
 | 
			
		||||
    AddKey {
 | 
			
		||||
        /// The domain name of the forgejo instance.
 | 
			
		||||
        host: String,
 | 
			
		||||
        /// The user that the key is associated with
 | 
			
		||||
        user: String,
 | 
			
		||||
        /// The name of the key. If not present, defaults to the username.
 | 
			
		||||
        #[clap(short, long)]
 | 
			
		||||
        name: Option<String>,
 | 
			
		||||
        /// The key to add. If not present, the key will be read in from stdin.
 | 
			
		||||
        key: Option<String>,
 | 
			
		||||
    },
 | 
			
		||||
    List,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[tokio::main]
 | 
			
		||||
async fn main() -> eyre::Result<()> {
 | 
			
		||||
    let args = App::parse();
 | 
			
		||||
    let mut keys = KeyInfo::load().await?;
 | 
			
		||||
 | 
			
		||||
    match args.command {
 | 
			
		||||
        Command::Repo(repo_subcommand) => match repo_subcommand {
 | 
			
		||||
            RepoCommand::Create { 
 | 
			
		||||
                host, 
 | 
			
		||||
                repo ,
 | 
			
		||||
 | 
			
		||||
                description,
 | 
			
		||||
                private,
 | 
			
		||||
                set_upstream,
 | 
			
		||||
                push,
 | 
			
		||||
            } => {
 | 
			
		||||
                // let (host_domain, host_keys, repo) = keys.get_current_host_and_repo().await?;
 | 
			
		||||
                let host_info = keys.hosts.get(&host).ok_or_else(|| eyre!("not a known host"))?;
 | 
			
		||||
                let (_, user) = host_info.get_current_user()?;
 | 
			
		||||
                let url = Url::parse(&format!("http://{host}/"))?;
 | 
			
		||||
                let api = Forgejo::new(&user.key, url.clone())?;
 | 
			
		||||
                let repo_spec = CreateRepoOption {
 | 
			
		||||
                    auto_init: false,
 | 
			
		||||
                    default_branch: "main".into(),
 | 
			
		||||
                    description,
 | 
			
		||||
                    gitignores: String::new(),
 | 
			
		||||
                    issue_labels: String::new(),
 | 
			
		||||
                    license: String::new(),
 | 
			
		||||
                    name: repo.clone(),
 | 
			
		||||
                    private,
 | 
			
		||||
                    readme: String::new(),
 | 
			
		||||
                    template: false,
 | 
			
		||||
                    trust_model: forgejo_api::TrustModel::Default,
 | 
			
		||||
                };
 | 
			
		||||
                let new_repo = api.create_repo(repo_spec).await?;
 | 
			
		||||
                eprintln!("created new repo at {}", url.join(&format!("{}/{}", user.name, repo))?);
 | 
			
		||||
 | 
			
		||||
                if set_upstream || push {
 | 
			
		||||
                    let status = tokio::process::Command::new("git")
 | 
			
		||||
                        .arg("remote")
 | 
			
		||||
                        .arg("add")
 | 
			
		||||
                        .arg("origin")
 | 
			
		||||
                        .arg(new_repo.clone_url.as_str())
 | 
			
		||||
                        .status()
 | 
			
		||||
                        .await?;
 | 
			
		||||
                    if !status.success() {
 | 
			
		||||
                        eprintln!("origin set failed");
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if push {
 | 
			
		||||
                    let status = tokio::process::Command::new("git")
 | 
			
		||||
                        .arg("push")
 | 
			
		||||
                        .arg("-u")
 | 
			
		||||
                        .arg("origin")
 | 
			
		||||
                        .arg("main")
 | 
			
		||||
                        .arg(new_repo.clone_url.as_str())
 | 
			
		||||
                        .status()
 | 
			
		||||
                        .await?;
 | 
			
		||||
                    if !status.success() {
 | 
			
		||||
                        eprintln!("push failed");
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            RepoCommand::Info => {
 | 
			
		||||
                let (host_domain, host_keys, repo) = keys.get_current_host_and_repo().await?;
 | 
			
		||||
                let (_, user) = host_keys.get_current_user()?;
 | 
			
		||||
                let url = Url::parse(&format!("http://{host_domain}/"))?;
 | 
			
		||||
                let api = Forgejo::new(&user.key, url)?;
 | 
			
		||||
                let repo = api.get_repo(&user.name, &repo).await?;
 | 
			
		||||
                match repo {
 | 
			
		||||
                    Some(repo) => {
 | 
			
		||||
                        dbg!(repo);
 | 
			
		||||
                    }
 | 
			
		||||
                    None => eprintln!("repo not found"),
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        Command::User { host } => {
 | 
			
		||||
            let (host_domain, host_keys) = match host.as_deref() {
 | 
			
		||||
                Some(s) => (s, keys.hosts.get(s).ok_or_else(|| eyre!("not a known host"))?),
 | 
			
		||||
                None => keys.get_current_host().await?,
 | 
			
		||||
            };
 | 
			
		||||
            let (_, info) = host_keys.get_current_user()?;
 | 
			
		||||
            eprintln!("currently signed in to {}@{}", info.name, host_domain);
 | 
			
		||||
        },
 | 
			
		||||
        Command::Auth(auth_subcommand) => match auth_subcommand {
 | 
			
		||||
            AuthCommand::Login => {
 | 
			
		||||
                todo!();
 | 
			
		||||
                // let user = readline("username: ").await?;
 | 
			
		||||
                // let pass = readline("password: ").await?;
 | 
			
		||||
            }
 | 
			
		||||
            AuthCommand::Logout { host, user } => {
 | 
			
		||||
                let was_signed_in = keys
 | 
			
		||||
                    .hosts
 | 
			
		||||
                    .get_mut(&host)
 | 
			
		||||
                    .and_then(|host| host.users.remove(&user))
 | 
			
		||||
                    .is_some();
 | 
			
		||||
                if was_signed_in {
 | 
			
		||||
                    eprintln!("signed out of {user}@{host}");
 | 
			
		||||
                } else {
 | 
			
		||||
                    eprintln!("already not signed in");
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            AuthCommand::Switch { host, user } => {
 | 
			
		||||
                let host = host.unwrap_or(keys.get_current_host().await?.0.to_string());
 | 
			
		||||
                let host_info = keys
 | 
			
		||||
                    .hosts
 | 
			
		||||
                    .get_mut(&host)
 | 
			
		||||
                    .ok_or_else(|| eyre!("not a known host"))?;
 | 
			
		||||
                if !host_info.users.contains_key(&user) {
 | 
			
		||||
                    bail!("could not switch user: not signed into {host} as {user}");
 | 
			
		||||
                }
 | 
			
		||||
                let previous = host_info.default.replace(user.clone());
 | 
			
		||||
                print!("set current user for {host} to {user}");
 | 
			
		||||
                match previous {
 | 
			
		||||
                    Some(prev) => println!(" (previously {prev})"),
 | 
			
		||||
                    None => println!(),
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            AuthCommand::AddKey {
 | 
			
		||||
                host,
 | 
			
		||||
                user,
 | 
			
		||||
                name,
 | 
			
		||||
                key,
 | 
			
		||||
            } => {
 | 
			
		||||
                let host_keys = keys.hosts.entry(host.clone()).or_default();
 | 
			
		||||
                let key = match key {
 | 
			
		||||
                    Some(key) => key,
 | 
			
		||||
                    None => readline("new key: ").await?,
 | 
			
		||||
                };
 | 
			
		||||
                if host_keys.users.get(&user).is_none() {
 | 
			
		||||
                    host_keys.users.insert(
 | 
			
		||||
                        name.unwrap_or_else(|| user.clone()),
 | 
			
		||||
                        UserInfo { name: user, key },
 | 
			
		||||
                    );
 | 
			
		||||
                } else {
 | 
			
		||||
                    println!(
 | 
			
		||||
                        "key {} for {} already exists (rename it?)",
 | 
			
		||||
                        name.unwrap_or(user),
 | 
			
		||||
                        host
 | 
			
		||||
                    );
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            AuthCommand::List => {
 | 
			
		||||
                if keys.hosts.is_empty() {
 | 
			
		||||
                    println!("No logins.");
 | 
			
		||||
                }
 | 
			
		||||
                for (host_url, host_info) in &keys.hosts {
 | 
			
		||||
                    for (key_name, key_info) in &host_info.users {
 | 
			
		||||
                        let UserInfo { name, key: _ } = key_info;
 | 
			
		||||
                        println!("{key_name}: {name}@{host_url}");
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    keys.save().await?;
 | 
			
		||||
    Ok(())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn readline(msg: &str) -> eyre::Result<String> {
 | 
			
		||||
    print!("{msg}");
 | 
			
		||||
    tokio::io::stdout().flush().await?;
 | 
			
		||||
    tokio::task::spawn_blocking(|| {
 | 
			
		||||
        let mut input = String::new();
 | 
			
		||||
        std::io::stdin().read_line(&mut input)?;
 | 
			
		||||
        Ok(input)
 | 
			
		||||
    })
 | 
			
		||||
    .await?
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn get_remotes() -> eyre::Result<Vec<(String, Url)>> {
 | 
			
		||||
    let remotes = String::from_utf8(
 | 
			
		||||
        tokio::process::Command::new("git")
 | 
			
		||||
            .arg("remote")
 | 
			
		||||
            .output()
 | 
			
		||||
            .await?
 | 
			
		||||
            .stdout,
 | 
			
		||||
    )?;
 | 
			
		||||
    let remotes = futures::future::try_join_all(remotes.lines().map(|name| async {
 | 
			
		||||
        let name = name.trim();
 | 
			
		||||
        let url = Url::parse(
 | 
			
		||||
            String::from_utf8(
 | 
			
		||||
                tokio::process::Command::new("git")
 | 
			
		||||
                    .arg("remote")
 | 
			
		||||
                    .arg("get-url")
 | 
			
		||||
                    .arg(name)
 | 
			
		||||
                    .output()
 | 
			
		||||
                    .await?
 | 
			
		||||
                    .stdout,
 | 
			
		||||
            )?
 | 
			
		||||
            .trim(),
 | 
			
		||||
        )?;
 | 
			
		||||
        Ok::<_, eyre::Report>((name.to_string(), url))
 | 
			
		||||
    }))
 | 
			
		||||
    .await?;
 | 
			
		||||
    Ok(remotes)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async fn get_remote(remotes: &[(String, Url)]) -> eyre::Result<Url> {
 | 
			
		||||
    let url = if remotes.len() == 1 {
 | 
			
		||||
        remotes[0].1.clone()
 | 
			
		||||
    } else if let Some((_, url)) = remotes.iter().find(|(name, _)| *name == "origin") {
 | 
			
		||||
        url.clone()
 | 
			
		||||
    } else {
 | 
			
		||||
        bail!("could not find remote");
 | 
			
		||||
    };
 | 
			
		||||
    Ok(url)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Default)]
 | 
			
		||||
struct KeyInfo {
 | 
			
		||||
    hosts: BTreeMap<String, HostInfo>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl KeyInfo {
 | 
			
		||||
    async fn load() -> eyre::Result<Self> {
 | 
			
		||||
        let path = directories::ProjectDirs::from("", "Cyborus", "forgejo-cli")
 | 
			
		||||
            .ok_or_else(|| eyre!("Could not find data directory"))?
 | 
			
		||||
            .data_dir()
 | 
			
		||||
            .join("keys.json");
 | 
			
		||||
        let json = tokio::fs::read(path).await;
 | 
			
		||||
        let this = match json {
 | 
			
		||||
            Ok(x) => serde_json::from_slice::<Self>(&x)?,
 | 
			
		||||
            Err(e) if e.kind() == ErrorKind::NotFound => {
 | 
			
		||||
                eprintln!("keys file not found, creating");
 | 
			
		||||
                Self::default()
 | 
			
		||||
            }
 | 
			
		||||
            Err(e) => return Err(e.into()),
 | 
			
		||||
        };
 | 
			
		||||
        Ok(this)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async fn save(&self) -> eyre::Result<()> {
 | 
			
		||||
        let json = serde_json::to_vec_pretty(self)?;
 | 
			
		||||
        let dirs = directories::ProjectDirs::from("", "Cyborus", "forgejo-cli")
 | 
			
		||||
            .ok_or_else(|| eyre!("Could not find data directory"))?;
 | 
			
		||||
        let path = dirs.data_dir();
 | 
			
		||||
 | 
			
		||||
        tokio::fs::create_dir_all(path).await?;
 | 
			
		||||
 | 
			
		||||
        tokio::fs::File::create(path.join("keys.json"))
 | 
			
		||||
            .await?
 | 
			
		||||
            .write_all(&json)
 | 
			
		||||
            .await?;
 | 
			
		||||
 | 
			
		||||
        Ok(())
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async fn get_current_host_and_repo(&self) -> eyre::Result<(&str, &HostInfo, String)> {
 | 
			
		||||
        let remotes = get_remotes().await?;
 | 
			
		||||
        let remote = get_remote(&remotes).await?;
 | 
			
		||||
        let host_str = remote
 | 
			
		||||
            .host_str()
 | 
			
		||||
            .ok_or_else(|| eyre!("remote url does not have host"))?;
 | 
			
		||||
        let domain = if let Some(port) = remote.port() {
 | 
			
		||||
            format!("{}:{}", host_str, port)
 | 
			
		||||
        } else {
 | 
			
		||||
            host_str.to_owned()
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        let (name, host) = self
 | 
			
		||||
            .hosts
 | 
			
		||||
            .get_key_value(&domain)
 | 
			
		||||
            .ok_or_else(|| eyre!("not signed in to {domain}"))?;
 | 
			
		||||
        Ok((name, host, repo_from_url(&remote)?.into()))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async fn get_current_host(&self) -> eyre::Result<(&str, &HostInfo)> {
 | 
			
		||||
        let (name, host, _) = self.get_current_host_and_repo().await?;
 | 
			
		||||
        Ok((name, host))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async fn get_current_user(&self) -> eyre::Result<(&str, &UserInfo)> {
 | 
			
		||||
        let user = self.get_current_host().await?.1.get_current_user()?;
 | 
			
		||||
 | 
			
		||||
        Ok(user)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn repo_from_url(url: &Url) -> eyre::Result<&str> {
 | 
			
		||||
    let mut iter = url
 | 
			
		||||
        .path_segments()
 | 
			
		||||
        .ok_or_else(|| eyre!("failed to get path from url"))?;
 | 
			
		||||
    soft_assert::soft_assert!(
 | 
			
		||||
        matches!(iter.next(), Some(_)),
 | 
			
		||||
        Err(eyre!("path should have 2 segments, has none"))
 | 
			
		||||
    );
 | 
			
		||||
    let repo = iter
 | 
			
		||||
        .next()
 | 
			
		||||
        .ok_or_else(|| eyre!("path should have 2 segments, has only 1"))?;
 | 
			
		||||
    let repo = repo.strip_suffix(".git").unwrap_or(repo);
 | 
			
		||||
    soft_assert::soft_assert!(
 | 
			
		||||
        matches!(iter.next(), None),
 | 
			
		||||
        Err(eyre!("path should have 2 segments, has more"))
 | 
			
		||||
    );
 | 
			
		||||
    Ok(repo)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Default)]
 | 
			
		||||
struct HostInfo {
 | 
			
		||||
    default: Option<String>,
 | 
			
		||||
    users: BTreeMap<String, UserInfo>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl HostInfo {
 | 
			
		||||
    fn get_current_user(&self) -> eyre::Result<(&str, &UserInfo)> {
 | 
			
		||||
        if self.users.len() == 1 {
 | 
			
		||||
            let (s, k) = self.users.first_key_value().unwrap();
 | 
			
		||||
            return Ok((s, k));
 | 
			
		||||
        }
 | 
			
		||||
        if let Some(default) = self
 | 
			
		||||
            .default
 | 
			
		||||
            .as_ref()
 | 
			
		||||
        {
 | 
			
		||||
            if let Some(default_info) = self.users.get(default) {
 | 
			
		||||
                return Ok((default, default_info));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Err(eyre!("could not find user"))
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Default)]
 | 
			
		||||
struct UserInfo {
 | 
			
		||||
    name: String,
 | 
			
		||||
    key: String,
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue