Skip to main content

auths_cli/commands/
init_helpers.rs

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// --- Agent Capability Helpers ---
145
146#[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
202// --- Shell Completion Helpers ---
203
204pub(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}