use anyhow::{Context, Result};
use colored::Colorize;
use crate::cli::TeamAction;
use tsafe_core::{audit::AuditEntry, profile};
use crate::helpers::*;
pub(crate) fn cmd_team(profile: &str, action: TeamAction) -> Result<()> {
use tsafe_core::{age_crypto, team};
let path = profile::vault_path(profile);
match action {
TeamAction::Init { identity } => {
if path.exists() {
anyhow::bail!("vault already exists for profile '{profile}' — delete it first or use a new profile");
}
let identities = age_crypto::load_identities(std::path::Path::new(&identity))?;
let id_content = std::fs::read_to_string(&identity)?;
let recipients: Vec<String> = id_content
.lines()
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
.filter_map(|l| {
l.parse::<age::x25519::Identity>()
.ok()
.map(|i| i.to_public().to_string())
})
.collect();
if recipients.is_empty() {
anyhow::bail!("no valid age identities found in {identity}");
}
let (file, _dek) = team::create_team_vault(&recipients)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&file)?;
let tmp = path.with_extension("vault.tmp");
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &path)?;
audit(profile)
.append(&AuditEntry::success(profile, "team-init", None))
.ok();
println!(
"{} Team vault created for profile '{}'",
"✓".green(),
profile
);
println!(" Recipients: {}", recipients.join(", "));
drop(identities);
Ok(())
}
TeamAction::AddMember {
public_key,
identity,
} => {
let identities = age_crypto::load_identities(std::path::Path::new(&identity))?;
let json = std::fs::read_to_string(&path)
.with_context(|| format!("vault not found: {}", path.display()))?;
let mut file: tsafe_core::vault::VaultFile = serde_json::from_str(&json)?;
team::add_member(&mut file, &public_key, &identities)?;
let new_json = serde_json::to_string_pretty(&file)?;
let tmp = path.with_extension("vault.tmp");
std::fs::write(&tmp, &new_json)?;
std::fs::rename(&tmp, &path)?;
audit(profile)
.append(&AuditEntry::success(profile, "team-add-member", None))
.ok();
println!("{} Added team member: {}", "✓".green(), public_key);
Ok(())
}
TeamAction::RemoveMember {
public_key,
identity,
} => {
let identities = age_crypto::load_identities(std::path::Path::new(&identity))?;
let json = std::fs::read_to_string(&path)
.with_context(|| format!("vault not found: {}", path.display()))?;
let mut file: tsafe_core::vault::VaultFile = serde_json::from_str(&json)?;
team::remove_member(&mut file, &public_key, &identities)?;
let new_json = serde_json::to_string_pretty(&file)?;
let tmp = path.with_extension("vault.tmp");
std::fs::write(&tmp, &new_json)?;
std::fs::rename(&tmp, &path)?;
audit(profile)
.append(&AuditEntry::success(profile, "team-remove-member", None))
.ok();
println!("{} Removed team member: {}", "✓".green(), public_key);
println!(" All secrets re-encrypted with a new data key.");
Ok(())
}
TeamAction::Members => {
let json = std::fs::read_to_string(&path)
.with_context(|| format!("vault not found: {}", path.display()))?;
let file: tsafe_core::vault::VaultFile = serde_json::from_str(&json)?;
if file.age_recipients.is_empty() {
println!("This is not a team vault (no age recipients).");
} else {
println!("Team members ({}):\n", file.age_recipients.len());
for r in &file.age_recipients {
println!(" {r}");
}
}
Ok(())
}
TeamAction::Keygen { name, email } => {
let (secret, pubkey) = age_crypto::generate_identity();
let identity_dir = directories::UserDirs::new()
.map(|d| d.home_dir().join(".age"))
.unwrap_or_else(|| std::path::PathBuf::from(".age"));
std::fs::create_dir_all(&identity_dir)?;
let identity_path = profile::default_age_identity_path(profile);
if identity_path.exists() {
anyhow::bail!(
"identity file already exists: {}\nUse `tsafe team show-key` to view your public key.",
identity_path.display()
);
}
let file_content = format!(
"# created: {}\n# public key: {}\n{}\n",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
pubkey,
secret
);
std::fs::write(&identity_path, &file_content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&identity_path, std::fs::Permissions::from_mode(0o600))?;
}
println!(
"{} Identity created: {}",
"✓".green(),
identity_path.display()
);
println!(" Public key: {}\n", pubkey.cyan());
println!(
"Add this block to {} via a PR:\n",
".tsafe/team-keys.json".cyan()
);
let display_name = name.unwrap_or_else(|| {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "your-name".into())
});
let display_email =
email.unwrap_or_else(|| format!("{display_name}@your-domain.example"));
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"name": display_name,
"email": display_email,
"public_key": pubkey
}))?
);
Ok(())
}
TeamAction::ShowKey { identity } => {
let identity_path = identity
.map(std::path::PathBuf::from)
.unwrap_or_else(|| profile::resolve_age_identity_path(profile));
let content = std::fs::read_to_string(&identity_path)
.with_context(|| format!("identity file not found: {}", identity_path.display()))?;
let from_comment = content
.lines()
.find_map(|l| l.strip_prefix("# public key: ").map(str::to_string));
let pubkey = from_comment.or_else(|| {
content
.lines()
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
.find_map(|l| {
l.parse::<age::x25519::Identity>()
.ok()
.map(|id| id.to_public().to_string())
})
});
match pubkey {
Some(pk) => println!("{pk}"),
None => anyhow::bail!("no valid age identity found in {}", identity_path.display()),
}
Ok(())
}
TeamAction::SyncKeys {
identity,
keys_file,
} => {
let keys_path = keys_file.map(std::path::PathBuf::from).unwrap_or_else(|| {
let mut dir = std::env::current_dir().unwrap_or_default();
loop {
let cur = dir.join(".tsafe").join("team-keys.json");
if cur.exists() {
break cur;
}
if !dir.pop() {
break std::path::PathBuf::from(".tsafe/team-keys.json");
}
}
});
let keys_json = std::fs::read_to_string(&keys_path)
.with_context(|| format!("team keys file not found: {}", keys_path.display()))?;
#[derive(serde::Deserialize)]
struct TeamKeysFile {
members: Vec<TeamKeyEntry>,
}
#[derive(serde::Deserialize)]
struct TeamKeyEntry {
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
email: String,
public_key: String,
}
let keys_file: TeamKeysFile =
serde_json::from_str(&keys_json).context("invalid team-keys.json format")?;
let desired: Vec<String> = keys_file
.members
.iter()
.map(|m| m.public_key.clone())
.collect();
if desired.is_empty() {
anyhow::bail!("team-keys.json has no members — at least one is required");
}
let vault_json = std::fs::read_to_string(&path)
.with_context(|| format!("vault not found: {}", path.display()))?;
let mut file: tsafe_core::vault::VaultFile = serde_json::from_str(&vault_json)?;
let current: std::collections::HashSet<String> =
file.age_recipients.iter().cloned().collect();
let target: std::collections::HashSet<String> = desired.iter().cloned().collect();
let to_add: Vec<String> = target.difference(¤t).cloned().collect();
let to_remove: Vec<String> = current.difference(&target).cloned().collect();
if to_add.is_empty() && to_remove.is_empty() {
println!("{} Team keys already in sync — nothing to do.", "✓".green());
return Ok(());
}
let identities = age_crypto::load_identities(std::path::Path::new(&identity))?;
for pk in &to_add {
team::add_member(&mut file, pk, &identities)?;
println!(" {} Added: {}", "+".green(), pk);
}
for pk in &to_remove {
team::remove_member(&mut file, pk, &identities)?;
println!(" {} Removed: {} (vault re-keyed)", "-".red(), pk);
}
let new_json = serde_json::to_string_pretty(&file)?;
let tmp = path.with_extension("vault.tmp");
std::fs::write(&tmp, &new_json)?;
std::fs::rename(&tmp, &path)?;
audit(profile)
.append(&AuditEntry::success(profile, "team-sync-keys", None))
.ok();
println!(
"\n{} Synced: {} added, {} removed, {} total members",
"✓".green(),
to_add.len(),
to_remove.len(),
file.age_recipients.len()
);
Ok(())
}
}
}