use crate::ux::format::Output;
use anyhow::{Context, Result, anyhow};
use auths_core::signing::PassphraseProvider;
use auths_core::storage::keychain::get_platform_keychain;
use auths_id::ports::registry::RegistryBackend;
use auths_id::storage::identity::IdentityStorage;
use auths_id::storage::registry::install_linearity_hook;
use auths_sdk::workflows::provision::{IdentityConfig, NodeConfig, enforce_identity_state};
use auths_storage::git::{GitRegistryBackend, RegistryConfig, RegistryIdentityStorage};
use clap::Parser;
use config::{Config, Environment, File};
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Parser, Debug, Clone)]
#[command(
name = "provision",
about = "Declarative headless provisioning from a TOML config file"
)]
pub struct ProvisionCommand {
#[arg(long, value_parser, help = "Path to the TOML config file")]
pub config: PathBuf,
#[arg(long, help = "Validate and print resolved config without applying")]
pub dry_run: bool,
#[arg(long, help = "Overwrite existing identity")]
pub force: bool,
}
pub fn handle_provision(
cmd: ProvisionCommand,
passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
) -> Result<()> {
let out = Output::new();
let config = load_node_config(&cmd.config)?;
if cmd.dry_run {
return display_resolved_state(&config, &out);
}
out.print_heading("Auths Provision");
out.println("================");
out.newline();
validate_storage_perimeter(&config.identity, &out)?;
out.print_info("Initializing identity...");
let repo_path = Path::new(&config.identity.repo_path);
let registry: Arc<dyn RegistryBackend + Send + Sync> = Arc::new(
GitRegistryBackend::from_config_unchecked(RegistryConfig::single_tenant(repo_path)),
);
let identity_storage: Arc<dyn IdentityStorage + Send + Sync> =
Arc::new(RegistryIdentityStorage::new(repo_path.to_path_buf()));
let keychain =
get_platform_keychain().map_err(|e| anyhow!("Failed to access keychain: {}", e))?;
match enforce_identity_state(
&config,
cmd.force,
passphrase_provider.as_ref(),
keychain.as_ref(),
registry,
identity_storage,
)
.map_err(|e| anyhow::anyhow!("{}", e))?
{
None => {
out.print_success("Identity already exists and matches — no changes needed.");
}
Some(result) => {
out.newline();
out.print_success("Identity provisioned successfully.");
out.println(&format!(
" {}",
out.key_value("Controller DID", &result.controller_did)
));
out.println(&format!(
" {}",
out.key_value("Key alias", &result.key_alias)
));
}
}
install_system_hooks(&config.identity, &out);
print_provision_summary(&config, &out);
Ok(())
}
fn load_node_config(path: &Path) -> Result<NodeConfig> {
let path_str = path
.to_str()
.ok_or_else(|| anyhow!("Config path is not valid UTF-8"))?;
let settings = Config::builder()
.add_source(File::with_name(path_str))
.add_source(Environment::with_prefix("AUTHS").separator("__"))
.build()
.with_context(|| format!("Failed to load config from {:?}", path))?;
settings
.try_deserialize::<NodeConfig>()
.with_context(|| "Failed to deserialize node config")
}
fn display_resolved_state(config: &NodeConfig, out: &Output) -> Result<()> {
out.print_heading("Resolved Configuration (dry-run)");
out.println("=================================");
out.newline();
out.println(&format!(
" {}",
out.key_value("key_alias", &config.identity.key_alias)
));
out.println(&format!(
" {}",
out.key_value("repo_path", &config.identity.repo_path)
));
out.println(&format!(
" {}",
out.key_value("preset", &config.identity.preset)
));
if !config.identity.metadata.is_empty() {
out.newline();
out.println(" Metadata:");
for (k, v) in &config.identity.metadata {
out.println(&format!(" {} = {}", k, v));
}
}
if let Some(ref witness) = config.witness {
out.newline();
out.println(" Witness:");
out.println(&format!(
" {}",
out.key_value("urls", &format!("{:?}", witness.urls))
));
out.println(&format!(
" {}",
out.key_value("threshold", &witness.threshold.to_string())
));
out.println(&format!(
" {}",
out.key_value("timeout_ms", &witness.timeout_ms.to_string())
));
out.println(&format!(" {}", out.key_value("policy", &witness.policy)));
}
out.newline();
out.print_success("Config is valid. No changes applied (dry-run).");
Ok(())
}
fn validate_storage_perimeter(identity: &IdentityConfig, out: &Output) -> Result<()> {
use crate::factories::storage::{ensure_git_repo, open_git_repo};
let repo_path = Path::new(&identity.repo_path);
if repo_path.exists() {
match open_git_repo(repo_path) {
Ok(_) => {
out.println(&format!(
" Repository: {} ({})",
out.info(&identity.repo_path),
out.success("found")
));
}
Err(_) => {
out.print_info("Initializing Git repository...");
ensure_git_repo(repo_path)
.with_context(|| format!("Failed to init Git repository at {:?}", repo_path))?;
out.println(&format!(
" Repository: {} ({})",
out.info(&identity.repo_path),
out.success("initialized")
));
}
}
} else {
out.print_info("Creating directory and Git repository...");
ensure_git_repo(repo_path).with_context(|| {
format!(
"Failed to create and init Git repository at {:?}",
repo_path
)
})?;
out.println(&format!(
" Repository: {} ({})",
out.info(&identity.repo_path),
out.success("created")
));
}
Ok(())
}
fn install_system_hooks(identity: &IdentityConfig, out: &Output) {
let repo_path = Path::new(&identity.repo_path);
if let Err(e) = install_linearity_hook(repo_path) {
out.print_warn(&format!("Could not install linearity hook: {}", e));
}
}
fn print_provision_summary(config: &NodeConfig, out: &Output) {
out.newline();
out.print_heading("Provision Summary");
out.println(&format!(
" {}",
out.key_value("Repository", &config.identity.repo_path)
));
out.println(&format!(
" {}",
out.key_value("Key alias", &config.identity.key_alias)
));
out.println(&format!(
" {}",
out.key_value("Preset", &config.identity.preset)
));
if let Some(ref w) = config.witness {
out.println(&format!(
" {}",
out.key_value("Witnesses", &w.urls.join(", "))
));
out.println(&format!(" {}", out.key_value("Witness policy", &w.policy)));
}
}
impl crate::commands::executable::ExecutableCommand for ProvisionCommand {
fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
handle_provision(self.clone(), ctx.passphrase_provider.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_test_toml(content: &str) -> NamedTempFile {
let mut f = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn test_load_minimal_config() {
let toml = r#"
[identity]
key_alias = "test-key"
repo_path = "/tmp/test-auths"
"#;
let f = write_test_toml(toml);
let config = load_node_config(f.path()).unwrap();
assert_eq!(config.identity.key_alias, "test-key");
assert_eq!(config.identity.repo_path, "/tmp/test-auths");
assert_eq!(config.identity.preset, "default");
assert!(config.witness.is_none());
}
#[test]
fn test_load_full_config() {
let toml = r#"
[identity]
key_alias = "prod-key"
repo_path = "/data/auths"
preset = "radicle"
[identity.metadata]
name = "prod-node-01"
environment = "production"
[witness]
urls = ["https://witness1.example.com", "https://witness2.example.com"]
threshold = 2
timeout_ms = 10000
policy = "enforce"
"#;
let f = write_test_toml(toml);
let config = load_node_config(f.path()).unwrap();
assert_eq!(config.identity.key_alias, "prod-key");
assert_eq!(config.identity.preset, "radicle");
assert_eq!(
config.identity.metadata.get("name").unwrap(),
"prod-node-01"
);
let w = config.witness.unwrap();
assert_eq!(w.urls.len(), 2);
assert_eq!(w.threshold, 2);
assert_eq!(w.timeout_ms, 10000);
assert_eq!(w.policy, "enforce");
}
#[test]
fn test_load_config_with_defaults() {
let toml = r#"
[identity]
"#;
let f = write_test_toml(toml);
let config = load_node_config(f.path()).unwrap();
assert_eq!(config.identity.key_alias, "main");
assert_eq!(config.identity.preset, "default");
}
#[test]
fn test_load_config_missing_file() {
let result = load_node_config(Path::new("/nonexistent/config.toml"));
assert!(result.is_err());
}
#[test]
fn test_provision_command_defaults() {
let cmd = ProvisionCommand {
config: PathBuf::from("test.toml"),
dry_run: false,
force: false,
};
assert!(!cmd.dry_run);
assert!(!cmd.force);
}
#[test]
fn test_witness_policy_parsing() {
let toml = r#"
[identity]
key_alias = "test"
repo_path = "/tmp/test"
[witness]
urls = ["https://w1.example.com"]
threshold = 1
policy = "warn"
"#;
let f = write_test_toml(toml);
let config = load_node_config(f.path()).unwrap();
let w = config.witness.unwrap();
assert_eq!(w.policy, "warn");
}
}