1#![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
13pub 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
40pub 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
61fn 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
131fn 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
184fn 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
227fn 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
293fn 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
344fn 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}