Skip to main content

gor/cmd/
keys.rs

1//! Implementation of the `gor ssh-key` and `gor gpg-key` subcommands.
2//!
3//! Provides SSH and GPG key management for the authenticated user.
4
5#![allow(clippy::print_stdout)]
6
7use crate::cli::{GpgKeyCommand, SshKeyCommand};
8use crate::client::Client;
9use crate::output::print_json;
10use anyhow::Context;
11use std::fs;
12
13/// Run the `gor ssh-key` subcommand.
14///
15/// # Errors
16///
17/// Returns an error if the command execution fails.
18pub fn run_ssh(cmd: SshKeyCommand) -> anyhow::Result<()> {
19    match cmd {
20        SshKeyCommand::List { json, hostname } => list_ssh_keys(json, hostname.as_deref()),
21        SshKeyCommand::Add {
22            title,
23            file,
24            body,
25            hostname,
26        } => add_ssh_key(
27            &title,
28            file.as_deref(),
29            body.as_deref(),
30            hostname.as_deref(),
31        ),
32        SshKeyCommand::Delete {
33            key_id,
34            yes,
35            hostname,
36        } => delete_ssh_key(&key_id, yes, hostname.as_deref()),
37    }
38}
39
40/// Run the `gor gpg-key` subcommand.
41///
42/// # Errors
43///
44/// Returns an error if the command execution fails.
45pub fn run_gpg(cmd: GpgKeyCommand) -> anyhow::Result<()> {
46    match cmd {
47        GpgKeyCommand::List { json, hostname } => list_gpg_keys(json, hostname.as_deref()),
48        GpgKeyCommand::Add {
49            file,
50            body,
51            hostname,
52        } => add_gpg_key(file.as_deref(), body.as_deref(), hostname.as_deref()),
53        GpgKeyCommand::Delete {
54            key_id,
55            yes,
56            hostname,
57        } => delete_gpg_key(&key_id, yes, hostname.as_deref()),
58    }
59}
60
61/// Execute `gor ssh-key list`.
62///
63/// Lists SSH keys for the authenticated user.
64///
65/// # Errors
66///
67/// Returns an error if the API request fails.
68fn list_ssh_keys(json: Option<Vec<String>>, hostname: Option<&str>) -> anyhow::Result<()> {
69    let host = hostname.unwrap_or("github.com");
70    let client = Client::new(host).context("failed to create HTTP client")?;
71
72    let response = client
73        .get("/user/keys")
74        .context("failed to fetch SSH keys")?;
75
76    let status = response.status();
77    if !status.is_success() {
78        anyhow::bail!("failed to list SSH keys: HTTP {status}");
79    }
80
81    let keys: Vec<serde_json::Value> = response
82        .json()
83        .context("failed to parse SSH keys response")?;
84
85    if let Some(fields) = json {
86        let fields_ref: Option<&[String]> = if fields.is_empty() {
87            None
88        } else {
89            Some(&fields)
90        };
91        print_json(&keys, fields_ref);
92        return Ok(());
93    }
94
95    if keys.is_empty() {
96        println!("No SSH keys found.");
97        return Ok(());
98    }
99
100    let title_width = 30;
101    let type_width = 10;
102
103    println!("{:<title_width$}  {:<type_width$}  KEY", "TITLE", "TYPE");
104
105    for key in &keys {
106        let title = key["title"].as_str().unwrap_or("—");
107        let key_type = key["key"]
108            .as_str()
109            .unwrap_or("")
110            .split(' ')
111            .next()
112            .unwrap_or("—");
113        let key_short = key["key"]
114            .as_str()
115            .unwrap_or("")
116            .split(' ')
117            .nth(1)
118            .unwrap_or("")
119            .chars()
120            .take(40)
121            .collect::<String>();
122
123        let title_truncated = crate::cmd::util::truncate(title, title_width);
124
125        println!("{title_truncated:<title_width$}  {key_type:<type_width$}  {key_short}");
126    }
127
128    Ok(())
129}
130
131/// Execute `gor ssh-key add`.
132///
133/// Adds an SSH public key to the authenticated user's account.
134///
135/// # Errors
136///
137/// Returns an error if the key body is missing or the API request fails.
138fn add_ssh_key(
139    title: &str,
140    file: Option<&str>,
141    body: Option<&str>,
142    hostname: Option<&str>,
143) -> anyhow::Result<()> {
144    let key_body = if let Some(b) = body {
145        b.to_string()
146    } else if let Some(f) = file {
147        fs::read_to_string(f)
148            .with_context(|| format!("failed to read SSH key file: {f}"))?
149            .trim()
150            .to_string()
151    } else {
152        anyhow::bail!("no SSH key provided (use --file or --body)");
153    };
154
155    if key_body.is_empty() {
156        anyhow::bail!("SSH key body is empty");
157    }
158
159    let host = hostname.unwrap_or("github.com");
160    let client = Client::new(host).context("failed to create HTTP client")?;
161
162    let body_value = serde_json::json!({
163        "title": title,
164        "key": key_body,
165    });
166
167    let response = client
168        .post("/user/keys", &body_value)
169        .context("failed to add SSH key")?;
170
171    let status = response.status();
172    if !status.is_success() {
173        let err_body: serde_json::Value = response.json().unwrap_or_default();
174        let msg = err_body["message"].as_str().unwrap_or("add failed");
175        anyhow::bail!("failed to add SSH key: {msg}");
176    }
177
178    let result: serde_json::Value = response.json().context("failed to parse response")?;
179    let key_id = result["id"].as_u64().unwrap_or(0);
180    println!("SSH key added: {key_id} ({title})");
181    Ok(())
182}
183
184/// Execute `gor ssh-key delete`.
185///
186/// Deletes an SSH public key from the authenticated user's account.
187///
188/// # Errors
189///
190/// Returns an error if the key does not exist or the API request fails.
191fn delete_ssh_key(key_id: &str, yes: bool, hostname: Option<&str>) -> anyhow::Result<()> {
192    if !yes {
193        use std::io::Write;
194        print!("Are you sure you want to delete SSH key '{key_id}'? [y/N] ");
195        std::io::stdout().flush().ok();
196
197        let mut input = String::new();
198        std::io::stdin()
199            .read_line(&mut input)
200            .context("failed to read input")?;
201        let input = input.trim().to_lowercase();
202        if input != "y" && input != "yes" {
203            println!("Cancelled.");
204            return Ok(());
205        }
206    }
207
208    let host = hostname.unwrap_or("github.com");
209    let client = Client::new(host).context("failed to create HTTP client")?;
210
211    let path = format!("/user/keys/{key_id}");
212    let response = client
213        .request("DELETE", &path, &[], None)
214        .context("failed to delete SSH key")?;
215
216    let status = response.status();
217    if !status.is_success() {
218        let err_body: serde_json::Value = response.json().unwrap_or_default();
219        let msg = err_body["message"].as_str().unwrap_or("delete failed");
220        anyhow::bail!("failed to delete SSH key '{key_id}': {msg}");
221    }
222
223    println!("SSH key '{key_id}' deleted.");
224    Ok(())
225}
226
227/// Execute `gor gpg-key list`.
228///
229/// Lists GPG keys for the authenticated user.
230///
231/// # Errors
232///
233/// Returns an error if the API request fails.
234fn list_gpg_keys(json: Option<Vec<String>>, hostname: Option<&str>) -> anyhow::Result<()> {
235    let host = hostname.unwrap_or("github.com");
236    let client = Client::new(host).context("failed to create HTTP client")?;
237
238    let response = client
239        .get("/user/gpg_keys")
240        .context("failed to fetch GPG keys")?;
241
242    let status = response.status();
243    if !status.is_success() {
244        anyhow::bail!("failed to list GPG keys: HTTP {status}");
245    }
246
247    let keys: Vec<serde_json::Value> = response
248        .json()
249        .context("failed to parse GPG keys response")?;
250
251    if let Some(fields) = json {
252        let fields_ref: Option<&[String]> = if fields.is_empty() {
253            None
254        } else {
255            Some(&fields)
256        };
257        print_json(&keys, fields_ref);
258        return Ok(());
259    }
260
261    if keys.is_empty() {
262        println!("No GPG keys found.");
263        return Ok(());
264    }
265
266    let id_width = 16;
267    let name_width = 30;
268
269    println!("{:<id_width$}  {:<name_width$}  EMAILS", "KEY ID", "NAME");
270
271    for key in &keys {
272        let key_id = key["key_id"].as_str().unwrap_or("—");
273        let name = key["name"].as_str().unwrap_or("—");
274
275        let emails: Vec<String> = key["emails"]
276            .as_array()
277            .map(|arr| {
278                arr.iter()
279                    .filter_map(|e| e["email"].as_str().map(String::from))
280                    .collect()
281            })
282            .unwrap_or_default();
283
284        let name_truncated = crate::cmd::util::truncate(name, name_width);
285        let emails_str = emails.join(", ");
286
287        println!("{key_id:<id_width$}  {name_truncated:<name_width$}  {emails_str}");
288    }
289
290    Ok(())
291}
292
293/// Execute `gor gpg-key add`.
294///
295/// Adds a GPG public key to the authenticated user's account.
296///
297/// # Errors
298///
299/// Returns an error if the key body is missing or the API request fails.
300fn add_gpg_key(
301    file: Option<&str>,
302    body: Option<&str>,
303    hostname: Option<&str>,
304) -> anyhow::Result<()> {
305    let armored_key = if let Some(b) = body {
306        b.to_string()
307    } else if let Some(f) = file {
308        fs::read_to_string(f)
309            .with_context(|| format!("failed to read GPG key file: {f}"))?
310            .trim()
311            .to_string()
312    } else {
313        anyhow::bail!("no GPG key provided (use --file or --body)");
314    };
315
316    if armored_key.is_empty() {
317        anyhow::bail!("GPG key body is empty");
318    }
319
320    let host = hostname.unwrap_or("github.com");
321    let client = Client::new(host).context("failed to create HTTP client")?;
322
323    let body_value = serde_json::json!({
324        "armored_public_key": armored_key,
325    });
326
327    let response = client
328        .post("/user/gpg_keys", &body_value)
329        .context("failed to add GPG key")?;
330
331    let status = response.status();
332    if !status.is_success() {
333        let err_body: serde_json::Value = response.json().unwrap_or_default();
334        let msg = err_body["message"].as_str().unwrap_or("add failed");
335        anyhow::bail!("failed to add GPG key: {msg}");
336    }
337
338    let result: serde_json::Value = response.json().context("failed to parse response")?;
339    let key_id = result["key_id"].as_str().unwrap_or("—");
340    println!("GPG key added: {key_id}");
341    Ok(())
342}
343
344/// Execute `gor gpg-key delete`.
345///
346/// Deletes a GPG public key from the authenticated user's account.
347///
348/// # Errors
349///
350/// Returns an error if the key does not exist or the API request fails.
351fn delete_gpg_key(key_id: &str, yes: bool, hostname: Option<&str>) -> anyhow::Result<()> {
352    if !yes {
353        use std::io::Write;
354        print!("Are you sure you want to delete GPG key '{key_id}'? [y/N] ");
355        std::io::stdout().flush().ok();
356
357        let mut input = String::new();
358        std::io::stdin()
359            .read_line(&mut input)
360            .context("failed to read input")?;
361        let input = input.trim().to_lowercase();
362        if input != "y" && input != "yes" {
363            println!("Cancelled.");
364            return Ok(());
365        }
366    }
367
368    let host = hostname.unwrap_or("github.com");
369    let client = Client::new(host).context("failed to create HTTP client")?;
370
371    let path = format!("/user/gpg_keys/{key_id}");
372    let response = client
373        .request("DELETE", &path, &[], None)
374        .context("failed to delete GPG key")?;
375
376    let status = response.status();
377    if !status.is_success() {
378        let err_body: serde_json::Value = response.json().unwrap_or_default();
379        let msg = err_body["message"].as_str().unwrap_or("delete failed");
380        anyhow::bail!("failed to delete GPG key '{key_id}': {msg}");
381    }
382
383    println!("GPG key '{key_id}' deleted.");
384    Ok(())
385}