1use anyhow::{Context, Result, anyhow};
2use clap_complete::Shell;
3use dialoguer::MultiSelect;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use auths_id::storage::attestation::AttestationSource;
8use auths_storage::git::RegistryAttestationStorage;
9
10use crate::commands::git::{get_principal, public_key_to_ssh};
11use crate::ux::format::Output;
12
13pub(crate) const MIN_GIT_VERSION: (u32, u32, u32) = (2, 34, 0);
14
15pub(crate) fn get_auths_repo_path() -> Result<PathBuf> {
16 auths_core::paths::auths_home().map_err(|e| anyhow!(e))
17}
18
19pub(crate) fn short_did(did: &str) -> String {
20 if did.len() <= 24 {
21 did.to_string()
22 } else {
23 format!("{}...{}", &did[..16], &did[did.len() - 8..])
24 }
25}
26
27pub(crate) fn check_git_version(out: &Output) -> Result<()> {
28 let output = Command::new("git")
29 .arg("--version")
30 .output()
31 .context("Failed to run git --version")?;
32
33 if !output.status.success() {
34 return Err(anyhow!("Git is not installed or not in PATH"));
35 }
36
37 let version_str = String::from_utf8_lossy(&output.stdout);
38 let version = parse_git_version(&version_str)?;
39
40 if version < MIN_GIT_VERSION {
41 return Err(anyhow!(
42 "Git version {}.{}.{} found, but {}.{}.{} or higher is required for SSH signing",
43 version.0,
44 version.1,
45 version.2,
46 MIN_GIT_VERSION.0,
47 MIN_GIT_VERSION.1,
48 MIN_GIT_VERSION.2
49 ));
50 }
51
52 out.println(&format!(
53 " Git: {}.{}.{} (OK)",
54 version.0, version.1, version.2
55 ));
56 Ok(())
57}
58
59pub(crate) fn parse_git_version(version_str: &str) -> Result<(u32, u32, u32)> {
60 let parts: Vec<&str> = version_str.split_whitespace().collect();
61 let version_part = parts
62 .iter()
63 .find(|s| s.chars().next().is_some_and(|c| c.is_ascii_digit()))
64 .ok_or_else(|| anyhow!("Could not parse Git version from: {}", version_str))?;
65
66 let numbers: Vec<u32> = version_part
67 .split('.')
68 .take(3)
69 .filter_map(|s| {
70 s.chars()
71 .take_while(|c| c.is_ascii_digit())
72 .collect::<String>()
73 .parse()
74 .ok()
75 })
76 .collect();
77
78 match numbers.as_slice() {
79 [major, minor, patch, ..] => Ok((*major, *minor, *patch)),
80 [major, minor] => Ok((*major, *minor, 0)),
81 [major] => Ok((*major, 0, 0)),
82 _ => Err(anyhow!("Could not parse Git version: {}", version_str)),
83 }
84}
85
86pub(crate) fn detect_ci_environment() -> Option<String> {
87 if std::env::var("GITHUB_ACTIONS").is_ok() {
88 Some("GitHub Actions".to_string())
89 } else if std::env::var("GITLAB_CI").is_ok() {
90 Some("GitLab CI".to_string())
91 } else if std::env::var("CIRCLECI").is_ok() {
92 Some("CircleCI".to_string())
93 } else if std::env::var("JENKINS_URL").is_ok() {
94 Some("Jenkins".to_string())
95 } else if std::env::var("TRAVIS").is_ok() {
96 Some("Travis CI".to_string())
97 } else if std::env::var("BUILDKITE").is_ok() {
98 Some("Buildkite".to_string())
99 } else if std::env::var("CI").is_ok() {
100 Some("Generic CI".to_string())
101 } else {
102 None
103 }
104}
105
106pub(crate) fn generate_allowed_signers(key_alias: &str, out: &Output) -> Result<()> {
107 let _ = key_alias;
108
109 let repo_path = get_auths_repo_path()?;
110 let storage = RegistryAttestationStorage::new(&repo_path);
111 let attestations = storage.load_all_attestations().unwrap_or_default();
112
113 let mut entries: Vec<String> = Vec::new();
114 for att in attestations {
115 if att.is_revoked() {
116 continue;
117 }
118 let principal = get_principal(&att);
119 let ssh_key = match public_key_to_ssh(att.device_public_key.as_bytes()) {
120 Ok(key) => key,
121 Err(_) => continue,
122 };
123 entries.push(format!("{} namespaces=\"git\" {}", principal, ssh_key));
124 }
125 entries.sort();
126 entries.dedup();
127
128 let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
129 let ssh_dir = home.join(".ssh");
130 std::fs::create_dir_all(&ssh_dir)?;
131 let signers_path = ssh_dir.join("allowed_signers");
132 std::fs::write(&signers_path, entries.join("\n") + "\n")?;
133
134 set_git_config(
135 "gpg.ssh.allowedSignersFile",
136 signers_path.to_str().unwrap(),
137 "--global",
138 )?;
139
140 out.println(&format!(
141 " Wrote {} allowed signer(s) to {}",
142 entries.len(),
143 signers_path.display()
144 ));
145 out.println(&format!(
146 " Set gpg.ssh.allowedSignersFile = {}",
147 signers_path.display()
148 ));
149
150 Ok(())
151}
152
153fn set_git_config(key: &str, value: &str, scope: &str) -> Result<()> {
154 let status = Command::new("git")
155 .args(["config", scope, key, value])
156 .status()
157 .with_context(|| format!("Failed to run git config {scope} {key} {value}"))?;
158
159 if !status.success() {
160 return Err(anyhow!("Failed to set git config {key} = {value}"));
161 }
162 Ok(())
163}
164
165#[derive(Debug, Clone)]
168pub(crate) struct AgentCapability {
169 pub name: String,
170 pub description: String,
171}
172
173impl AgentCapability {
174 pub fn new(name: &str, description: &str) -> Self {
175 Self {
176 name: name.to_string(),
177 description: description.to_string(),
178 }
179 }
180}
181
182pub(crate) fn get_available_capabilities() -> Vec<AgentCapability> {
183 vec![
184 AgentCapability::new("sign_commit", "Sign Git commits"),
185 AgentCapability::new("sign_release", "Sign releases and tags"),
186 AgentCapability::new("manage_members", "Manage organization members"),
187 AgentCapability::new("rotate_keys", "Rotate identity keys"),
188 ]
189}
190
191pub(crate) fn select_agent_capabilities(
192 interactive: bool,
193 out: &Output,
194) -> Result<Vec<AgentCapability>> {
195 let available = get_available_capabilities();
196
197 if !interactive {
198 out.println(" Using default capability: sign_commit");
199 return Ok(vec![available[0].clone()]);
200 }
201
202 let items: Vec<String> = available
203 .iter()
204 .map(|c| format!("{} - {}", c.name, c.description))
205 .collect();
206
207 let defaults = vec![true, false, false, false];
208
209 let selections = MultiSelect::new()
210 .with_prompt("Select capabilities for this agent (space to toggle, enter to confirm)")
211 .items(&items)
212 .defaults(&defaults)
213 .interact()?;
214
215 if selections.is_empty() {
216 out.print_warn("No capabilities selected, defaulting to sign_commit");
217 return Ok(vec![available[0].clone()]);
218 }
219
220 Ok(selections.iter().map(|&i| available[i].clone()).collect())
221}
222
223pub(crate) fn detect_shell() -> Option<Shell> {
226 std::env::var("SHELL").ok().and_then(|shell_path| {
227 if shell_path.contains("zsh") {
228 Some(Shell::Zsh)
229 } else if shell_path.contains("bash") {
230 Some(Shell::Bash)
231 } else if shell_path.contains("fish") {
232 Some(Shell::Fish)
233 } else {
234 None
235 }
236 })
237}
238
239pub(crate) fn get_completion_path(shell: Shell) -> Option<PathBuf> {
240 let home = dirs::home_dir()?;
241
242 match shell {
243 Shell::Zsh => {
244 let omz_path = home.join(".oh-my-zsh/completions");
245 if omz_path.exists() {
246 return Some(omz_path.join("_auths"));
247 }
248 Some(home.join(".zfunc/_auths"))
249 }
250 Shell::Bash => dirs::data_local_dir().map(|d| d.join("bash-completion/completions/auths")),
251 Shell::Fish => dirs::config_dir().map(|d| d.join("fish/completions/auths.fish")),
252 _ => None,
253 }
254}
255
256pub(crate) fn offer_shell_completions(interactive: bool, out: &Output) -> Result<()> {
257 let shell = match detect_shell() {
258 Some(s) => s,
259 None => return Ok(()),
260 };
261
262 let path = match get_completion_path(shell) {
263 Some(p) => p,
264 None => return Ok(()),
265 };
266
267 if path.exists() {
268 return Ok(());
269 }
270
271 if !interactive {
272 if path.parent().is_some_and(|p| p.exists()) {
273 if let Err(e) = install_shell_completions(shell, &path) {
274 out.print_warn(&format!("Could not install completions: {}", e));
275 } else {
276 out.print_success(&format!("Installed {} completions", shell));
277 }
278 }
279 return Ok(());
280 }
281
282 out.newline();
283 let install = dialoguer::Confirm::new()
284 .with_prompt(format!(
285 "Install {} completions to {}?",
286 shell,
287 path.display()
288 ))
289 .default(true)
290 .interact()?;
291
292 if install {
293 match install_shell_completions(shell, &path) {
294 Ok(()) => {
295 out.print_success(&format!("Installed {} completions", shell));
296 out.println(&format!(
297 " Restart your shell or run: source {}",
298 path.display()
299 ));
300 }
301 Err(e) => {
302 out.print_warn(&format!("Could not install completions: {}", e));
303 }
304 }
305 }
306
307 Ok(())
308}
309
310fn install_shell_completions(shell: Shell, path: &Path) -> Result<()> {
311 if let Some(parent) = path.parent() {
312 std::fs::create_dir_all(parent)
313 .with_context(|| format!("Failed to create directory: {:?}", parent))?;
314 }
315
316 let shell_name = match shell {
317 Shell::Bash => "bash",
318 Shell::Zsh => "zsh",
319 Shell::Fish => "fish",
320 _ => return Err(anyhow!("Unsupported shell: {:?}", shell)),
321 };
322
323 let output = Command::new("auths")
324 .args(["completions", shell_name])
325 .output()
326 .context("Failed to run auths completions")?;
327
328 if !output.status.success() {
329 return Err(anyhow!(
330 "auths completions failed: {}",
331 String::from_utf8_lossy(&output.stderr)
332 ));
333 }
334
335 std::fs::write(path, &output.stdout)
336 .with_context(|| format!("Failed to write completions to {:?}", path))?;
337
338 Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn test_parse_git_version() {
347 assert_eq!(parse_git_version("git version 2.39.0").unwrap(), (2, 39, 0));
348 assert_eq!(parse_git_version("git version 2.34.1").unwrap(), (2, 34, 1));
349 assert_eq!(
350 parse_git_version("git version 2.39.0.windows.1").unwrap(),
351 (2, 39, 0)
352 );
353 assert_eq!(parse_git_version("git version 2.30").unwrap(), (2, 30, 0));
354 }
355
356 #[test]
357 fn test_short_did() {
358 assert_eq!(short_did("did:key:z123"), "did:key:z123");
359 assert_eq!(
360 short_did("did:keri:EAbcdefghijklmnopqrstuvwxyz123456789"),
361 "did:keri:EAbcdef...23456789"
362 );
363 }
364
365 #[test]
366 fn test_min_git_version() {
367 assert!(MIN_GIT_VERSION <= (2, 34, 0));
368 assert!(MIN_GIT_VERSION <= (2, 39, 0));
369 assert!(MIN_GIT_VERSION > (2, 33, 0));
370 }
371
372 #[test]
373 fn test_detect_ci_environment_none() {
374 let result = detect_ci_environment();
375 let _ = result;
376 }
377
378 #[test]
379 fn test_get_available_capabilities() {
380 let caps = get_available_capabilities();
381 assert_eq!(caps.len(), 4);
382 assert_eq!(caps[0].name, "sign_commit");
383 assert_eq!(caps[1].name, "sign_release");
384 assert_eq!(caps[2].name, "manage_members");
385 assert_eq!(caps[3].name, "rotate_keys");
386 }
387
388 #[test]
389 fn test_agent_capability() {
390 let cap = AgentCapability::new("test_cap", "Test capability");
391 assert_eq!(cap.name, "test_cap");
392 assert_eq!(cap.description, "Test capability");
393 }
394
395 #[test]
396 fn test_detect_shell() {
397 let _ = detect_shell();
398 }
399
400 #[test]
401 fn test_get_completion_path_zsh() {
402 let path = get_completion_path(Shell::Zsh);
403 assert!(path.is_some());
404 let p = path.unwrap();
405 assert!(p.ends_with("_auths"));
406 }
407
408 #[test]
409 fn test_get_completion_path_bash() {
410 let path = get_completion_path(Shell::Bash);
411 assert!(path.is_some());
412 let p = path.unwrap();
413 assert!(p.ends_with("auths"));
414 }
415
416 #[test]
417 fn test_get_completion_path_fish() {
418 let path = get_completion_path(Shell::Fish);
419 assert!(path.is_some());
420 let p = path.unwrap();
421 assert!(p.ends_with("auths.fish"));
422 }
423}