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