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_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// --- Agent Capability Helpers ---
166
167#[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
223// --- Shell Completion Helpers ---
224
225pub(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}