use anyhow::{Context, Result, anyhow};
use clap_complete::Shell;
use dialoguer::MultiSelect;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use auths_sdk::workflows::diagnostics::{MIN_GIT_VERSION, parse_git_version};
use crate::subprocess::git_command;
use crate::ux::format::Output;
pub(crate) fn get_auths_repo_path() -> Result<PathBuf> {
auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))
}
pub(crate) fn check_git_version(out: &Output) -> Result<()> {
let output = git_command(&["--version"])
.output()
.context("Failed to run git --version")?;
if !output.status.success() {
return Err(anyhow!("Git is not installed or not in PATH"));
}
let version_str = String::from_utf8_lossy(&output.stdout);
let version = cli_parse_git_version(&version_str)?;
if version < MIN_GIT_VERSION {
return Err(anyhow!(
"Git version {}.{}.{} found, but {}.{}.{} or higher is required for SSH signing",
version.0,
version.1,
version.2,
MIN_GIT_VERSION.0,
MIN_GIT_VERSION.1,
MIN_GIT_VERSION.2
));
}
out.println(&format!(
" Git: {}.{}.{} (OK)",
version.0, version.1, version.2
));
Ok(())
}
pub(crate) fn cli_parse_git_version(version_str: &str) -> Result<(u32, u32, u32)> {
parse_git_version(version_str)
.ok_or_else(|| anyhow!("Could not parse Git version from: {}", version_str))
}
#[allow(clippy::disallowed_methods)] pub(crate) fn detect_ci_environment() -> Option<String> {
if std::env::var("GITHUB_ACTIONS").is_ok() {
Some("GitHub Actions".to_string())
} else if std::env::var("GITLAB_CI").is_ok() {
Some("GitLab CI".to_string())
} else if std::env::var("CIRCLECI").is_ok() {
Some("CircleCI".to_string())
} else if std::env::var("JENKINS_URL").is_ok() {
Some("Jenkins".to_string())
} else if std::env::var("TRAVIS").is_ok() {
Some("Travis CI".to_string())
} else if std::env::var("BUILDKITE").is_ok() {
Some("Buildkite".to_string())
} else if std::env::var("CI").is_ok() {
Some("Generic CI".to_string())
} else {
None
}
}
const GITHUB_ACTION_WORKFLOW_TEMPLATE: &str = r#"# Auths release workflow — verifies commits and signs artifacts ephemerally.
# Generated by: auths init --github-action
#
# No secrets needed for signing. Trust derives from commit signatures.
name: Auths Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: auths-dev/verify@v1
release:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build artifacts
run: |
# Replace this with your build step
echo "Build your artifacts here"
- name: Sign artifacts (ephemeral)
run: |
auths artifact sign dist/*.tar.gz --ci --commit ${{ github.sha }}
"#;
pub(crate) fn scaffold_github_action(out: &Output) -> Result<()> {
out.print_heading("GitHub Action Scaffolding");
out.newline();
let git_root = git_command(&["rev-parse", "--show-toplevel"])
.output()
.context("Failed to run git rev-parse")?;
if !git_root.status.success() {
return Err(anyhow!(
"Not inside a git repository. Run this from a git repo root."
));
}
let root = PathBuf::from(String::from_utf8_lossy(&git_root.stdout).trim());
let remote_output = git_command(&["remote", "get-url", "origin"]).output();
match remote_output {
Ok(ref output) if output.status.success() => {
let url = String::from_utf8_lossy(&output.stdout);
if !url.contains("github.com") {
out.print_warn(
"Origin remote does not appear to be a GitHub repository — workflow may not work as expected",
);
}
}
_ => {
out.print_warn(
"No 'origin' remote found — you may need to add one before the workflow can push",
);
}
}
let workflows_dir = root.join(".github/workflows");
std::fs::create_dir_all(&workflows_dir)
.with_context(|| format!("Failed to create {}", workflows_dir.display()))?;
let workflow_path = workflows_dir.join("auths-release.yml");
if workflow_path.exists() {
out.print_warn(&format!(
"{} already exists — skipping (delete it first to regenerate)",
workflow_path.display()
));
} else {
std::fs::write(&workflow_path, GITHUB_ACTION_WORKFLOW_TEMPLATE)
.with_context(|| format!("Failed to write {}", workflow_path.display()))?;
out.print_success(&format!("Created {}", workflow_path.display()));
}
let auths_dir = root.join(".auths");
std::fs::create_dir_all(&auths_dir)
.with_context(|| format!("Failed to create {}", auths_dir.display()))?;
let gitkeep_path = auths_dir.join(".gitkeep");
if !gitkeep_path.exists() {
std::fs::write(&gitkeep_path, "")
.with_context(|| format!("Failed to write {}", gitkeep_path.display()))?;
out.print_success(&format!("Created {}", gitkeep_path.display()));
}
out.newline();
out.print_heading("Next steps");
out.println(" 1. Set up CI secrets: just ci-setup");
out.println(" 2. Add the generated secrets to GitHub repository settings");
out.println(" 3. Customize the workflow's build step and artifact glob pattern");
out.println(" 4. Commit and push: git add .github/workflows/auths-release.yml .auths/");
out.newline();
Ok(())
}
#[derive(Debug, Clone)]
pub(crate) struct AgentCapability {
pub name: String,
pub description: String,
}
impl AgentCapability {
pub fn new(name: &str, description: &str) -> Self {
Self {
name: name.to_string(),
description: description.to_string(),
}
}
}
pub(crate) fn get_available_capabilities() -> Vec<AgentCapability> {
vec![
AgentCapability::new("sign_commit", "Sign Git commits"),
AgentCapability::new("sign_release", "Sign releases and tags"),
AgentCapability::new("manage_members", "Manage organization members"),
AgentCapability::new("rotate_keys", "Rotate identity keys"),
]
}
pub(crate) fn select_agent_capabilities(
interactive: bool,
out: &Output,
) -> Result<Vec<AgentCapability>> {
let available = get_available_capabilities();
if !interactive {
out.println(" Using default capability: sign_commit");
return Ok(vec![available[0].clone()]);
}
let items: Vec<String> = available
.iter()
.map(|c| format!("{} - {}", c.name, c.description))
.collect();
let defaults = vec![true, false, false, false];
let selections = MultiSelect::new()
.with_prompt("Select capabilities for this agent (space to toggle, enter to confirm)")
.items(&items)
.defaults(&defaults)
.interact()?;
if selections.is_empty() {
out.print_warn("No capabilities selected, defaulting to sign_commit");
return Ok(vec![available[0].clone()]);
}
Ok(selections.iter().map(|&i| available[i].clone()).collect())
}
#[allow(clippy::disallowed_methods)] pub(crate) fn detect_shell() -> Option<Shell> {
std::env::var("SHELL").ok().and_then(|shell_path| {
if shell_path.contains("zsh") {
Some(Shell::Zsh)
} else if shell_path.contains("bash") {
Some(Shell::Bash)
} else if shell_path.contains("fish") {
Some(Shell::Fish)
} else {
None
}
})
}
pub(crate) fn get_completion_path(shell: Shell) -> Option<PathBuf> {
let home = dirs::home_dir()?;
match shell {
Shell::Zsh => {
let omz_path = home.join(".oh-my-zsh/completions");
if omz_path.exists() {
return Some(omz_path.join("_auths"));
}
Some(home.join(".zfunc/_auths"))
}
Shell::Bash => dirs::data_local_dir().map(|d| d.join("bash-completion/completions/auths")),
Shell::Fish => dirs::config_dir().map(|d| d.join("fish/completions/auths.fish")),
_ => None,
}
}
pub(crate) fn offer_shell_completions(interactive: bool, out: &Output) -> Result<()> {
let shell = match detect_shell() {
Some(s) => s,
None => return Ok(()),
};
let path = match get_completion_path(shell) {
Some(p) => p,
None => return Ok(()),
};
if path.exists() {
return Ok(());
}
if !interactive {
if path.parent().is_some_and(|p| p.exists()) {
match install_shell_completions(shell, &path) {
Ok(zshrc_modified) => {
out.print_success(&format!("Installed {} completions", shell));
if zshrc_modified {
out.println(" Updated ~/.zshrc with fpath configuration");
}
out.println(&shell_reload_hint(shell, &path));
}
Err(e) => {
out.print_warn(&format!("Could not install completions: {}", e));
}
}
}
return Ok(());
}
out.newline();
let install = dialoguer::Confirm::new()
.with_prompt(format!(
"Install {} completions to {}?",
shell,
path.display()
))
.default(true)
.interact()?;
if install {
match install_shell_completions(shell, &path) {
Ok(zshrc_modified) => {
out.print_success(&format!("Installed {} completions", shell));
if zshrc_modified {
out.println(" Updated ~/.zshrc with fpath configuration");
}
out.println(&shell_reload_hint(shell, &path));
}
Err(e) => {
out.print_warn(&format!("Could not install completions: {}", e));
}
}
}
Ok(())
}
fn shell_reload_hint(shell: Shell, path: &Path) -> String {
match shell {
Shell::Zsh => " Restart your shell or run: autoload -Uz compinit && compinit".to_string(),
_ => format!(" Restart your shell or run: source {}", path.display()),
}
}
fn ensure_zfunc_in_fpath(completion_path: &Path, home: &Path) -> Result<bool> {
let is_zfunc = completion_path
.parent()
.and_then(|p| p.file_name())
.is_some_and(|name| name == ".zfunc");
if !is_zfunc {
return Ok(false);
}
let zshrc = home.join(".zshrc");
let contents = std::fs::read_to_string(&zshrc).unwrap_or_default();
let already_configured = contents
.lines()
.any(|line| line.contains("fpath") && line.contains(".zfunc"));
if already_configured {
return Ok(false);
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&zshrc)
.with_context(|| format!("Failed to open {}", zshrc.display()))?;
file.write_all(
b"\n# Added by auths init\nfpath+=~/.zfunc\nautoload -Uz compinit && compinit\n",
)
.with_context(|| format!("Failed to write to {}", zshrc.display()))?;
Ok(true)
}
fn install_shell_completions(shell: Shell, path: &Path) -> Result<bool> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {:?}", parent))?;
}
let shell_name = match shell {
Shell::Bash => "bash",
Shell::Zsh => "zsh",
Shell::Fish => "fish",
_ => return Err(anyhow!("Unsupported shell: {:?}", shell)),
};
let output = Command::new("auths")
.args(["completions", shell_name])
.output()
.context("Failed to run auths completions")?;
if !output.status.success() {
return Err(anyhow!(
"auths completions failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
std::fs::write(path, &output.stdout)
.with_context(|| format!("Failed to write completions to {:?}", path))?;
if shell == Shell::Zsh
&& let Some(home) = dirs::home_dir()
{
return ensure_zfunc_in_fpath(path, &home);
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_git_version() {
assert_eq!(parse_git_version("git version 2.39.0"), Some((2, 39, 0)));
assert_eq!(parse_git_version("git version 2.34.1"), Some((2, 34, 1)));
assert_eq!(
parse_git_version("git version 2.39.0.windows.1"),
Some((2, 39, 0))
);
assert_eq!(parse_git_version("git version 2.30"), Some((2, 30, 0)));
}
#[test]
fn test_min_git_version() {
assert!(MIN_GIT_VERSION <= (2, 34, 0));
assert!(MIN_GIT_VERSION <= (2, 39, 0));
assert!(MIN_GIT_VERSION > (2, 33, 0));
}
#[test]
fn test_detect_ci_environment_none() {
let result = detect_ci_environment();
let _ = result;
}
#[test]
fn test_get_available_capabilities() {
let caps = get_available_capabilities();
assert_eq!(caps.len(), 4);
assert_eq!(caps[0].name, "sign_commit");
assert_eq!(caps[1].name, "sign_release");
assert_eq!(caps[2].name, "manage_members");
assert_eq!(caps[3].name, "rotate_keys");
}
#[test]
fn test_agent_capability() {
let cap = AgentCapability::new("test_cap", "Test capability");
assert_eq!(cap.name, "test_cap");
assert_eq!(cap.description, "Test capability");
}
#[test]
fn test_detect_shell() {
let _ = detect_shell();
}
#[test]
fn test_get_completion_path_zsh() {
let path = get_completion_path(Shell::Zsh);
assert!(path.is_some());
let p = path.unwrap();
assert!(p.ends_with("_auths"));
}
#[test]
fn test_get_completion_path_bash() {
let path = get_completion_path(Shell::Bash);
assert!(path.is_some());
let p = path.unwrap();
assert!(p.ends_with("auths"));
}
#[test]
fn test_get_completion_path_fish() {
let path = get_completion_path(Shell::Fish);
assert!(path.is_some());
let p = path.unwrap();
assert!(p.ends_with("auths.fish"));
}
#[test]
fn test_ensure_zfunc_in_fpath_adds_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
std::fs::write(home.join(".zshrc"), "# existing config\n").unwrap();
let completion_path = home.join(".zfunc/_auths");
let modified = ensure_zfunc_in_fpath(&completion_path, home).unwrap();
assert!(modified);
let contents = std::fs::read_to_string(home.join(".zshrc")).unwrap();
assert!(contents.contains("fpath+=~/.zfunc"));
assert!(contents.contains("compinit"));
}
#[test]
fn test_ensure_zfunc_in_fpath_skips_when_present() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
std::fs::write(home.join(".zshrc"), "fpath+=~/.zfunc\n").unwrap();
let completion_path = home.join(".zfunc/_auths");
let modified = ensure_zfunc_in_fpath(&completion_path, home).unwrap();
assert!(!modified);
}
#[test]
fn test_ensure_zfunc_in_fpath_skips_non_zfunc_path() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
std::fs::write(home.join(".zshrc"), "").unwrap();
let completion_path = home.join(".oh-my-zsh/completions/_auths");
let modified = ensure_zfunc_in_fpath(&completion_path, home).unwrap();
assert!(!modified);
}
#[test]
fn test_ensure_zfunc_in_fpath_creates_zshrc_if_missing() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();
let completion_path = home.join(".zfunc/_auths");
let modified = ensure_zfunc_in_fpath(&completion_path, home).unwrap();
assert!(modified);
assert!(home.join(".zshrc").exists());
let contents = std::fs::read_to_string(home.join(".zshrc")).unwrap();
assert!(contents.contains("fpath+=~/.zfunc"));
}
#[test]
fn test_shell_reload_hint_zsh_uses_compinit() {
let hint = shell_reload_hint(Shell::Zsh, Path::new("~/.zfunc/_auths"));
assert!(hint.contains("compinit"));
assert!(!hint.contains("source"));
}
#[test]
fn test_shell_reload_hint_bash_uses_source() {
let path = Path::new("/tmp/completions/auths");
let hint = shell_reload_hint(Shell::Bash, path);
assert!(hint.contains("source"));
assert!(hint.contains("/tmp/completions/auths"));
}
}