pub mod github;
pub mod scanner;
pub mod verify;
use async_trait::async_trait;
use dialoguer::Select;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::cli::commands::{SecretsCmd, SecretsSubCommand};
use crate::commands::ssh_helpers::prompt_for_input;
use crate::utils::find_xbp_config_upwards;
#[derive(Debug, Clone)]
pub struct SecretVariable {
pub name: String,
pub value: String,
}
#[async_trait]
pub trait SecretsProvider {
async fn push(&self, secrets: &HashMap<String, String>, force: bool) -> Result<(), String>;
async fn pull(&self) -> Result<Vec<SecretVariable>, String>;
async fn list(&self) -> Result<Vec<SecretVariable>, String>;
}
pub async fn run_secrets(cmd: SecretsCmd, debug: bool) -> Result<(), String> {
let project_root = resolve_project_root()?;
let repo_override = cmd.repo.as_deref();
let outcome = match cmd.command {
Some(SecretsSubCommand::Usage) => {
print_secrets_help()?;
return Ok(());
}
Some(SecretsSubCommand::List(c)) => list_secrets(&project_root, c.file.as_deref(), c.format.as_deref()),
Some(SecretsSubCommand::Push(c)) => {
push_secrets(&project_root, c.file.clone(), c.force, c.dry_run, repo_override).await
}
Some(SecretsSubCommand::Pull(c)) => pull_secrets(&project_root, c.output.clone(), repo_override).await,
Some(SecretsSubCommand::GenerateDefault(c)) => {
scanner::generate_env_default(&project_root, c.output.as_deref())
}
Some(SecretsSubCommand::GenerateExample(c)) => scanner::generate_env_example(
&project_root,
c.output.as_deref(),
c.clean,
&c.include_prefix,
&c.exclude_prefix,
),
Some(SecretsSubCommand::Diff) => diff_secrets(&project_root, repo_override).await,
Some(SecretsSubCommand::Verify) => verify::verify_envs(&project_root).await,
None => list_secrets(&project_root, None, None),
};
outcome.map(|_| {
if debug {
println!("[secrets] Completed secrets command");
}
})
}
async fn push_secrets(
project_root: &Path,
file_override: Option<String>,
force: bool,
dry_run: bool,
repo_override: Option<&str>,
) -> Result<(), String> {
let env_path = resolve_env_file(project_root, file_override)?;
let secrets = parse_env_file(&env_path)?;
if secrets.is_empty() {
println!("No secrets found in {}", env_path.display());
return Ok(());
}
if dry_run {
println!("[dry-run] Would push {} variable(s) from {}:", secrets.len(), env_path.display());
for name in secrets.keys() {
println!(" {}", name);
}
return Ok(());
}
let provider = github::GitHubProvider::new(project_root, repo_override).await?;
provider.push(&secrets, force).await?;
println!(
"Pushed {} variable(s) from {} to GitHub repository variables.",
secrets.len(),
env_path.display()
);
Ok(())
}
async fn pull_secrets(
project_root: &Path,
output: Option<String>,
repo_override: Option<&str>,
) -> Result<(), String> {
let provider = resolve_provider_for_pull(project_root, repo_override).await?;
let variables = provider.pull().await?;
if variables.is_empty() {
println!("No variables found in GitHub repository.");
return Ok(());
}
let output_path = output
.map(PathBuf::from)
.map(|p| {
if p.is_relative() {
project_root.join(p)
} else {
p
}
})
.unwrap_or_else(|| project_root.join(".env.local"));
let mut content = String::new();
let mut variable_names: Vec<&SecretVariable> = variables.iter().collect();
variable_names.sort_by_key(|v| v.name.clone());
for variable in variable_names {
content.push_str(&format!("{}={}\n", variable.name, variable.value));
}
fs::write(&output_path, content)
.map_err(|e| format!("Failed to write {}: {}", output_path.display(), e))?;
println!(
"Pulled {} variable(s) into {}.",
variables.len(),
output_path.display()
);
Ok(())
}
async fn diff_secrets(project_root: &Path, repo_override: Option<&str>) -> Result<(), String> {
let env_path = choose_env_for_list(project_root)?;
let local = parse_env_file(&env_path)?;
let provider = github::GitHubProvider::new(project_root, repo_override).await?;
let remote_vars = provider.list().await?;
let remote: HashMap<_, _> = remote_vars.into_iter().map(|v| (v.name, v.value)).collect();
let local_keys: std::collections::HashSet<_> = local.keys().collect();
let remote_keys: std::collections::HashSet<_> = remote.keys().collect();
let mut only_local: Vec<_> = local_keys.difference(&remote_keys).collect();
let mut only_remote: Vec<_> = remote_keys.difference(&local_keys).collect();
let mut differing: Vec<_> = local_keys
.intersection(&remote_keys)
.filter(|k| local.get(k.as_str()) != remote.get(k.as_str()))
.map(|k| k.as_str())
.collect();
only_local.sort();
only_remote.sort();
differing.sort();
println!("Local: {} (from {})", local.len(), env_path.display());
println!("Remote: {} variables\n", remote.len());
if !only_local.is_empty() {
println!("Only in local (not pushed):");
for k in &only_local {
println!(" + {}", k);
}
println!();
}
if !only_remote.is_empty() {
println!("Only in remote (not in local):");
for k in &only_remote {
println!(" - {}", k);
}
println!();
}
if !differing.is_empty() {
println!("Different values (local vs remote):");
for k in &differing {
println!(" ~ {} (local has value, remote differs)", k);
}
}
if only_local.is_empty() && only_remote.is_empty() && differing.is_empty() {
println!("Local and remote are in sync.");
}
Ok(())
}
async fn resolve_provider_for_pull(
project_root: &Path,
repo_override: Option<&str>,
) -> Result<github::GitHubProvider, String> {
match github::GitHubProvider::new(project_root, repo_override).await {
Ok(provider) => Ok(provider),
Err(error) if repo_override.is_none() && github::needs_repo_setup(&error) => {
println!("GitHub repository could not be detected for this project.");
println!("Starting secrets setup wizard.\n");
let repo = prompt_for_input("Enter GitHub repository (owner/repo): ")?;
let repo = repo.trim();
if repo.is_empty() {
return Err(error);
}
github::GitHubProvider::new(project_root, Some(repo)).await
}
Err(error) => Err(error),
}
}
fn list_secrets(
project_root: &Path,
file_override: Option<&str>,
format_override: Option<&str>,
) -> Result<(), String> {
let env_file = if let Some(f) = file_override {
let p = PathBuf::from(f);
let resolved = if p.is_relative() {
project_root.join(p)
} else {
p
};
if resolved.exists() {
resolved
} else {
return Err(format!("File not found: {}", resolved.display()));
}
} else {
choose_env_for_list(project_root)?
};
let secrets = parse_env_file(&env_file)?;
let format = format_override.unwrap_or("plain");
if format.eq_ignore_ascii_case("json") {
let mut sorted: Vec<_> = secrets.keys().collect();
sorted.sort();
let obj: std::collections::HashMap<_, _> =
sorted.into_iter().map(|k| (k.clone(), secrets[k].clone())).collect();
println!("{}", serde_json::to_string_pretty(&obj).unwrap_or_default());
} else {
println!(
"Found {} variable(s) in {}",
secrets.len(),
env_file.display()
);
let mut names: Vec<_> = secrets.keys().collect();
names.sort();
for name in names {
println!(" {}", name);
}
}
Ok(())
}
fn resolve_env_file(project_root: &Path, override_file: Option<String>) -> Result<PathBuf, String> {
if let Some(value) = override_file {
let candidate = PathBuf::from(value);
let resolved = if candidate.is_relative() {
project_root.join(candidate)
} else {
candidate
};
if resolved.exists() {
return Ok(resolved);
}
return Err(format!(
"Specified env file does not exist: {}",
resolved.display()
));
}
let env_local = project_root.join(".env.local");
let env = project_root.join(".env");
let local_exists = env_local.exists();
let env_exists = env.exists();
match (local_exists, env_exists) {
(true, false) => Ok(env_local),
(false, true) => Ok(env),
(true, true) => {
let options = vec![".env.local", ".env"];
let selection = Select::new()
.with_prompt("Multiple env files detected, choose one to push")
.items(&options)
.default(0)
.interact()
.map_err(|e| format!("Failed to run selection prompt: {}", e))?;
let chosen = if options[selection] == ".env" {
env
} else {
env_local
};
Ok(chosen)
}
_ => Err("No .env.local or .env file found in project root".to_string()),
}
}
fn choose_env_for_list(project_root: &Path) -> Result<PathBuf, String> {
let env_local = project_root.join(".env.local");
if env_local.exists() {
return Ok(env_local);
}
let env = project_root.join(".env");
if env.exists() {
return Ok(env);
}
let env_default = project_root.join(".env.default");
if env_default.exists() {
return Ok(env_default);
}
Err(
"No .env.local, .env, or .env.default found. Run 'xbp secrets generate-default' to create \
.env.default from source, or add .env manually."
.to_string(),
)
}
fn parse_env_file(path: &Path) -> Result<HashMap<String, String>, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
let mut result = HashMap::new();
for line in content.lines() {
let mut trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with("export ") {
trimmed = trimmed.trim_start_matches("export ").trim();
}
if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim().to_string();
let value = value.trim().to_string();
if !key.is_empty() {
result.insert(key, value);
}
}
}
Ok(result)
}
fn print_secrets_help() -> Result<(), String> {
println!("\nXBP Secrets Management");
println!("{}\n", "═".repeat(60));
println!("Usage: xbp secrets [OPTIONS] <COMMAND>");
println!("\nCommands:");
println!(" list List local env vars (--file, --format json)");
println!(" push Push to GitHub (--file, --force, --dry-run)");
println!(" pull Pull from GitHub into .env.local");
println!(" generate-default Generate .env.default from source scan");
println!(" generate-example Generate .env.example (--clean, --include-prefix, --exclude-prefix)");
println!(" diff Compare local vs remote variables");
println!(" verify Verify required env vars are set");
println!(" usage Print this help");
println!("\nOptions:");
println!(" --repo <OWNER/REPO> GitHub repository override");
println!("\nExamples:");
println!(" xbp secrets list --format json");
println!(" xbp secrets push --dry-run");
println!(" xbp secrets diff");
println!(" xbp secrets generate-example --clean --include-prefix DATABASE_");
println!();
Ok(())
}
fn resolve_project_root() -> Result<PathBuf, String> {
let current_dir =
env::current_dir().map_err(|e| format!("Failed to read current dir: {}", e))?;
find_xbp_config_upwards(¤t_dir)
.map(|found| found.project_root)
.ok_or_else(|| {
"Currently not in an XBP project. Run 'xbp init' to create a project config in this directory, or 'xbp' to select an existing project.".to_string()
})
}