Git hosting and a container registry in one Rust binary (axum + Astro)
ig CLI: device login, git and docker credential helpers, repo/image/ssh-key/token/org/api commands, upgrade; 302 for release downloads
10 files changed, +1717 -3
+1-0Cargo.lock
| @@ -1876,6 +1876,7 @@ name = "ig" | ||
| 1876 | 1876 | version = "0.1.0" |
| 1877 | 1877 | dependencies = [ |
| 1878 | 1878 | "anyhow", |
| 1879 | + "chrono", | |
| 1879 | 1880 | "clap", |
| 1880 | 1881 | "dirs", |
| 1881 | 1882 | "hex", |
+2-2backend/src/api/release.rs
| @@ -13,7 +13,7 @@ use axum::{ | ||
| 13 | 13 | Json, |
| 14 | 14 | extract::{Query, State}, |
| 15 | 15 | http::{HeaderMap, StatusCode, header}, |
| 16 | - response::{IntoResponse, Redirect, Response}, | |
| 16 | + response::{IntoResponse, Response}, | |
| 17 | 17 | }; |
| 18 | 18 | use chrono::{DateTime, Utc}; |
| 19 | 19 | use irongit_shared as shared; |
| @@ -124,7 +124,7 @@ pub async fn download(State(state): State<AppState>, headers: HeaderMap, Query(q | ||
| 124 | 124 | let agent = auth::user_agent(&headers).unwrap_or("").chars().take(40).collect::<String>(); |
| 125 | 125 | analytics::track(&state, "cli_downloaded", None, "/download/ig", json!({ "version": row.version, "agent": agent })); |
| 126 | 126 | tracing::info!(version = %row.version, "cli download"); |
| 127 | - Redirect::to(url.as_str()).into_response() | |
| 127 | + (StatusCode::FOUND, [(header::LOCATION, url.to_string())]).into_response() | |
| 128 | 128 | } |
| 129 | 129 | Ok(None) => (StatusCode::NOT_FOUND, "No ig release has been published yet.\n").into_response(), |
| 130 | 130 | Err(error) => { |
+1-0cli/Cargo.toml
| @@ -5,6 +5,7 @@ edition = "2024" | ||
| 5 | 5 | |
| 6 | 6 | [dependencies] |
| 7 | 7 | anyhow = "1.0.104" |
| 8 | +chrono = "0.4.45" | |
| 8 | 9 | clap = { version = "4.6.7", features = ["derive", "env"] } |
| 9 | 10 | dirs = "7.0.0" |
| 10 | 11 | hex = "0.4.3" |
+374-0cli/src/auth.rs
| @@ -0,0 +1,374 @@ | ||
| 1 | +//! Signing in, and making git and docker use the saved token. | |
| 2 | + | |
| 3 | +use std::{ | |
| 4 | + io::{BufRead, Read, Write}, | |
| 5 | + path::{Path, PathBuf}, | |
| 6 | + process::{Command, Stdio}, | |
| 7 | + time::{Duration, Instant}, | |
| 8 | +}; | |
| 9 | + | |
| 10 | +use anyhow::{Context, bail}; | |
| 11 | +use irongit_shared::{self as shared, device_errors}; | |
| 12 | +use serde_json::{Value, json}; | |
| 13 | + | |
| 14 | +use crate::{ | |
| 15 | + client::{ApiFailure, Client}, | |
| 16 | + config::{self, Config, HostEntry}, | |
| 17 | + output::{bold, dim, green, yellow}, | |
| 18 | +}; | |
| 19 | + | |
| 20 | +pub struct LoginOptions { | |
| 21 | + pub host: Option<String>, | |
| 22 | + pub with_token: bool, | |
| 23 | + pub skip_setup: bool, | |
| 24 | +} | |
| 25 | + | |
| 26 | +pub fn login(options: LoginOptions) -> anyhow::Result<()> { | |
| 27 | + let mut config = Config::load()?; | |
| 28 | + let host = config.resolve_host(options.host.as_deref())?; | |
| 29 | + | |
| 30 | + let (username, token) = if options.with_token { | |
| 31 | + let mut token = String::new(); | |
| 32 | + std::io::stdin().read_to_string(&mut token)?; | |
| 33 | + let token = token.trim().to_string(); | |
| 34 | + if token.is_empty() { | |
| 35 | + bail!("no token on stdin. Example: echo igp_... | ig login --with-token --host {host}"); | |
| 36 | + } | |
| 37 | + let user: shared::User = Client::new(&host, Some(token.clone()))?.get("/api/v1/user").context("the token was not accepted")?; | |
| 38 | + (user.username, token) | |
| 39 | + } else { | |
| 40 | + device_login(&host)? | |
| 41 | + }; | |
| 42 | + | |
| 43 | + config.hosts.insert(host.clone(), HostEntry { username: username.clone(), token }); | |
| 44 | + config.default_host = Some(host.clone()); | |
| 45 | + config.save()?; | |
| 46 | + println!("{} Logged in to {host} as {}", green("✓"), bold(&username)); | |
| 47 | + println!("{}", dim(&format!(" token saved in {}", config::path()?.display()))); | |
| 48 | + | |
| 49 | + if !options.skip_setup { | |
| 50 | + match setup_git(&host) { | |
| 51 | + Ok(()) => println!("{} git uses ig for {host} credentials", green("✓")), | |
| 52 | + Err(error) => println!("{} could not configure git: {error:#}", yellow("!")), | |
| 53 | + } | |
| 54 | + match setup_docker(&host) { | |
| 55 | + Ok(note) => println!("{} docker uses ig for {}{note}", green("✓"), config::authority(&host)), | |
| 56 | + Err(error) => println!("{} could not configure docker: {error:#}", yellow("!")), | |
| 57 | + } | |
| 58 | + } | |
| 59 | + Ok(()) | |
| 60 | +} | |
| 61 | + | |
| 62 | +fn machine_name() -> String { | |
| 63 | + std::fs::read_to_string("/proc/sys/kernel/hostname") | |
| 64 | + .or_else(|_| std::fs::read_to_string("/etc/hostname")) | |
| 65 | + .map(|s| s.trim().to_string()) | |
| 66 | + .ok() | |
| 67 | + .filter(|s| !s.is_empty()) | |
| 68 | + .unwrap_or_else(|| "this computer".into()) | |
| 69 | +} | |
| 70 | + | |
| 71 | +fn device_login(host: &str) -> anyhow::Result<(String, String)> { | |
| 72 | + let client = Client::new(host, None)?; | |
| 73 | + let code: shared::DeviceCodeResponse = | |
| 74 | + client.post("/api/v1/device/code", &shared::DeviceCodeRequest { client_name: machine_name() }).context("starting device login")?; | |
| 75 | + | |
| 76 | + println!("First copy your one-time code: {}", bold(&code.user_code)); | |
| 77 | + println!("Then open {} and approve it.", bold(&code.verification_uri_complete)); | |
| 78 | + open_browser(&code.verification_uri_complete); | |
| 79 | + println!("{}", dim("Waiting for approval...")); | |
| 80 | + | |
| 81 | + let deadline = Instant::now() + Duration::from_secs(code.expires_in); | |
| 82 | + let mut interval = Duration::from_secs(code.interval.max(1)); | |
| 83 | + let request = shared::DeviceTokenRequest { device_code: code.device_code.clone() }; | |
| 84 | + loop { | |
| 85 | + if Instant::now() > deadline { | |
| 86 | + bail!("the code expired before it was approved. Run ig login again."); | |
| 87 | + } | |
| 88 | + std::thread::sleep(interval); | |
| 89 | + match client.post::<_, shared::DeviceTokenResponse>("/api/v1/device/token", &request) { | |
| 90 | + Ok(done) => return Ok((done.username, done.token)), | |
| 91 | + Err(error) => match error.downcast_ref::<ApiFailure>() { | |
| 92 | + Some(f) if f.code == device_errors::PENDING => continue, | |
| 93 | + Some(f) if f.code == device_errors::SLOW_DOWN => interval += Duration::from_secs(5), | |
| 94 | + Some(f) if f.code == device_errors::DENIED => bail!("the login was denied in the browser"), | |
| 95 | + Some(f) if f.code == device_errors::EXPIRED => bail!("the code expired or was already used. Run ig login again."), | |
| 96 | + _ => return Err(error.context("waiting for approval")), | |
| 97 | + }, | |
| 98 | + } | |
| 99 | + } | |
| 100 | +} | |
| 101 | + | |
| 102 | +/// Best effort: a browser if there is a display, never blocking or failing. | |
| 103 | +fn open_browser(url: &str) { | |
| 104 | + if std::env::var_os("IG_NO_BROWSER").is_some() { | |
| 105 | + return; | |
| 106 | + } | |
| 107 | + if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() { | |
| 108 | + return; | |
| 109 | + } | |
| 110 | + let _ = Command::new("xdg-open").arg(url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn(); | |
| 111 | +} | |
| 112 | + | |
| 113 | +pub fn logout(host_flag: Option<&str>) -> anyhow::Result<()> { | |
| 114 | + let mut config = Config::load()?; | |
| 115 | + let host = config.resolve_host(host_flag)?; | |
| 116 | + let Some(entry) = config.hosts.remove(&host) else { | |
| 117 | + bail!("not logged in to {host}"); | |
| 118 | + }; | |
| 119 | + // Revoke the token server-side too, matched by its visible prefix. | |
| 120 | + let client = Client::new(&host, Some(entry.token.clone()))?; | |
| 121 | + let revoked = client | |
| 122 | + .get::<Vec<shared::Token>>("/api/v1/user/tokens") | |
| 123 | + .ok() | |
| 124 | + .and_then(|tokens| tokens.into_iter().find(|t| entry.token.starts_with(&t.prefix))) | |
| 125 | + .map(|t| client.delete(&format!("/api/v1/user/tokens/{}", t.id)).is_ok()) | |
| 126 | + .unwrap_or(false); | |
| 127 | + if config.default_host.as_deref() == Some(host.as_str()) { | |
| 128 | + config.default_host = config.hosts.keys().next().cloned(); | |
| 129 | + } | |
| 130 | + config.save()?; | |
| 131 | + println!("{} Logged out of {host}{}", green("✓"), if revoked { " and revoked the token" } else { "" }); | |
| 132 | + Ok(()) | |
| 133 | +} | |
| 134 | + | |
| 135 | +pub fn status(host_flag: Option<&str>, json_output: bool) -> anyhow::Result<()> { | |
| 136 | + let config = Config::load()?; | |
| 137 | + let hosts: Vec<String> = match host_flag { | |
| 138 | + Some(h) => vec![config::normalize_host(h)?], | |
| 139 | + None => config.hosts.keys().cloned().collect(), | |
| 140 | + }; | |
| 141 | + if hosts.is_empty() { | |
| 142 | + bail!("not logged in anywhere. Run: ig login --host https://your-irongit-server"); | |
| 143 | + } | |
| 144 | + let mut report = Vec::new(); | |
| 145 | + for host in hosts { | |
| 146 | + let default = config.default_host.as_deref() == Some(host.as_str()); | |
| 147 | + let result = match config.token_for(&host) { | |
| 148 | + Some(token) => Client::new(&host, Some(token))?.get::<shared::User>("/api/v1/user"), | |
| 149 | + None => Err(anyhow::anyhow!("no token saved")), | |
| 150 | + }; | |
| 151 | + match result { | |
| 152 | + Ok(user) => { | |
| 153 | + if !json_output { | |
| 154 | + println!("{host}{}", if default { dim(" (default)") } else { String::new() }); | |
| 155 | + println!(" {} Logged in as {} (scopes: {})", green("✓"), bold(&user.username), user.scopes.join(", ")); | |
| 156 | + println!(" git helper: {}", if git_helper_configured(&host) { "configured" } else { "not configured (ig auth setup-git)" }); | |
| 157 | + println!(" docker helper: {}", if docker_helper_configured(&host) { "configured" } else { "not configured (ig auth setup-docker)" }); | |
| 158 | + } | |
| 159 | + report.push(json!({ "host": host, "default": default, "username": user.username, "scopes": user.scopes, "ok": true })); | |
| 160 | + } | |
| 161 | + Err(error) => { | |
| 162 | + if !json_output { | |
| 163 | + println!("{host}{}", if default { dim(" (default)") } else { String::new() }); | |
| 164 | + println!(" {} {error:#}", yellow("!")); | |
| 165 | + } | |
| 166 | + report.push(json!({ "host": host, "default": default, "ok": false, "error": format!("{error:#}") })); | |
| 167 | + } | |
| 168 | + } | |
| 169 | + } | |
| 170 | + if json_output { | |
| 171 | + crate::output::json(&report)?; | |
| 172 | + } | |
| 173 | + Ok(()) | |
| 174 | +} | |
| 175 | + | |
| 176 | +pub fn print_token(host_flag: Option<&str>) -> anyhow::Result<()> { | |
| 177 | + let config = Config::load()?; | |
| 178 | + let host = config.resolve_host(host_flag)?; | |
| 179 | + match config.token_for(&host) { | |
| 180 | + Some(token) => { | |
| 181 | + println!("{token}"); | |
| 182 | + Ok(()) | |
| 183 | + } | |
| 184 | + None => bail!("not logged in to {host}"), | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +// --------------------------------------------------------------------------- | |
| 189 | +// git | |
| 190 | + | |
| 191 | +fn current_exe() -> anyhow::Result<PathBuf> { | |
| 192 | + std::env::current_exe().context("cannot locate the ig executable") | |
| 193 | +} | |
| 194 | + | |
| 195 | +fn sh_quote(path: &Path) -> String { | |
| 196 | + format!("'{}'", path.display().to_string().replace('\'', "'\\''")) | |
| 197 | +} | |
| 198 | + | |
| 199 | +fn git_helper_value() -> anyhow::Result<String> { | |
| 200 | + Ok(format!("!{} auth git-credential", sh_quote(¤t_exe()?))) | |
| 201 | +} | |
| 202 | + | |
| 203 | +/// `credential.<host>.helper`: reset to empty first so helpers from other | |
| 204 | +/// config (a keychain) are not consulted for this host, then add ig. | |
| 205 | +pub fn setup_git(host: &str) -> anyhow::Result<()> { | |
| 206 | + let key = format!("credential.{host}.helper"); | |
| 207 | + let run = |args: &[&str]| -> anyhow::Result<()> { | |
| 208 | + let status = Command::new("git").args(args).stdin(Stdio::null()).status().context("running git (is it installed?)")?; | |
| 209 | + if !status.success() { | |
| 210 | + bail!("git {} failed", args.join(" ")); | |
| 211 | + } | |
| 212 | + Ok(()) | |
| 213 | + }; | |
| 214 | + run(&["config", "--global", "--replace-all", &key, ""])?; | |
| 215 | + run(&["config", "--global", "--add", &key, &git_helper_value()?])?; | |
| 216 | + Ok(()) | |
| 217 | +} | |
| 218 | + | |
| 219 | +fn git_helper_configured(host: &str) -> bool { | |
| 220 | + Command::new("git") | |
| 221 | + .args(["config", "--global", "--get-all", &format!("credential.{host}.helper")]) | |
| 222 | + .stderr(Stdio::null()) | |
| 223 | + .output() | |
| 224 | + .map(|o| String::from_utf8_lossy(&o.stdout).contains("auth git-credential")) | |
| 225 | + .unwrap_or(false) | |
| 226 | +} | |
| 227 | + | |
| 228 | +/// The git credential protocol: key=value lines on stdin, answer on stdout. | |
| 229 | +pub fn git_credential(operation: &str) -> anyhow::Result<()> { | |
| 230 | + let mut fields = std::collections::HashMap::new(); | |
| 231 | + for line in std::io::stdin().lock().lines() { | |
| 232 | + let line = line?; | |
| 233 | + if line.is_empty() { | |
| 234 | + break; | |
| 235 | + } | |
| 236 | + if let Some((key, value)) = line.split_once('=') { | |
| 237 | + fields.insert(key.to_string(), value.to_string()); | |
| 238 | + } | |
| 239 | + } | |
| 240 | + if operation != "get" { | |
| 241 | + return Ok(()); // store/erase: tokens are managed by ig login/logout | |
| 242 | + } | |
| 243 | + let (Some(protocol), Some(authority)) = (fields.get("protocol"), fields.get("host")) else { | |
| 244 | + return Ok(()); | |
| 245 | + }; | |
| 246 | + let config = Config::load()?; | |
| 247 | + let host = format!("{protocol}://{}", authority.to_ascii_lowercase()); | |
| 248 | + let Some(entry) = config.hosts.get(&host) else { | |
| 249 | + return Ok(()); // not ours: say nothing so git tries other helpers | |
| 250 | + }; | |
| 251 | + let token = config.token_for(&host).unwrap_or_else(|| entry.token.clone()); | |
| 252 | + let mut out = std::io::stdout().lock(); | |
| 253 | + writeln!(out, "protocol={protocol}")?; | |
| 254 | + writeln!(out, "host={authority}")?; | |
| 255 | + writeln!(out, "username={}", entry.username)?; | |
| 256 | + writeln!(out, "password={token}")?; | |
| 257 | + Ok(()) | |
| 258 | +} | |
| 259 | + | |
| 260 | +// --------------------------------------------------------------------------- | |
| 261 | +// docker | |
| 262 | + | |
| 263 | +fn docker_config_path() -> anyhow::Result<PathBuf> { | |
| 264 | + if let Some(dir) = std::env::var_os("DOCKER_CONFIG") { | |
| 265 | + return Ok(PathBuf::from(dir).join("config.json")); | |
| 266 | + } | |
| 267 | + Ok(dirs::home_dir().context("HOME is not set")?.join(".docker").join("config.json")) | |
| 268 | +} | |
| 269 | + | |
| 270 | +fn read_docker_config() -> anyhow::Result<Value> { | |
| 271 | + let path = docker_config_path()?; | |
| 272 | + match std::fs::read_to_string(&path) { | |
| 273 | + Ok(text) if !text.trim().is_empty() => serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display())), | |
| 274 | + Ok(_) => Ok(json!({})), | |
| 275 | + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), | |
| 276 | + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), | |
| 277 | + } | |
| 278 | +} | |
| 279 | + | |
| 280 | +fn on_path(name: &str) -> Option<PathBuf> { | |
| 281 | + std::env::var_os("PATH").and_then(|paths| std::env::split_paths(&paths).map(|dir| dir.join(name)).find(|p| p.is_file())) | |
| 282 | +} | |
| 283 | + | |
| 284 | +/// Points docker at `docker-credential-ig` for this registry and makes sure | |
| 285 | +/// that name exists next to ig. Returns a note for the user, if any. | |
| 286 | +pub fn setup_docker(host: &str) -> anyhow::Result<String> { | |
| 287 | + let path = docker_config_path()?; | |
| 288 | + let mut doc = read_docker_config()?; | |
| 289 | + let registry = config::authority(host); | |
| 290 | + let object = doc.as_object_mut().context("docker config is not a JSON object")?; | |
| 291 | + let helpers = object.entry("credHelpers").or_insert_with(|| json!({})); | |
| 292 | + helpers.as_object_mut().context("credHelpers is not an object")?.insert(registry, json!("ig")); | |
| 293 | + if let Some(dir) = path.parent() { | |
| 294 | + std::fs::create_dir_all(dir)?; | |
| 295 | + } | |
| 296 | + std::fs::write(&path, serde_json::to_string_pretty(&doc)? + "\n").with_context(|| format!("writing {}", path.display()))?; | |
| 297 | + | |
| 298 | + if on_path("docker-credential-ig").is_some() { | |
| 299 | + return Ok(String::new()); | |
| 300 | + } | |
| 301 | + let exe = current_exe()?; | |
| 302 | + let link = exe.with_file_name("docker-credential-ig"); | |
| 303 | + if !link.exists() { | |
| 304 | + #[cfg(unix)] | |
| 305 | + std::os::unix::fs::symlink(&exe, &link).with_context(|| format!("creating {}", link.display()))?; | |
| 306 | + } | |
| 307 | + if on_path("docker-credential-ig").is_some() { | |
| 308 | + Ok(String::new()) | |
| 309 | + } else { | |
| 310 | + Ok(format!(" (add {} to PATH so docker can find docker-credential-ig)", link.parent().map(|p| p.display().to_string()).unwrap_or_default())) | |
| 311 | + } | |
| 312 | +} | |
| 313 | + | |
| 314 | +fn docker_helper_configured(host: &str) -> bool { | |
| 315 | + read_docker_config() | |
| 316 | + .ok() | |
| 317 | + .and_then(|doc| doc.get("credHelpers")?.get(config::authority(host))?.as_str().map(|s| s == "ig")) | |
| 318 | + .unwrap_or(false) | |
| 319 | +} | |
| 320 | + | |
| 321 | +/// "https://localhost:7878/v2/" -> "localhost:7878" | |
| 322 | +fn registry_authority(server_url: &str) -> String { | |
| 323 | + config::authority(server_url.trim()).trim_end_matches('/').to_string() | |
| 324 | +} | |
| 325 | + | |
| 326 | +/// Entry point when invoked as `docker-credential-ig <get|store|erase|list>`. | |
| 327 | +pub fn docker_credential_helper(args: &[String]) -> anyhow::Result<()> { | |
| 328 | + let operation = args.get(1).map(String::as_str).unwrap_or(""); | |
| 329 | + let mut input = String::new(); | |
| 330 | + if operation != "list" { | |
| 331 | + std::io::stdin().read_to_string(&mut input)?; | |
| 332 | + } | |
| 333 | + let config = Config::load()?; | |
| 334 | + match operation { | |
| 335 | + "get" => { | |
| 336 | + let server = input.trim(); | |
| 337 | + let Some((host, entry)) = config.find_by_authority(®istry_authority(server)) else { | |
| 338 | + // Docker recognizes exactly this message as "no credentials". | |
| 339 | + println!("credentials not found in native keychain"); | |
| 340 | + std::process::exit(1); | |
| 341 | + }; | |
| 342 | + let token = config.token_for(host).unwrap_or_else(|| entry.token.clone()); | |
| 343 | + println!("{}", json!({ "ServerURL": server, "Username": entry.username, "Secret": token })); | |
| 344 | + Ok(()) | |
| 345 | + } | |
| 346 | + "list" => { | |
| 347 | + let map: serde_json::Map<String, Value> = | |
| 348 | + config.hosts.iter().map(|(host, entry)| (config::authority(host), json!(entry.username))).collect(); | |
| 349 | + println!("{}", Value::Object(map)); | |
| 350 | + Ok(()) | |
| 351 | + } | |
| 352 | + // Tokens come from `ig login`; `docker login` cannot replace them. | |
| 353 | + "store" | "erase" => Ok(()), | |
| 354 | + other => bail!("docker-credential-ig: unknown operation '{other}' (expected get, store, erase or list)"), | |
| 355 | + } | |
| 356 | +} | |
| 357 | + | |
| 358 | +#[cfg(test)] | |
| 359 | +mod tests { | |
| 360 | + use super::*; | |
| 361 | + | |
| 362 | + #[test] | |
| 363 | + fn registry_authority_strips_scheme_and_path() { | |
| 364 | + assert_eq!(registry_authority("localhost:7878"), "localhost:7878"); | |
| 365 | + assert_eq!(registry_authority("https://git.example.com/v2/"), "git.example.com"); | |
| 366 | + assert_eq!(registry_authority("http://localhost:7878\n"), "localhost:7878"); | |
| 367 | + } | |
| 368 | + | |
| 369 | + #[test] | |
| 370 | + fn helper_paths_are_shell_quoted() { | |
| 371 | + assert_eq!(sh_quote(Path::new("/opt/my tools/ig")), "'/opt/my tools/ig'"); | |
| 372 | + assert_eq!(sh_quote(Path::new("/a'b/ig")), r"'/a'\''b/ig'"); | |
| 373 | + } | |
| 374 | +} |
+135-0cli/src/client.rs
| @@ -0,0 +1,135 @@ | ||
| 1 | +//! Thin blocking client for /api/v1 that turns error bodies into readable | |
| 2 | +//! messages. | |
| 3 | + | |
| 4 | +use std::time::Duration; | |
| 5 | + | |
| 6 | +use anyhow::{Context, anyhow}; | |
| 7 | +use irongit_shared::ErrorBody; | |
| 8 | +use reqwest::{Method, StatusCode, blocking::Response}; | |
| 9 | +use serde::{Serialize, de::DeserializeOwned}; | |
| 10 | + | |
| 11 | +use crate::VERSION; | |
| 12 | + | |
| 13 | +pub struct Client { | |
| 14 | + http: reqwest::blocking::Client, | |
| 15 | + pub host: String, | |
| 16 | + token: Option<String>, | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// An HTTP error from the server, kept typed so callers can branch on it. | |
| 20 | +#[derive(Debug)] | |
| 21 | +pub struct ApiFailure { | |
| 22 | + pub status: StatusCode, | |
| 23 | + pub code: String, | |
| 24 | +} | |
| 25 | + | |
| 26 | +impl std::fmt::Display for ApiFailure { | |
| 27 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 28 | + write!(f, "{} (HTTP {})", self.code, self.status.as_u16()) | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +impl std::error::Error for ApiFailure {} | |
| 33 | + | |
| 34 | +pub fn http_client() -> anyhow::Result<reqwest::blocking::Client> { | |
| 35 | + Ok(reqwest::blocking::Client::builder() | |
| 36 | + .user_agent(format!("ig/{VERSION}")) | |
| 37 | + .connect_timeout(Duration::from_secs(10)) | |
| 38 | + .timeout(Duration::from_secs(300)) | |
| 39 | + .build()?) | |
| 40 | +} | |
| 41 | + | |
| 42 | +impl Client { | |
| 43 | + pub fn new(host: &str, token: Option<String>) -> anyhow::Result<Self> { | |
| 44 | + Ok(Self { http: http_client()?, host: host.to_string(), token }) | |
| 45 | + } | |
| 46 | + | |
| 47 | + pub fn url(&self, path: &str) -> String { | |
| 48 | + format!("{}{}", self.host, path) | |
| 49 | + } | |
| 50 | + | |
| 51 | + fn send(&self, method: Method, path: &str, body: Option<&serde_json::Value>) -> anyhow::Result<Response> { | |
| 52 | + let mut request = self.http.request(method.clone(), self.url(path)).header("accept", "application/json"); | |
| 53 | + if let Some(token) = &self.token { | |
| 54 | + request = request.bearer_auth(token); | |
| 55 | + } | |
| 56 | + if let Some(body) = body { | |
| 57 | + request = request.json(body); | |
| 58 | + } | |
| 59 | + request.send().with_context(|| format!("could not reach {} ({method} {path})", self.host)) | |
| 60 | + } | |
| 61 | + | |
| 62 | + fn check(&self, response: Response) -> anyhow::Result<Response> { | |
| 63 | + let status = response.status(); | |
| 64 | + if status.is_success() { | |
| 65 | + return Ok(response); | |
| 66 | + } | |
| 67 | + let text = response.text().unwrap_or_default(); | |
| 68 | + let code = serde_json::from_str::<ErrorBody>(&text).map(|b| b.error).unwrap_or_else(|_| text.trim().chars().take(300).collect()); | |
| 69 | + let failure = ApiFailure { status, code }; | |
| 70 | + if status == StatusCode::UNAUTHORIZED { | |
| 71 | + return Err(anyhow!(failure).context(format!("not signed in to {} or the token was revoked. Run: ig login --host {}", self.host, self.host))); | |
| 72 | + } | |
| 73 | + Err(anyhow!(failure)) | |
| 74 | + } | |
| 75 | + | |
| 76 | + pub fn get<T: DeserializeOwned>(&self, path: &str) -> anyhow::Result<T> { | |
| 77 | + let response = self.check(self.send(Method::GET, path, None)?)?; | |
| 78 | + Ok(response.json().with_context(|| format!("unexpected response from GET {path}"))?) | |
| 79 | + } | |
| 80 | + | |
| 81 | + pub fn post<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> anyhow::Result<T> { | |
| 82 | + let body = serde_json::to_value(body)?; | |
| 83 | + let response = self.check(self.send(Method::POST, path, Some(&body))?)?; | |
| 84 | + Ok(response.json().with_context(|| format!("unexpected response from POST {path}"))?) | |
| 85 | + } | |
| 86 | + | |
| 87 | + pub fn patch<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> anyhow::Result<T> { | |
| 88 | + let body = serde_json::to_value(body)?; | |
| 89 | + let response = self.check(self.send(Method::PATCH, path, Some(&body))?)?; | |
| 90 | + Ok(response.json().with_context(|| format!("unexpected response from PATCH {path}"))?) | |
| 91 | + } | |
| 92 | + | |
| 93 | + pub fn put<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> anyhow::Result<T> { | |
| 94 | + let body = serde_json::to_value(body)?; | |
| 95 | + let response = self.check(self.send(Method::PUT, path, Some(&body))?)?; | |
| 96 | + Ok(response.json().with_context(|| format!("unexpected response from PUT {path}"))?) | |
| 97 | + } | |
| 98 | + | |
| 99 | + pub fn delete(&self, path: &str) -> anyhow::Result<()> { | |
| 100 | + self.check(self.send(Method::DELETE, path, None)?)?; | |
| 101 | + Ok(()) | |
| 102 | + } | |
| 103 | + | |
| 104 | + /// Any request, returning status and body without judging them. | |
| 105 | + pub fn raw(&self, method: Method, path: &str, body: Option<&serde_json::Value>) -> anyhow::Result<(StatusCode, String)> { | |
| 106 | + let response = self.send(method, path, body)?; | |
| 107 | + let status = response.status(); | |
| 108 | + Ok((status, response.text().unwrap_or_default())) | |
| 109 | + } | |
| 110 | +} | |
| 111 | + | |
| 112 | +/// Percent-encodes one path segment ("tools/builder" -> "tools%2Fbuilder"). | |
| 113 | +pub fn segment(value: &str) -> String { | |
| 114 | + let mut out = String::with_capacity(value.len()); | |
| 115 | + for byte in value.bytes() { | |
| 116 | + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { | |
| 117 | + out.push(byte as char); | |
| 118 | + } else { | |
| 119 | + out.push_str(&format!("%{byte:02X}")); | |
| 120 | + } | |
| 121 | + } | |
| 122 | + out | |
| 123 | +} | |
| 124 | + | |
| 125 | +#[cfg(test)] | |
| 126 | +mod tests { | |
| 127 | + use super::*; | |
| 128 | + | |
| 129 | + #[test] | |
| 130 | + fn segments_encode_slashes() { | |
| 131 | + assert_eq!(segment("tools/builder"), "tools%2Fbuilder"); | |
| 132 | + assert_eq!(segment("api"), "api"); | |
| 133 | + assert_eq!(segment("a b"), "a%20b"); | |
| 134 | + } | |
| 135 | +} |
+506-0cli/src/cmds.rs
| @@ -0,0 +1,506 @@ | ||
| 1 | +//! Repository, image, key, token, organization and raw API commands. | |
| 2 | + | |
| 3 | +use std::process::Command; | |
| 4 | + | |
| 5 | +use anyhow::{Context, bail}; | |
| 6 | +use irongit_shared as shared; | |
| 7 | +use reqwest::Method; | |
| 8 | +use serde_json::Value; | |
| 9 | + | |
| 10 | +use crate::{ | |
| 11 | + client::{Client, segment}, | |
| 12 | + config::Config, | |
| 13 | + output::{self, ago, bold, bytes, dim, green, table}, | |
| 14 | +}; | |
| 15 | + | |
| 16 | +/// A signed-in client plus who it is signed in as. | |
| 17 | +pub struct Session { | |
| 18 | + pub client: Client, | |
| 19 | + pub username: Option<String>, | |
| 20 | + pub json: bool, | |
| 21 | +} | |
| 22 | + | |
| 23 | +impl Session { | |
| 24 | + pub fn open(host_flag: Option<&str>, json: bool) -> anyhow::Result<Self> { | |
| 25 | + let config = Config::load()?; | |
| 26 | + let host = config.resolve_host(host_flag)?; | |
| 27 | + let token = config.token_for(&host); | |
| 28 | + let username = config.hosts.get(&host).map(|h| h.username.clone()); | |
| 29 | + Ok(Self { client: Client::new(&host, token)?, username, json }) | |
| 30 | + } | |
| 31 | + | |
| 32 | + /// The signed-in username, asking the server if it is not saved. | |
| 33 | + pub fn me(&self) -> anyhow::Result<String> { | |
| 34 | + if let Some(name) = &self.username { | |
| 35 | + return Ok(name.clone()); | |
| 36 | + } | |
| 37 | + Ok(self.client.get::<shared::User>("/api/v1/user")?.username) | |
| 38 | + } | |
| 39 | + | |
| 40 | + /// "owner/name" or just "name" (owned by the signed-in user). | |
| 41 | + pub fn split_repo(&self, spec: &str) -> anyhow::Result<(String, String)> { | |
| 42 | + let spec = spec.trim().trim_end_matches(".git").trim_matches('/'); | |
| 43 | + match spec.split_once('/') { | |
| 44 | + Some((owner, name)) if !owner.is_empty() && !name.is_empty() && !name.contains('/') => Ok((owner.to_string(), name.to_string())), | |
| 45 | + None if !spec.is_empty() => Ok((self.me()?, spec.to_string())), | |
| 46 | + _ => bail!("expected owner/name, got '{spec}'"), | |
| 47 | + } | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +// --------------------------------------------------------------------------- | |
| 52 | +// repos | |
| 53 | + | |
| 54 | +pub fn repo_create(s: &Session, name: &str, org: Option<&str>, public: bool, description: Option<&str>) -> anyhow::Result<()> { | |
| 55 | + let body = shared::CreateRepo { | |
| 56 | + owner: org.map(str::to_string), | |
| 57 | + name: name.to_string(), | |
| 58 | + description: description.map(str::to_string), | |
| 59 | + visibility: Some(if public { "public" } else { "private" }.into()), | |
| 60 | + }; | |
| 61 | + let repo: shared::Repo = s.client.post("/api/v1/repos", &body)?; | |
| 62 | + if s.json { | |
| 63 | + return output::json(&repo); | |
| 64 | + } | |
| 65 | + println!("{} Created {} ({})", green("✓"), bold(&repo.full_name), repo.visibility); | |
| 66 | + println!(" {}", repo.web_url); | |
| 67 | + println!(); | |
| 68 | + println!("Push an existing repository:"); | |
| 69 | + println!(" git remote add origin {}", repo.clone_https); | |
| 70 | + println!(" git push -u origin main"); | |
| 71 | + println!("Or clone it: ig repo clone {}", repo.full_name); | |
| 72 | + Ok(()) | |
| 73 | +} | |
| 74 | + | |
| 75 | +pub fn repo_list(s: &Session, owner: Option<&str>) -> anyhow::Result<()> { | |
| 76 | + let owner = match owner { | |
| 77 | + Some(o) => o.to_string(), | |
| 78 | + None => s.me()?, | |
| 79 | + }; | |
| 80 | + let repos: Vec<shared::Repo> = s.client.get(&format!("/api/v1/accounts/{}/repos", segment(&owner)))?; | |
| 81 | + if s.json { | |
| 82 | + return output::json(&repos); | |
| 83 | + } | |
| 84 | + if repos.is_empty() { | |
| 85 | + println!("{}", dim(&format!("{owner} has no repositories you can see."))); | |
| 86 | + return Ok(()); | |
| 87 | + } | |
| 88 | + let rows: Vec<Vec<String>> = repos | |
| 89 | + .iter() | |
| 90 | + .map(|r| { | |
| 91 | + vec![ | |
| 92 | + r.full_name.clone(), | |
| 93 | + format!("{}{}", r.visibility, if r.archived { ", archived" } else { "" }), | |
| 94 | + if r.is_empty { "-".into() } else { r.default_branch.clone() }, | |
| 95 | + bytes(r.size_bytes), | |
| 96 | + ago(r.pushed_at), | |
| 97 | + r.description.chars().take(60).collect(), | |
| 98 | + ] | |
| 99 | + }) | |
| 100 | + .collect(); | |
| 101 | + table(&["NAME", "VISIBILITY", "BRANCH", "SIZE", "PUSHED", "DESCRIPTION"], &rows); | |
| 102 | + Ok(()) | |
| 103 | +} | |
| 104 | + | |
| 105 | +pub fn repo_view(s: &Session, spec: &str) -> anyhow::Result<()> { | |
| 106 | + let (owner, name) = s.split_repo(spec)?; | |
| 107 | + let repo: shared::Repo = s.client.get(&format!("/api/v1/repos/{}/{}", segment(&owner), segment(&name)))?; | |
| 108 | + if s.json { | |
| 109 | + return output::json(&repo); | |
| 110 | + } | |
| 111 | + println!("{} {}", bold(&repo.full_name), dim(&repo.visibility)); | |
| 112 | + if !repo.description.is_empty() { | |
| 113 | + println!("{}", repo.description); | |
| 114 | + } | |
| 115 | + println!(); | |
| 116 | + let rows = vec![ | |
| 117 | + vec!["web".into(), repo.web_url.clone()], | |
| 118 | + vec!["https".into(), repo.clone_https.clone()], | |
| 119 | + vec!["ssh".into(), repo.clone_ssh.clone()], | |
| 120 | + vec!["default branch".into(), if repo.is_empty { "(empty repository)".into() } else { repo.default_branch.clone() }], | |
| 121 | + vec!["size".into(), bytes(repo.size_bytes)], | |
| 122 | + vec!["last push".into(), ago(repo.pushed_at)], | |
| 123 | + vec!["your access".into(), repo.permission.clone().unwrap_or_else(|| "none".into())], | |
| 124 | + ]; | |
| 125 | + for row in rows { | |
| 126 | + println!(" {:<15} {}", dim(&row[0]), row[1]); | |
| 127 | + } | |
| 128 | + if repo.archived { | |
| 129 | + println!("\n This repository is archived (read-only)."); | |
| 130 | + } | |
| 131 | + Ok(()) | |
| 132 | +} | |
| 133 | + | |
| 134 | +pub fn repo_clone(s: &Session, spec: &str, dir: Option<&str>, ssh: bool) -> anyhow::Result<()> { | |
| 135 | + let (owner, name) = s.split_repo(spec)?; | |
| 136 | + let repo: shared::Repo = s.client.get(&format!("/api/v1/repos/{}/{}", segment(&owner), segment(&name)))?; | |
| 137 | + let url = if ssh { &repo.clone_ssh } else { &repo.clone_https }; | |
| 138 | + let mut command = Command::new("git"); | |
| 139 | + if !ssh { | |
| 140 | + // Works even if `ig auth setup-git` was skipped. | |
| 141 | + let exe = std::env::current_exe()?; | |
| 142 | + command.arg("-c").arg(format!("credential.{}.helper=!'{}' auth git-credential", s.client.host, exe.display())); | |
| 143 | + } | |
| 144 | + command.arg("clone").arg(url); | |
| 145 | + if let Some(dir) = dir { | |
| 146 | + command.arg(dir); | |
| 147 | + } | |
| 148 | + let status = command.status().context("running git clone (is git installed?)")?; | |
| 149 | + if !status.success() { | |
| 150 | + std::process::exit(status.code().unwrap_or(1)); | |
| 151 | + } | |
| 152 | + Ok(()) | |
| 153 | +} | |
| 154 | + | |
| 155 | +pub fn repo_delete(s: &Session, spec: &str, yes: bool) -> anyhow::Result<()> { | |
| 156 | + let (owner, name) = s.split_repo(spec)?; | |
| 157 | + if !yes { | |
| 158 | + bail!("deleting {owner}/{name} removes all of its history. Re-run with --yes to confirm."); | |
| 159 | + } | |
| 160 | + s.client.delete(&format!("/api/v1/repos/{}/{}", segment(&owner), segment(&name)))?; | |
| 161 | + println!("{} Deleted {owner}/{name}", green("✓")); | |
| 162 | + Ok(()) | |
| 163 | +} | |
| 164 | + | |
| 165 | +pub fn repo_update(s: &Session, spec: &str, update: shared::UpdateRepo, what: &str) -> anyhow::Result<()> { | |
| 166 | + let (owner, name) = s.split_repo(spec)?; | |
| 167 | + let repo: shared::Repo = s.client.patch(&format!("/api/v1/repos/{}/{}", segment(&owner), segment(&name)), &update)?; | |
| 168 | + if s.json { | |
| 169 | + return output::json(&repo); | |
| 170 | + } | |
| 171 | + println!("{} {} {what}", green("✓"), repo.full_name); | |
| 172 | + Ok(()) | |
| 173 | +} | |
| 174 | + | |
| 175 | +pub fn collab_list(s: &Session, spec: &str) -> anyhow::Result<()> { | |
| 176 | + let (owner, name) = s.split_repo(spec)?; | |
| 177 | + let people: Vec<shared::Collaborator> = s.client.get(&format!("/api/v1/repos/{}/{}/collaborators", segment(&owner), segment(&name)))?; | |
| 178 | + if s.json { | |
| 179 | + return output::json(&people); | |
| 180 | + } | |
| 181 | + if people.is_empty() { | |
| 182 | + println!("{}", dim("No collaborators.")); | |
| 183 | + } | |
| 184 | + table(&["USER", "PERMISSION", "ADDED"], &people.iter().map(|c| vec![c.username.clone(), c.permission.clone(), ago(Some(c.created_at))]).collect::<Vec<_>>()); | |
| 185 | + Ok(()) | |
| 186 | +} | |
| 187 | + | |
| 188 | +pub fn collab_add(s: &Session, spec: &str, user: &str, permission: &str) -> anyhow::Result<()> { | |
| 189 | + let (owner, name) = s.split_repo(spec)?; | |
| 190 | + let c: shared::Collaborator = s.client.put( | |
| 191 | + &format!("/api/v1/repos/{}/{}/collaborators/{}", segment(&owner), segment(&name), segment(user)), | |
| 192 | + &shared::SetPermission { permission: permission.to_string() }, | |
| 193 | + )?; | |
| 194 | + println!("{} {} has {} access to {owner}/{name}", green("✓"), c.username, c.permission); | |
| 195 | + Ok(()) | |
| 196 | +} | |
| 197 | + | |
| 198 | +pub fn collab_remove(s: &Session, spec: &str, user: &str) -> anyhow::Result<()> { | |
| 199 | + let (owner, name) = s.split_repo(spec)?; | |
| 200 | + s.client.delete(&format!("/api/v1/repos/{}/{}/collaborators/{}", segment(&owner), segment(&name), segment(user)))?; | |
| 201 | + println!("{} Removed {user} from {owner}/{name}", green("✓")); | |
| 202 | + Ok(()) | |
| 203 | +} | |
| 204 | + | |
| 205 | +// --------------------------------------------------------------------------- | |
| 206 | +// images | |
| 207 | + | |
| 208 | +/// Parsed image reference: owner, path below the owner, optional tag. | |
| 209 | +#[derive(Debug, PartialEq)] | |
| 210 | +pub struct ImageRef { | |
| 211 | + pub owner: String, | |
| 212 | + pub name: String, | |
| 213 | + pub tag: Option<String>, | |
| 214 | +} | |
| 215 | + | |
| 216 | +/// Accepts "owner/name", "owner/a/b:tag" and "host:port/owner/name:tag". | |
| 217 | +pub fn parse_image(spec: &str) -> anyhow::Result<ImageRef> { | |
| 218 | + let mut parts: Vec<&str> = spec.trim().split('/').filter(|p| !p.is_empty()).collect(); | |
| 219 | + if parts.len() >= 3 && (parts[0].contains('.') || parts[0].contains(':') || parts[0] == "localhost") { | |
| 220 | + parts.remove(0); | |
| 221 | + } | |
| 222 | + if parts.len() < 2 { | |
| 223 | + bail!("expected owner/name[:tag], got '{spec}'"); | |
| 224 | + } | |
| 225 | + let owner = parts[0].to_string(); | |
| 226 | + let mut rest = parts[1..].join("/"); | |
| 227 | + let mut tag = None; | |
| 228 | + if let Some(last) = rest.rsplit('/').next() { | |
| 229 | + if let Some((_, t)) = last.split_once(':') { | |
| 230 | + tag = Some(t.to_string()); | |
| 231 | + rest = rest[..rest.len() - t.len() - 1].to_string(); | |
| 232 | + } | |
| 233 | + } | |
| 234 | + Ok(ImageRef { owner, name: rest.to_ascii_lowercase(), tag }) | |
| 235 | +} | |
| 236 | + | |
| 237 | +fn package_path(image: &ImageRef) -> String { | |
| 238 | + format!("/api/v1/packages/{}/{}", segment(&image.owner), segment(&image.name)) | |
| 239 | +} | |
| 240 | + | |
| 241 | +pub fn image_list(s: &Session, owner: Option<&str>) -> anyhow::Result<()> { | |
| 242 | + let owner = match owner { | |
| 243 | + Some(o) => o.to_string(), | |
| 244 | + None => s.me()?, | |
| 245 | + }; | |
| 246 | + let packages: Vec<shared::Package> = s.client.get(&format!("/api/v1/accounts/{}/packages", segment(&owner)))?; | |
| 247 | + if s.json { | |
| 248 | + return output::json(&packages); | |
| 249 | + } | |
| 250 | + if packages.is_empty() { | |
| 251 | + println!("{}", dim(&format!("{owner} has no images you can see."))); | |
| 252 | + return Ok(()); | |
| 253 | + } | |
| 254 | + let rows: Vec<Vec<String>> = packages | |
| 255 | + .iter() | |
| 256 | + .map(|p| vec![p.full_name.clone(), p.visibility.clone(), p.tag_count.to_string(), p.pull_count.to_string(), ago(Some(p.updated_at)), p.repo.clone().unwrap_or_default()]) | |
| 257 | + .collect(); | |
| 258 | + table(&["IMAGE", "VISIBILITY", "TAGS", "PULLS", "UPDATED", "REPO"], &rows); | |
| 259 | + Ok(()) | |
| 260 | +} | |
| 261 | + | |
| 262 | +pub fn image_tags(s: &Session, spec: &str) -> anyhow::Result<()> { | |
| 263 | + let image = parse_image(spec)?; | |
| 264 | + let tags: Vec<shared::Tag> = s.client.get(&format!("{}/tags", package_path(&image)))?; | |
| 265 | + if s.json { | |
| 266 | + return output::json(&tags); | |
| 267 | + } | |
| 268 | + if tags.is_empty() { | |
| 269 | + println!("{}", dim("No tags.")); | |
| 270 | + return Ok(()); | |
| 271 | + } | |
| 272 | + let rows: Vec<Vec<String>> = tags | |
| 273 | + .iter() | |
| 274 | + .map(|t| vec![t.name.clone(), t.digest.chars().take(19).collect(), bytes(t.size), ago(Some(t.updated_at))]) | |
| 275 | + .collect(); | |
| 276 | + table(&["TAG", "DIGEST", "SIZE", "UPDATED"], &rows); | |
| 277 | + Ok(()) | |
| 278 | +} | |
| 279 | + | |
| 280 | +pub fn image_visibility(s: &Session, spec: &str, visibility: &str) -> anyhow::Result<()> { | |
| 281 | + let image = parse_image(spec)?; | |
| 282 | + let package: shared::Package = | |
| 283 | + s.client.patch(&package_path(&image), &shared::UpdatePackage { visibility: Some(visibility.to_string()), description: None })?; | |
| 284 | + if s.json { | |
| 285 | + return output::json(&package); | |
| 286 | + } | |
| 287 | + println!("{} {} is now {}", green("✓"), package.full_name, package.visibility); | |
| 288 | + if package.visibility == "public" { | |
| 289 | + println!(" Anyone can now run: {}", package.pull_command); | |
| 290 | + } | |
| 291 | + Ok(()) | |
| 292 | +} | |
| 293 | + | |
| 294 | +pub fn image_delete(s: &Session, spec: &str, yes: bool) -> anyhow::Result<()> { | |
| 295 | + let image = parse_image(spec)?; | |
| 296 | + let full = format!("{}/{}", image.owner, image.name); | |
| 297 | + if !yes { | |
| 298 | + match &image.tag { | |
| 299 | + Some(tag) => bail!("re-run with --yes to delete tag {full}:{tag}"), | |
| 300 | + None => bail!("deleting {full} removes every tag. Re-run with --yes to confirm."), | |
| 301 | + } | |
| 302 | + } | |
| 303 | + match &image.tag { | |
| 304 | + Some(tag) => { | |
| 305 | + s.client.delete(&format!("{}/tags/{}", package_path(&image), segment(tag)))?; | |
| 306 | + println!("{} Deleted tag {full}:{tag}", green("✓")); | |
| 307 | + } | |
| 308 | + None => { | |
| 309 | + s.client.delete(&package_path(&image))?; | |
| 310 | + println!("{} Deleted image {full}", green("✓")); | |
| 311 | + } | |
| 312 | + } | |
| 313 | + Ok(()) | |
| 314 | +} | |
| 315 | + | |
| 316 | +// --------------------------------------------------------------------------- | |
| 317 | +// ssh keys | |
| 318 | + | |
| 319 | +pub fn key_add(s: &Session, file: Option<&str>, title: Option<&str>) -> anyhow::Result<()> { | |
| 320 | + let path = match file { | |
| 321 | + Some(f) => std::path::PathBuf::from(f), | |
| 322 | + None => { | |
| 323 | + let ssh = dirs::home_dir().context("HOME is not set")?.join(".ssh"); | |
| 324 | + ["id_ed25519.pub", "id_ecdsa.pub", "id_rsa.pub"] | |
| 325 | + .iter() | |
| 326 | + .map(|n| ssh.join(n)) | |
| 327 | + .find(|p| p.is_file()) | |
| 328 | + .context("no public key in ~/.ssh. Create one with: ssh-keygen -t ed25519")? | |
| 329 | + } | |
| 330 | + }; | |
| 331 | + if path.extension().is_none_or(|e| e != "pub") && file.is_some() { | |
| 332 | + let text = std::fs::read_to_string(&path).unwrap_or_default(); | |
| 333 | + if text.contains("PRIVATE KEY") { | |
| 334 | + bail!("{} is a private key. Pass the .pub file.", path.display()); | |
| 335 | + } | |
| 336 | + } | |
| 337 | + let key = std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; | |
| 338 | + let added: shared::SshKey = s.client.post("/api/v1/user/keys", &shared::CreateSshKey { title: title.map(str::to_string), key })?; | |
| 339 | + if s.json { | |
| 340 | + return output::json(&added); | |
| 341 | + } | |
| 342 | + println!("{} Added SSH key \"{}\" ({})", green("✓"), added.title, added.fingerprint); | |
| 343 | + Ok(()) | |
| 344 | +} | |
| 345 | + | |
| 346 | +pub fn key_list(s: &Session) -> anyhow::Result<()> { | |
| 347 | + let keys: Vec<shared::SshKey> = s.client.get("/api/v1/user/keys")?; | |
| 348 | + if s.json { | |
| 349 | + return output::json(&keys); | |
| 350 | + } | |
| 351 | + if keys.is_empty() { | |
| 352 | + println!("{}", dim("No SSH keys. Add one with: ig ssh-key add")); | |
| 353 | + return Ok(()); | |
| 354 | + } | |
| 355 | + table( | |
| 356 | + &["ID", "TITLE", "FINGERPRINT", "ADDED", "LAST USED"], | |
| 357 | + &keys.iter().map(|k| vec![k.id.to_string(), k.title.clone(), k.fingerprint.clone(), ago(Some(k.created_at)), ago(k.last_used_at)]).collect::<Vec<_>>(), | |
| 358 | + ); | |
| 359 | + Ok(()) | |
| 360 | +} | |
| 361 | + | |
| 362 | +pub fn key_remove(s: &Session, id: i64) -> anyhow::Result<()> { | |
| 363 | + s.client.delete(&format!("/api/v1/user/keys/{id}"))?; | |
| 364 | + println!("{} Removed SSH key {id}", green("✓")); | |
| 365 | + Ok(()) | |
| 366 | +} | |
| 367 | + | |
| 368 | +// --------------------------------------------------------------------------- | |
| 369 | +// tokens | |
| 370 | + | |
| 371 | +pub fn token_create(s: &Session, name: &str, scopes: Option<&str>, expires_days: Option<u32>) -> anyhow::Result<()> { | |
| 372 | + let scopes = scopes.map(|list| list.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()); | |
| 373 | + let created: shared::CreatedToken = | |
| 374 | + s.client.post("/api/v1/user/tokens", &shared::CreateToken { name: name.to_string(), scopes, expires_in_days: expires_days })?; | |
| 375 | + if s.json { | |
| 376 | + return output::json(&created); | |
| 377 | + } | |
| 378 | + println!("{} Created token \"{}\" (scopes: {})", green("✓"), created.token.name, created.token.scopes.join(", ")); | |
| 379 | + println!("{}", created.secret); | |
| 380 | + println!("{}", dim("Copy it now; it cannot be shown again.")); | |
| 381 | + Ok(()) | |
| 382 | +} | |
| 383 | + | |
| 384 | +pub fn token_list(s: &Session) -> anyhow::Result<()> { | |
| 385 | + let tokens: Vec<shared::Token> = s.client.get("/api/v1/user/tokens")?; | |
| 386 | + if s.json { | |
| 387 | + return output::json(&tokens); | |
| 388 | + } | |
| 389 | + table( | |
| 390 | + &["ID", "NAME", "PREFIX", "SCOPES", "LAST USED", "EXPIRES"], | |
| 391 | + &tokens | |
| 392 | + .iter() | |
| 393 | + .map(|t| { | |
| 394 | + vec![ | |
| 395 | + t.id.to_string(), | |
| 396 | + t.name.clone(), | |
| 397 | + format!("{}...", t.prefix), | |
| 398 | + t.scopes.join(","), | |
| 399 | + ago(t.last_used_at), | |
| 400 | + t.expires_at.map(|e| e.format("%Y-%m-%d").to_string()).unwrap_or_else(|| "never".into()), | |
| 401 | + ] | |
| 402 | + }) | |
| 403 | + .collect::<Vec<_>>(), | |
| 404 | + ); | |
| 405 | + Ok(()) | |
| 406 | +} | |
| 407 | + | |
| 408 | +pub fn token_revoke(s: &Session, id: i64) -> anyhow::Result<()> { | |
| 409 | + s.client.delete(&format!("/api/v1/user/tokens/{id}"))?; | |
| 410 | + println!("{} Revoked token {id}", green("✓")); | |
| 411 | + Ok(()) | |
| 412 | +} | |
| 413 | + | |
| 414 | +// --------------------------------------------------------------------------- | |
| 415 | +// orgs | |
| 416 | + | |
| 417 | +pub fn org_create(s: &Session, name: &str, display_name: Option<&str>) -> anyhow::Result<()> { | |
| 418 | + let org: shared::Account = s.client.post("/api/v1/orgs", &shared::CreateOrg { name: name.to_string(), display_name: display_name.map(str::to_string) })?; | |
| 419 | + if s.json { | |
| 420 | + return output::json(&org); | |
| 421 | + } | |
| 422 | + println!("{} Created organization {} ({})", green("✓"), bold(&org.name), org.web_url); | |
| 423 | + println!(" Create repos in it with: ig repo create <name> --org {}", org.name); | |
| 424 | + Ok(()) | |
| 425 | +} | |
| 426 | + | |
| 427 | +pub fn org_list(s: &Session) -> anyhow::Result<()> { | |
| 428 | + let orgs: Vec<shared::OrgMembership> = s.client.get("/api/v1/user/orgs")?; | |
| 429 | + if s.json { | |
| 430 | + return output::json(&orgs); | |
| 431 | + } | |
| 432 | + if orgs.is_empty() { | |
| 433 | + println!("{}", dim("You are not in any organizations.")); | |
| 434 | + return Ok(()); | |
| 435 | + } | |
| 436 | + table(&["ORG", "ROLE", "NAME"], &orgs.iter().map(|o| vec![o.org.clone(), o.role.clone(), o.display_name.clone()]).collect::<Vec<_>>()); | |
| 437 | + Ok(()) | |
| 438 | +} | |
| 439 | + | |
| 440 | +pub fn org_members(s: &Session, org: &str) -> anyhow::Result<()> { | |
| 441 | + let members: Vec<shared::OrgMember> = s.client.get(&format!("/api/v1/orgs/{}/members", segment(org)))?; | |
| 442 | + if s.json { | |
| 443 | + return output::json(&members); | |
| 444 | + } | |
| 445 | + table(&["USER", "ROLE", "JOINED"], &members.iter().map(|m| vec![m.username.clone(), m.role.clone(), ago(Some(m.joined_at))]).collect::<Vec<_>>()); | |
| 446 | + Ok(()) | |
| 447 | +} | |
| 448 | + | |
| 449 | +pub fn org_add(s: &Session, org: &str, user: &str, role: &str) -> anyhow::Result<()> { | |
| 450 | + let member: shared::OrgMember = | |
| 451 | + s.client.put(&format!("/api/v1/orgs/{}/members/{}", segment(org), segment(user)), &shared::SetRole { role: role.to_string() })?; | |
| 452 | + println!("{} {} is now {} of {org}", green("✓"), member.username, if member.role == "owner" { "an owner" } else { "a member" }); | |
| 453 | + Ok(()) | |
| 454 | +} | |
| 455 | + | |
| 456 | +pub fn org_remove(s: &Session, org: &str, user: &str) -> anyhow::Result<()> { | |
| 457 | + s.client.delete(&format!("/api/v1/orgs/{}/members/{}", segment(org), segment(user)))?; | |
| 458 | + println!("{} Removed {user} from {org}", green("✓")); | |
| 459 | + Ok(()) | |
| 460 | +} | |
| 461 | + | |
| 462 | +// --------------------------------------------------------------------------- | |
| 463 | +// raw API | |
| 464 | + | |
| 465 | +pub fn api(s: &Session, method: &str, path: &str, data: Option<&str>) -> anyhow::Result<()> { | |
| 466 | + let method: Method = method.to_ascii_uppercase().parse().context("invalid HTTP method")?; | |
| 467 | + let path = if path.starts_with("/api/") || path.starts_with("/v2/") { | |
| 468 | + path.to_string() | |
| 469 | + } else { | |
| 470 | + format!("/api/v1/{}", path.trim_start_matches('/')) | |
| 471 | + }; | |
| 472 | + let body: Option<Value> = match data { | |
| 473 | + Some("-") => { | |
| 474 | + let mut text = String::new(); | |
| 475 | + std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)?; | |
| 476 | + Some(serde_json::from_str(&text).context("stdin is not JSON")?) | |
| 477 | + } | |
| 478 | + Some(text) => Some(serde_json::from_str(text).context("-d is not JSON")?), | |
| 479 | + None => None, | |
| 480 | + }; | |
| 481 | + let (status, text) = s.client.raw(method, &path, body.as_ref())?; | |
| 482 | + match serde_json::from_str::<Value>(&text) { | |
| 483 | + Ok(value) => println!("{}", serde_json::to_string_pretty(&value)?), | |
| 484 | + Err(_) if !text.is_empty() => println!("{text}"), | |
| 485 | + Err(_) => {} | |
| 486 | + } | |
| 487 | + if !status.is_success() { | |
| 488 | + eprintln!("HTTP {}", status.as_u16()); | |
| 489 | + std::process::exit(1); | |
| 490 | + } | |
| 491 | + Ok(()) | |
| 492 | +} | |
| 493 | + | |
| 494 | +#[cfg(test)] | |
| 495 | +mod tests { | |
| 496 | + use super::*; | |
| 497 | + | |
| 498 | + #[test] | |
| 499 | + fn image_references_parse() { | |
| 500 | + assert_eq!(parse_image("alice/api").unwrap(), ImageRef { owner: "alice".into(), name: "api".into(), tag: None }); | |
| 501 | + assert_eq!(parse_image("alice/tools/builder:1.2").unwrap(), ImageRef { owner: "alice".into(), name: "tools/builder".into(), tag: Some("1.2".into()) }); | |
| 502 | + assert_eq!(parse_image("localhost:7878/alice/api:latest").unwrap(), ImageRef { owner: "alice".into(), name: "api".into(), tag: Some("latest".into()) }); | |
| 503 | + assert_eq!(parse_image("git.example.com/org/svc").unwrap(), ImageRef { owner: "org".into(), name: "svc".into(), tag: None }); | |
| 504 | + assert!(parse_image("justone").is_err()); | |
| 505 | + } | |
| 506 | +} |
+183-0cli/src/config.rs
| @@ -0,0 +1,183 @@ | ||
| 1 | +//! ~/.config/irongit/config.toml: which server to talk to and the token for | |
| 2 | +//! each. Written with mode 0600 because it holds credentials. | |
| 3 | +//! | |
| 4 | +//! default_host = "https://git.example.com" | |
| 5 | +//! | |
| 6 | +//! [hosts."https://git.example.com"] | |
| 7 | +//! username = "alice" | |
| 8 | +//! token = "igp_..." | |
| 9 | + | |
| 10 | +use std::{collections::BTreeMap, path::PathBuf}; | |
| 11 | + | |
| 12 | +use anyhow::{Context, bail}; | |
| 13 | +use serde::{Deserialize, Serialize}; | |
| 14 | + | |
| 15 | +#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] | |
| 16 | +pub struct Config { | |
| 17 | + pub default_host: Option<String>, | |
| 18 | + #[serde(default)] | |
| 19 | + pub hosts: BTreeMap<String, HostEntry>, | |
| 20 | +} | |
| 21 | + | |
| 22 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | |
| 23 | +pub struct HostEntry { | |
| 24 | + pub username: String, | |
| 25 | + pub token: String, | |
| 26 | +} | |
| 27 | + | |
| 28 | +pub fn path() -> anyhow::Result<PathBuf> { | |
| 29 | + if let Some(dir) = std::env::var_os("IG_CONFIG_DIR") { | |
| 30 | + return Ok(PathBuf::from(dir).join("config.toml")); | |
| 31 | + } | |
| 32 | + let base = dirs::config_dir().context("cannot find a config directory (is HOME set?)")?; | |
| 33 | + Ok(base.join("irongit").join("config.toml")) | |
| 34 | +} | |
| 35 | + | |
| 36 | +impl Config { | |
| 37 | + pub fn load() -> anyhow::Result<Self> { | |
| 38 | + let path = path()?; | |
| 39 | + match std::fs::read_to_string(&path) { | |
| 40 | + Ok(text) => Self::parse(&text).with_context(|| format!("reading {}", path.display())), | |
| 41 | + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), | |
| 42 | + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + pub fn parse(text: &str) -> anyhow::Result<Self> { | |
| 47 | + Ok(toml::from_str(text)?) | |
| 48 | + } | |
| 49 | + | |
| 50 | + pub fn save(&self) -> anyhow::Result<()> { | |
| 51 | + let path = path()?; | |
| 52 | + if let Some(dir) = path.parent() { | |
| 53 | + std::fs::create_dir_all(dir)?; | |
| 54 | + } | |
| 55 | + let text = toml::to_string_pretty(self)?; | |
| 56 | + let tmp = path.with_extension("toml.tmp"); | |
| 57 | + { | |
| 58 | + use std::io::Write; | |
| 59 | + let mut options = std::fs::OpenOptions::new(); | |
| 60 | + options.write(true).create(true).truncate(true); | |
| 61 | + #[cfg(unix)] | |
| 62 | + { | |
| 63 | + use std::os::unix::fs::OpenOptionsExt; | |
| 64 | + options.mode(0o600); | |
| 65 | + } | |
| 66 | + let mut file = options.open(&tmp).with_context(|| format!("writing {}", tmp.display()))?; | |
| 67 | + file.write_all(text.as_bytes())?; | |
| 68 | + } | |
| 69 | + std::fs::rename(&tmp, &path)?; | |
| 70 | + Ok(()) | |
| 71 | + } | |
| 72 | + | |
| 73 | + /// The host to use: --host / IG_HOST first, then the saved default. | |
| 74 | + pub fn resolve_host(&self, flag: Option<&str>) -> anyhow::Result<String> { | |
| 75 | + match flag.filter(|h| !h.trim().is_empty()) { | |
| 76 | + Some(host) => normalize_host(host), | |
| 77 | + None => match &self.default_host { | |
| 78 | + Some(host) => Ok(host.clone()), | |
| 79 | + None => bail!("no server configured. Run: ig login --host https://your-irongit-server"), | |
| 80 | + }, | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + /// Token for `host`: IG_TOKEN wins, then the saved one. | |
| 85 | + pub fn token_for(&self, host: &str) -> Option<String> { | |
| 86 | + if let Some(token) = std::env::var("IG_TOKEN").ok().filter(|t| !t.trim().is_empty()) { | |
| 87 | + return Some(token.trim().to_string()); | |
| 88 | + } | |
| 89 | + self.hosts.get(host).map(|h| h.token.clone()) | |
| 90 | + } | |
| 91 | + | |
| 92 | + /// Finds the saved host whose "host[:port]" matches, for credential | |
| 93 | + /// helpers that only know the authority. | |
| 94 | + pub fn find_by_authority(&self, authority: &str) -> Option<(&String, &HostEntry)> { | |
| 95 | + let wanted = authority.trim().trim_end_matches('/').to_ascii_lowercase(); | |
| 96 | + self.hosts.iter().find(|(host, _)| self::authority(host).eq_ignore_ascii_case(&wanted)) | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +/// "git.example.com" -> "https://git.example.com"; localhost defaults to | |
| 101 | +/// http. Paths and trailing slashes are dropped. | |
| 102 | +pub fn normalize_host(input: &str) -> anyhow::Result<String> { | |
| 103 | + let input = input.trim().trim_end_matches('/'); | |
| 104 | + if input.is_empty() { | |
| 105 | + bail!("empty host"); | |
| 106 | + } | |
| 107 | + let with_scheme = if input.contains("://") { | |
| 108 | + input.to_string() | |
| 109 | + } else { | |
| 110 | + let bare = input.split('/').next().unwrap_or(input); | |
| 111 | + let local = bare.starts_with("localhost") || bare.starts_with("127.") || bare.starts_with("[::1]"); | |
| 112 | + format!("{}://{input}", if local { "http" } else { "https" }) | |
| 113 | + }; | |
| 114 | + let (scheme, rest) = with_scheme.split_once("://").expect("has scheme"); | |
| 115 | + let scheme = scheme.to_ascii_lowercase(); | |
| 116 | + if scheme != "http" && scheme != "https" { | |
| 117 | + bail!("host must use http or https, not {scheme}"); | |
| 118 | + } | |
| 119 | + let authority = rest.split(['/', '?', '#']).next().unwrap_or("").to_ascii_lowercase(); | |
| 120 | + if authority.is_empty() || authority.contains('@') || authority.contains(char::is_whitespace) { | |
| 121 | + bail!("not a valid host: {input}"); | |
| 122 | + } | |
| 123 | + Ok(format!("{scheme}://{authority}")) | |
| 124 | +} | |
| 125 | + | |
| 126 | +/// "https://git.example.com:8443" -> "git.example.com:8443". | |
| 127 | +pub fn authority(host: &str) -> String { | |
| 128 | + host.split_once("://").map(|(_, rest)| rest).unwrap_or(host).split('/').next().unwrap_or("").to_string() | |
| 129 | +} | |
| 130 | + | |
| 131 | +#[cfg(test)] | |
| 132 | +mod tests { | |
| 133 | + use super::*; | |
| 134 | + | |
| 135 | + #[test] | |
| 136 | + fn hosts_normalize() { | |
| 137 | + assert_eq!(normalize_host("git.example.com").unwrap(), "https://git.example.com"); | |
| 138 | + assert_eq!(normalize_host("https://Git.Example.com/").unwrap(), "https://git.example.com"); | |
| 139 | + assert_eq!(normalize_host("localhost:7878").unwrap(), "http://localhost:7878"); | |
| 140 | + assert_eq!(normalize_host("http://localhost:7878/alice/repo").unwrap(), "http://localhost:7878"); | |
| 141 | + assert!(normalize_host("ftp://x").is_err()); | |
| 142 | + assert!(normalize_host("https://user@host").is_err()); | |
| 143 | + assert!(normalize_host("").is_err()); | |
| 144 | + } | |
| 145 | + | |
| 146 | + #[test] | |
| 147 | + fn config_roundtrips_and_parses() { | |
| 148 | + let text = r#" | |
| 149 | +default_host = "https://git.example.com" | |
| 150 | + | |
| 151 | +[hosts."https://git.example.com"] | |
| 152 | +username = "alice" | |
| 153 | +token = "igp_abc" | |
| 154 | + | |
| 155 | +[hosts."http://localhost:7878"] | |
| 156 | +username = "bob" | |
| 157 | +token = "igp_def" | |
| 158 | +"#; | |
| 159 | + let config = Config::parse(text).unwrap(); | |
| 160 | + assert_eq!(config.default_host.as_deref(), Some("https://git.example.com")); | |
| 161 | + assert_eq!(config.hosts["http://localhost:7878"].username, "bob"); | |
| 162 | + let again = Config::parse(&toml::to_string_pretty(&config).unwrap()).unwrap(); | |
| 163 | + assert_eq!(config, again); | |
| 164 | + assert!(Config::parse("").unwrap().hosts.is_empty()); | |
| 165 | + } | |
| 166 | + | |
| 167 | + #[test] | |
| 168 | + fn authority_lookup_matches_credential_helper_input() { | |
| 169 | + let config = Config::parse("[hosts.\"http://localhost:7878\"]\nusername = \"bob\"\ntoken = \"t\"\n").unwrap(); | |
| 170 | + assert_eq!(config.find_by_authority("localhost:7878").unwrap().1.username, "bob"); | |
| 171 | + assert_eq!(config.find_by_authority("LOCALHOST:7878/").unwrap().1.username, "bob"); | |
| 172 | + assert!(config.find_by_authority("localhost:9999").is_none()); | |
| 173 | + assert_eq!(authority("https://a.b:8443"), "a.b:8443"); | |
| 174 | + } | |
| 175 | + | |
| 176 | + #[test] | |
| 177 | + fn resolve_prefers_flag() { | |
| 178 | + let config = Config { default_host: Some("https://a.example".into()), hosts: Default::default() }; | |
| 179 | + assert_eq!(config.resolve_host(None).unwrap(), "https://a.example"); | |
| 180 | + assert_eq!(config.resolve_host(Some("b.example")).unwrap(), "https://b.example"); | |
| 181 | + assert!(Config::default().resolve_host(None).is_err()); | |
| 182 | + } | |
| 183 | +} |
+347-1cli/src/main.rs
| @@ -1 +1,347 @@ | ||
| 1 | -fn main() {} | |
| 1 | +//! ig: the irongit command line tool. | |
| 2 | +//! | |
| 3 | +//! Also acts as `docker-credential-ig` when invoked under that name (a | |
| 4 | +//! symlink install.sh creates), so `docker push`/`pull` use the token from | |
| 5 | +//! `ig login` without `docker login`. | |
| 6 | + | |
| 7 | +mod auth; | |
| 8 | +mod client; | |
| 9 | +mod cmds; | |
| 10 | +mod config; | |
| 11 | +mod output; | |
| 12 | +mod upgrade; | |
| 13 | + | |
| 14 | +use clap::{Args, Parser, Subcommand}; | |
| 15 | +use irongit_shared::UpdateRepo; | |
| 16 | + | |
| 17 | +use crate::cmds::Session; | |
| 18 | + | |
| 19 | +/// Release builds set IG_BUILD_VERSION; otherwise the crate version. | |
| 20 | +pub const VERSION: &str = match option_env!("IG_BUILD_VERSION") { | |
| 21 | + Some(v) => v, | |
| 22 | + None => env!("CARGO_PKG_VERSION"), | |
| 23 | +}; | |
| 24 | + | |
| 25 | +#[derive(Parser)] | |
| 26 | +#[command(name = "ig", version = VERSION, about = "Work with irongit repositories, images and accounts from the terminal")] | |
| 27 | +struct Cli { | |
| 28 | + /// Server to use, e.g. https://git.example.com (default: the one you logged in to). | |
| 29 | + #[arg(long, global = true, env = "IG_HOST")] | |
| 30 | + host: Option<String>, | |
| 31 | + /// Print JSON instead of tables. | |
| 32 | + #[arg(long, global = true)] | |
| 33 | + json: bool, | |
| 34 | + #[command(subcommand)] | |
| 35 | + command: Cmd, | |
| 36 | +} | |
| 37 | + | |
| 38 | +#[derive(Subcommand)] | |
| 39 | +enum Cmd { | |
| 40 | + /// Sign in through the browser (or --with-token) and set up git and docker. | |
| 41 | + Login { | |
| 42 | + /// Read a personal access token from stdin instead of using the browser. | |
| 43 | + #[arg(long)] | |
| 44 | + with_token: bool, | |
| 45 | + /// Do not configure the git and docker credential helpers. | |
| 46 | + #[arg(long)] | |
| 47 | + skip_setup: bool, | |
| 48 | + }, | |
| 49 | + /// Forget the saved token for a host and revoke it on the server. | |
| 50 | + Logout, | |
| 51 | + /// Authentication status, tokens and credential helpers. | |
| 52 | + #[command(subcommand)] | |
| 53 | + Auth(AuthCmd), | |
| 54 | + /// Repositories. | |
| 55 | + #[command(subcommand)] | |
| 56 | + Repo(RepoCmd), | |
| 57 | + /// Container images in the registry. | |
| 58 | + #[command(subcommand)] | |
| 59 | + Image(ImageCmd), | |
| 60 | + /// SSH keys for git over SSH. | |
| 61 | + #[command(subcommand, name = "ssh-key")] | |
| 62 | + SshKey(KeyCmd), | |
| 63 | + /// Personal access tokens. | |
| 64 | + #[command(subcommand)] | |
| 65 | + Token(TokenCmd), | |
| 66 | + /// Organizations. | |
| 67 | + #[command(subcommand)] | |
| 68 | + Org(OrgCmd), | |
| 69 | + /// Call the API directly: ig api GET user | |
| 70 | + Api { | |
| 71 | + method: String, | |
| 72 | + path: String, | |
| 73 | + /// JSON body, or - to read it from stdin. | |
| 74 | + #[arg(short = 'd', long = "data")] | |
| 75 | + data: Option<String>, | |
| 76 | + }, | |
| 77 | + /// Replace ig with the newest release from the server. | |
| 78 | + Upgrade { | |
| 79 | + /// Reinstall even if this version is current. | |
| 80 | + #[arg(long)] | |
| 81 | + force: bool, | |
| 82 | + /// Only report whether a newer version exists. | |
| 83 | + #[arg(long)] | |
| 84 | + check: bool, | |
| 85 | + }, | |
| 86 | +} | |
| 87 | + | |
| 88 | +#[derive(Subcommand)] | |
| 89 | +enum AuthCmd { | |
| 90 | + /// Show which hosts you are logged in to. | |
| 91 | + Status, | |
| 92 | + /// Print the token for the current host. | |
| 93 | + Token, | |
| 94 | + /// Make git use ig for this host's HTTPS credentials. | |
| 95 | + SetupGit, | |
| 96 | + /// Make docker use ig for this host's registry credentials. | |
| 97 | + SetupDocker, | |
| 98 | + /// git credential helper protocol (called by git). | |
| 99 | + #[command(hide = true)] | |
| 100 | + GitCredential { operation: String }, | |
| 101 | +} | |
| 102 | + | |
| 103 | +#[derive(Subcommand)] | |
| 104 | +enum RepoCmd { | |
| 105 | + /// Create a repository. | |
| 106 | + Create { | |
| 107 | + name: String, | |
| 108 | + /// Create it in this organization instead of your account. | |
| 109 | + #[arg(long)] | |
| 110 | + org: Option<String>, | |
| 111 | + #[command(flatten)] | |
| 112 | + visibility: VisibilityFlags, | |
| 113 | + #[arg(short = 'd', long)] | |
| 114 | + description: Option<String>, | |
| 115 | + }, | |
| 116 | + /// List repositories of a user or organization (default: you). | |
| 117 | + List { owner: Option<String> }, | |
| 118 | + /// Show a repository. | |
| 119 | + View { repo: String }, | |
| 120 | + /// Clone a repository with git. | |
| 121 | + Clone { | |
| 122 | + repo: String, | |
| 123 | + dir: Option<String>, | |
| 124 | + /// Clone over SSH instead of HTTPS. | |
| 125 | + #[arg(long)] | |
| 126 | + ssh: bool, | |
| 127 | + }, | |
| 128 | + /// Delete a repository and all of its history. | |
| 129 | + Delete { | |
| 130 | + repo: String, | |
| 131 | + #[arg(long)] | |
| 132 | + yes: bool, | |
| 133 | + }, | |
| 134 | + /// Make a repository public or private. | |
| 135 | + Visibility { | |
| 136 | + repo: String, | |
| 137 | + #[arg(value_parser = ["public", "private"])] | |
| 138 | + visibility: String, | |
| 139 | + }, | |
| 140 | + /// Change the description, default branch or archived state. | |
| 141 | + Edit { | |
| 142 | + repo: String, | |
| 143 | + #[arg(short = 'd', long)] | |
| 144 | + description: Option<String>, | |
| 145 | + #[arg(long)] | |
| 146 | + default_branch: Option<String>, | |
| 147 | + #[arg(long, conflicts_with = "unarchive")] | |
| 148 | + archive: bool, | |
| 149 | + #[arg(long)] | |
| 150 | + unarchive: bool, | |
| 151 | + }, | |
| 152 | + /// Manage collaborators. | |
| 153 | + #[command(subcommand)] | |
| 154 | + Collab(CollabCmd), | |
| 155 | +} | |
| 156 | + | |
| 157 | +#[derive(Args)] | |
| 158 | +struct VisibilityFlags { | |
| 159 | + /// Anyone can see and clone it. | |
| 160 | + #[arg(long, conflicts_with = "private")] | |
| 161 | + public: bool, | |
| 162 | + /// Only you and people you add (the default). | |
| 163 | + #[arg(long)] | |
| 164 | + private: bool, | |
| 165 | +} | |
| 166 | + | |
| 167 | +#[derive(Subcommand)] | |
| 168 | +enum CollabCmd { | |
| 169 | + List { repo: String }, | |
| 170 | + Add { | |
| 171 | + repo: String, | |
| 172 | + user: String, | |
| 173 | + #[arg(long, default_value = "write", value_parser = ["read", "write", "admin"])] | |
| 174 | + permission: String, | |
| 175 | + }, | |
| 176 | + Remove { repo: String, user: String }, | |
| 177 | +} | |
| 178 | + | |
| 179 | +#[derive(Subcommand)] | |
| 180 | +enum ImageCmd { | |
| 181 | + /// List images of a user or organization (default: you). | |
| 182 | + List { owner: Option<String> }, | |
| 183 | + /// List an image's tags. | |
| 184 | + Tags { image: String }, | |
| 185 | + /// Make an image public or private (independent of any repository). | |
| 186 | + Visibility { | |
| 187 | + image: String, | |
| 188 | + #[arg(value_parser = ["public", "private"])] | |
| 189 | + visibility: String, | |
| 190 | + }, | |
| 191 | + /// Delete an image, or one tag with owner/name:tag. | |
| 192 | + Delete { | |
| 193 | + image: String, | |
| 194 | + #[arg(long)] | |
| 195 | + yes: bool, | |
| 196 | + }, | |
| 197 | +} | |
| 198 | + | |
| 199 | +#[derive(Subcommand)] | |
| 200 | +enum KeyCmd { | |
| 201 | + /// Add a public key (default ~/.ssh/id_ed25519.pub). | |
| 202 | + Add { | |
| 203 | + file: Option<String>, | |
| 204 | + #[arg(long)] | |
| 205 | + title: Option<String>, | |
| 206 | + }, | |
| 207 | + List, | |
| 208 | + Remove { id: i64 }, | |
| 209 | +} | |
| 210 | + | |
| 211 | +#[derive(Subcommand)] | |
| 212 | +enum TokenCmd { | |
| 213 | + /// Create a token; the secret is printed once. | |
| 214 | + Create { | |
| 215 | + name: String, | |
| 216 | + /// Comma-separated: repo,packages,user,admin (default repo,packages,user). | |
| 217 | + #[arg(long)] | |
| 218 | + scopes: Option<String>, | |
| 219 | + #[arg(long)] | |
| 220 | + expires_days: Option<u32>, | |
| 221 | + }, | |
| 222 | + List, | |
| 223 | + Revoke { id: i64 }, | |
| 224 | +} | |
| 225 | + | |
| 226 | +#[derive(Subcommand)] | |
| 227 | +enum OrgCmd { | |
| 228 | + Create { | |
| 229 | + name: String, | |
| 230 | + #[arg(long)] | |
| 231 | + display_name: Option<String>, | |
| 232 | + }, | |
| 233 | + /// Organizations you belong to. | |
| 234 | + List, | |
| 235 | + Members { org: String }, | |
| 236 | + /// Add a member or change their role. | |
| 237 | + Add { | |
| 238 | + org: String, | |
| 239 | + user: String, | |
| 240 | + #[arg(long, default_value = "member", value_parser = ["member", "owner"])] | |
| 241 | + role: String, | |
| 242 | + }, | |
| 243 | + Remove { org: String, user: String }, | |
| 244 | +} | |
| 245 | + | |
| 246 | +fn main() { | |
| 247 | + let args: Vec<String> = std::env::args().collect(); | |
| 248 | + let invoked_as = args.first().and_then(|a| std::path::Path::new(a).file_name()).and_then(|n| n.to_str()).unwrap_or("ig"); | |
| 249 | + let result = if invoked_as.starts_with("docker-credential-") { | |
| 250 | + auth::docker_credential_helper(&args) | |
| 251 | + } else { | |
| 252 | + run(Cli::parse()) | |
| 253 | + }; | |
| 254 | + if let Err(error) = result { | |
| 255 | + eprintln!("ig: {error:#}"); | |
| 256 | + std::process::exit(1); | |
| 257 | + } | |
| 258 | +} | |
| 259 | + | |
| 260 | +fn run(cli: Cli) -> anyhow::Result<()> { | |
| 261 | + let host = cli.host.as_deref(); | |
| 262 | + let session = || Session::open(host, cli.json); | |
| 263 | + match cli.command { | |
| 264 | + Cmd::Login { with_token, skip_setup } => auth::login(auth::LoginOptions { host: cli.host.clone(), with_token, skip_setup }), | |
| 265 | + Cmd::Logout => auth::logout(host), | |
| 266 | + Cmd::Auth(cmd) => match cmd { | |
| 267 | + AuthCmd::Status => auth::status(host, cli.json), | |
| 268 | + AuthCmd::Token => auth::print_token(host), | |
| 269 | + AuthCmd::SetupGit => { | |
| 270 | + let h = config::Config::load()?.resolve_host(host)?; | |
| 271 | + auth::setup_git(&h)?; | |
| 272 | + println!("git uses ig for {h} credentials"); | |
| 273 | + Ok(()) | |
| 274 | + } | |
| 275 | + AuthCmd::SetupDocker => { | |
| 276 | + let h = config::Config::load()?.resolve_host(host)?; | |
| 277 | + let note = auth::setup_docker(&h)?; | |
| 278 | + println!("docker uses ig for {}{note}", config::authority(&h)); | |
| 279 | + Ok(()) | |
| 280 | + } | |
| 281 | + AuthCmd::GitCredential { operation } => auth::git_credential(&operation), | |
| 282 | + }, | |
| 283 | + Cmd::Repo(cmd) => { | |
| 284 | + let s = session()?; | |
| 285 | + match cmd { | |
| 286 | + RepoCmd::Create { name, org, visibility, description } => cmds::repo_create(&s, &name, org.as_deref(), visibility.public, description.as_deref()), | |
| 287 | + RepoCmd::List { owner } => cmds::repo_list(&s, owner.as_deref()), | |
| 288 | + RepoCmd::View { repo } => cmds::repo_view(&s, &repo), | |
| 289 | + RepoCmd::Clone { repo, dir, ssh } => cmds::repo_clone(&s, &repo, dir.as_deref(), ssh), | |
| 290 | + RepoCmd::Delete { repo, yes } => cmds::repo_delete(&s, &repo, yes), | |
| 291 | + RepoCmd::Visibility { repo, visibility } => { | |
| 292 | + let what = format!("is now {visibility}"); | |
| 293 | + cmds::repo_update(&s, &repo, UpdateRepo { visibility: Some(visibility), ..Default::default() }, &what) | |
| 294 | + } | |
| 295 | + RepoCmd::Edit { repo, description, default_branch, archive, unarchive } => { | |
| 296 | + let archived = if archive { Some(true) } else if unarchive { Some(false) } else { None }; | |
| 297 | + if description.is_none() && default_branch.is_none() && archived.is_none() { | |
| 298 | + anyhow::bail!("nothing to change: pass --description, --default-branch, --archive or --unarchive"); | |
| 299 | + } | |
| 300 | + cmds::repo_update(&s, &repo, UpdateRepo { description, default_branch, archived, visibility: None }, "updated") | |
| 301 | + } | |
| 302 | + RepoCmd::Collab(c) => match c { | |
| 303 | + CollabCmd::List { repo } => cmds::collab_list(&s, &repo), | |
| 304 | + CollabCmd::Add { repo, user, permission } => cmds::collab_add(&s, &repo, &user, &permission), | |
| 305 | + CollabCmd::Remove { repo, user } => cmds::collab_remove(&s, &repo, &user), | |
| 306 | + }, | |
| 307 | + } | |
| 308 | + } | |
| 309 | + Cmd::Image(cmd) => { | |
| 310 | + let s = session()?; | |
| 311 | + match cmd { | |
| 312 | + ImageCmd::List { owner } => cmds::image_list(&s, owner.as_deref()), | |
| 313 | + ImageCmd::Tags { image } => cmds::image_tags(&s, &image), | |
| 314 | + ImageCmd::Visibility { image, visibility } => cmds::image_visibility(&s, &image, &visibility), | |
| 315 | + ImageCmd::Delete { image, yes } => cmds::image_delete(&s, &image, yes), | |
| 316 | + } | |
| 317 | + } | |
| 318 | + Cmd::SshKey(cmd) => { | |
| 319 | + let s = session()?; | |
| 320 | + match cmd { | |
| 321 | + KeyCmd::Add { file, title } => cmds::key_add(&s, file.as_deref(), title.as_deref()), | |
| 322 | + KeyCmd::List => cmds::key_list(&s), | |
| 323 | + KeyCmd::Remove { id } => cmds::key_remove(&s, id), | |
| 324 | + } | |
| 325 | + } | |
| 326 | + Cmd::Token(cmd) => { | |
| 327 | + let s = session()?; | |
| 328 | + match cmd { | |
| 329 | + TokenCmd::Create { name, scopes, expires_days } => cmds::token_create(&s, &name, scopes.as_deref(), expires_days), | |
| 330 | + TokenCmd::List => cmds::token_list(&s), | |
| 331 | + TokenCmd::Revoke { id } => cmds::token_revoke(&s, id), | |
| 332 | + } | |
| 333 | + } | |
| 334 | + Cmd::Org(cmd) => { | |
| 335 | + let s = session()?; | |
| 336 | + match cmd { | |
| 337 | + OrgCmd::Create { name, display_name } => cmds::org_create(&s, &name, display_name.as_deref()), | |
| 338 | + OrgCmd::List => cmds::org_list(&s), | |
| 339 | + OrgCmd::Members { org } => cmds::org_members(&s, &org), | |
| 340 | + OrgCmd::Add { org, user, role } => cmds::org_add(&s, &org, &user, &role), | |
| 341 | + OrgCmd::Remove { org, user } => cmds::org_remove(&s, &org, &user), | |
| 342 | + } | |
| 343 | + } | |
| 344 | + Cmd::Api { method, path, data } => cmds::api(&session()?, &method, &path, data.as_deref()), | |
| 345 | + Cmd::Upgrade { force, check } => upgrade::upgrade(host, force, check), | |
| 346 | + } | |
| 347 | +} |
+106-0cli/src/output.rs
| @@ -0,0 +1,106 @@ | ||
| 1 | +//! Terminal output: aligned tables, sizes, relative times. | |
| 2 | + | |
| 3 | +use std::io::IsTerminal; | |
| 4 | + | |
| 5 | +use chrono::{DateTime, Utc}; | |
| 6 | + | |
| 7 | +fn color() -> bool { | |
| 8 | + std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() | |
| 9 | +} | |
| 10 | + | |
| 11 | +pub fn bold(s: &str) -> String { | |
| 12 | + if color() { format!("\x1b[1m{s}\x1b[0m") } else { s.to_string() } | |
| 13 | +} | |
| 14 | + | |
| 15 | +pub fn dim(s: &str) -> String { | |
| 16 | + if color() { format!("\x1b[2m{s}\x1b[0m") } else { s.to_string() } | |
| 17 | +} | |
| 18 | + | |
| 19 | +pub fn green(s: &str) -> String { | |
| 20 | + if color() { format!("\x1b[32m{s}\x1b[0m") } else { s.to_string() } | |
| 21 | +} | |
| 22 | + | |
| 23 | +pub fn yellow(s: &str) -> String { | |
| 24 | + if color() { format!("\x1b[33m{s}\x1b[0m") } else { s.to_string() } | |
| 25 | +} | |
| 26 | + | |
| 27 | +/// Prints rows under headers, columns padded to the widest cell. | |
| 28 | +pub fn table(headers: &[&str], rows: &[Vec<String>]) { | |
| 29 | + if rows.is_empty() { | |
| 30 | + return; | |
| 31 | + } | |
| 32 | + let mut widths: Vec<usize> = headers.iter().map(|h| h.chars().count()).collect(); | |
| 33 | + for row in rows { | |
| 34 | + for (i, cell) in row.iter().enumerate() { | |
| 35 | + if i < widths.len() { | |
| 36 | + widths[i] = widths[i].max(cell.chars().count()); | |
| 37 | + } | |
| 38 | + } | |
| 39 | + } | |
| 40 | + let line = |cells: Vec<String>, style: fn(&str) -> String| { | |
| 41 | + let last = cells.len().saturating_sub(1); | |
| 42 | + let text: Vec<String> = cells | |
| 43 | + .iter() | |
| 44 | + .enumerate() | |
| 45 | + .map(|(i, c)| { | |
| 46 | + let padded = if i == last { c.clone() } else { format!("{c:<width$}", width = widths[i]) }; | |
| 47 | + style(&padded) | |
| 48 | + }) | |
| 49 | + .collect(); | |
| 50 | + println!("{}", text.join(" ").trim_end()); | |
| 51 | + }; | |
| 52 | + line(headers.iter().map(|h| h.to_string()).collect(), dim); | |
| 53 | + for row in rows { | |
| 54 | + line(row.clone(), |s| s.to_string()); | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +pub fn bytes(n: i64) -> String { | |
| 59 | + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; | |
| 60 | + let mut value = n.max(0) as f64; | |
| 61 | + let mut unit = 0; | |
| 62 | + while value >= 1024.0 && unit < UNITS.len() - 1 { | |
| 63 | + value /= 1024.0; | |
| 64 | + unit += 1; | |
| 65 | + } | |
| 66 | + if unit == 0 { format!("{n} B") } else if value < 10.0 { format!("{value:.1} {}", UNITS[unit]) } else { format!("{value:.0} {}", UNITS[unit]) } | |
| 67 | +} | |
| 68 | + | |
| 69 | +pub fn ago(at: Option<DateTime<Utc>>) -> String { | |
| 70 | + let Some(at) = at else { return "never".into() }; | |
| 71 | + let seconds = (Utc::now() - at).num_seconds(); | |
| 72 | + let (n, unit) = match seconds { | |
| 73 | + s if s < 45 => return "just now".into(), | |
| 74 | + s if s < 3600 => ((s + 30) / 60, "minute"), | |
| 75 | + s if s < 86_400 => ((s + 1800) / 3600, "hour"), | |
| 76 | + s if s < 30 * 86_400 => ((s + 43_200) / 86_400, "day"), | |
| 77 | + s if s < 365 * 86_400 => (s / (30 * 86_400), "month"), | |
| 78 | + s => (s / (365 * 86_400), "year"), | |
| 79 | + }; | |
| 80 | + let n = n.max(1); | |
| 81 | + format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }) | |
| 82 | +} | |
| 83 | + | |
| 84 | +pub fn json<T: serde::Serialize>(value: &T) -> anyhow::Result<()> { | |
| 85 | + println!("{}", serde_json::to_string_pretty(value)?); | |
| 86 | + Ok(()) | |
| 87 | +} | |
| 88 | + | |
| 89 | +#[cfg(test)] | |
| 90 | +mod tests { | |
| 91 | + use super::*; | |
| 92 | + | |
| 93 | + #[test] | |
| 94 | + fn sizes() { | |
| 95 | + assert_eq!(bytes(0), "0 B"); | |
| 96 | + assert_eq!(bytes(2048), "2.0 KB"); | |
| 97 | + assert_eq!(bytes(5 * 1024 * 1024 * 1024), "5.0 GB"); | |
| 98 | + } | |
| 99 | + | |
| 100 | + #[test] | |
| 101 | + fn relative_times() { | |
| 102 | + assert_eq!(ago(None), "never"); | |
| 103 | + assert_eq!(ago(Some(Utc::now())), "just now"); | |
| 104 | + assert_eq!(ago(Some(Utc::now() - chrono::Duration::days(3))), "3 days ago"); | |
| 105 | + } | |
| 106 | +} |
+62-0cli/src/upgrade.rs
| @@ -0,0 +1,62 @@ | ||
| 1 | +//! `ig upgrade`: replace this executable with the server's newest release. | |
| 2 | + | |
| 3 | +use std::io::Write; | |
| 4 | + | |
| 5 | +use anyhow::{Context, bail}; | |
| 6 | +use irongit_shared::{self as shared, version_newer}; | |
| 7 | +use sha2::{Digest, Sha256}; | |
| 8 | + | |
| 9 | +use crate::{ | |
| 10 | + VERSION, | |
| 11 | + client::{Client, http_client}, | |
| 12 | + config::Config, | |
| 13 | + output::{bold, green}, | |
| 14 | +}; | |
| 15 | + | |
| 16 | +pub fn upgrade(host_flag: Option<&str>, force: bool, check_only: bool) -> anyhow::Result<()> { | |
| 17 | + let config = Config::load()?; | |
| 18 | + let host = config.resolve_host(host_flag)?; | |
| 19 | + let latest: shared::CliRelease = Client::new(&host, None)?.get("/api/v1/cli/latest").context("asking the server for the latest ig")?; | |
| 20 | + | |
| 21 | + let newer = version_newer(&latest.version, VERSION); | |
| 22 | + if !newer && !force { | |
| 23 | + println!("{} ig {VERSION} is up to date", green("✓")); | |
| 24 | + return Ok(()); | |
| 25 | + } | |
| 26 | + if check_only { | |
| 27 | + println!("ig {} is available (you have {VERSION}). Run: ig upgrade", bold(&latest.version)); | |
| 28 | + return Ok(()); | |
| 29 | + } | |
| 30 | + | |
| 31 | + let exe = std::env::current_exe().context("cannot locate the running ig")?; | |
| 32 | + println!("Downloading ig {} ({} bytes)...", latest.version, latest.size); | |
| 33 | + // A fresh client: the download redirects to storage and must not carry a token. | |
| 34 | + let mut response = http_client()?.get(&latest.url).send()?.error_for_status().context("downloading the release")?; | |
| 35 | + let mut bytes = Vec::with_capacity(latest.size.max(0) as usize); | |
| 36 | + std::io::copy(&mut response, &mut bytes)?; | |
| 37 | + let actual = hex::encode(Sha256::digest(&bytes)); | |
| 38 | + if actual != latest.sha256 { | |
| 39 | + bail!("checksum mismatch: expected {}, got {actual}. Nothing was changed.", latest.sha256); | |
| 40 | + } | |
| 41 | + | |
| 42 | + // Write next to the executable and rename over it: atomic on one filesystem, | |
| 43 | + // and safe while the old binary is still running. | |
| 44 | + let dir = exe.parent().context("executable has no directory")?; | |
| 45 | + let tmp = dir.join(format!(".ig-upgrade-{}", std::process::id())); | |
| 46 | + { | |
| 47 | + let mut file = std::fs::File::create(&tmp).with_context(|| format!("cannot write to {} (try with sudo?)", dir.display()))?; | |
| 48 | + file.write_all(&bytes)?; | |
| 49 | + file.sync_all()?; | |
| 50 | + } | |
| 51 | + #[cfg(unix)] | |
| 52 | + { | |
| 53 | + use std::os::unix::fs::PermissionsExt; | |
| 54 | + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?; | |
| 55 | + } | |
| 56 | + if let Err(error) = std::fs::rename(&tmp, &exe) { | |
| 57 | + let _ = std::fs::remove_file(&tmp); | |
| 58 | + return Err(error).with_context(|| format!("replacing {}", exe.display())); | |
| 59 | + } | |
| 60 | + println!("{} Upgraded ig {VERSION} -> {} ({})", green("✓"), bold(&latest.version), exe.display()); | |
| 61 | + Ok(()) | |
| 62 | +} |