irongit

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

huncholanehuncholaneauthored
parent 5443793commit b229bcc613a6a519e0c6ef2f10f3476a0d0f2beeBrowse files

10 files changed, +1717 -3

+1-0Cargo.lock
@@ -1876,6 +1876,7 @@ name = "ig"
18761876 version = "0.1.0"
18771877 dependencies = [
18781878 "anyhow",
1879+ "chrono",
18791880 "clap",
18801881 "dirs",
18811882 "hex",
+2-2backend/src/api/release.rs
@@ -13,7 +13,7 @@ use axum::{
1313 Json,
1414 extract::{Query, State},
1515 http::{HeaderMap, StatusCode, header},
16- response::{IntoResponse, Redirect, Response},
16+ response::{IntoResponse, Response},
1717 };
1818 use chrono::{DateTime, Utc};
1919 use irongit_shared as shared;
@@ -124,7 +124,7 @@ pub async fn download(State(state): State<AppState>, headers: HeaderMap, Query(q
124124 let agent = auth::user_agent(&headers).unwrap_or("").chars().take(40).collect::<String>();
125125 analytics::track(&state, "cli_downloaded", None, "/download/ig", json!({ "version": row.version, "agent": agent }));
126126 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()
128128 }
129129 Ok(None) => (StatusCode::NOT_FOUND, "No ig release has been published yet.\n").into_response(),
130130 Err(error) => {
+1-0cli/Cargo.toml
@@ -5,6 +5,7 @@ edition = "2024"
55
66 [dependencies]
77 anyhow = "1.0.104"
8+chrono = "0.4.45"
89 clap = { version = "4.6.7", features = ["derive", "env"] }
910 dirs = "7.0.0"
1011 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(&current_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(&registry_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+}