pub mod github;
pub mod scanner;
pub mod verify;
use dialoguer::Select;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use crate::cli::commands::{
CloudflareSecretsBulkDeleteCmd, CloudflareSecretsCmd, CloudflareSecretsCreateCmd,
CloudflareSecretsDeleteCmd, CloudflareSecretsDuplicateCmd, CloudflareSecretsEditCmd,
CloudflareSecretsGetCmd, CloudflareSecretsListCmd, CloudflareSecretsStoreCreateCmd,
CloudflareSecretsStoreDeleteCmd, CloudflareSecretsStoreGetCmd, GenerateExampleCmd, PullCmd,
PushCmd, SecretsCmd, SecretsProviderKind, SecretsQuotaCmd, SecretsQuotaSubCommand,
SecretsStoresCmd, SecretsStoresSubCommand, SecretsSubCommand,
};
use crate::cli::ui::Loader;
use crate::commands::ssh_helpers::prompt_for_input;
use crate::config::{resolve_cloudflare_account_id, resolve_cloudflare_api_token};
use crate::provider_support::cloudflare::{
CloudflareBulkDeleteRequest, CloudflareClient, CloudflareSecretCreateRequest,
CloudflareSecretDuplicateRequest, CloudflareSecretEditRequest, CloudflareStoreCreateRequest,
};
use crate::provider_support::{secrets_providers, GitHubEnvironmentClient};
use crate::utils::{find_xbp_config_upwards, parse_env_file as parse_shared_env_file};
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 token_override = cmd.token.as_deref();
let account_id_override = cmd.account_id.as_deref();
let environment = cmd.environment.as_str();
let outcome = match cmd.command {
Some(SecretsSubCommand::Providers) => {
for provider in secrets_providers() {
println!(
"{}\t{}\t{}",
provider.key,
provider.capabilities.join(","),
provider.notes.unwrap_or_default()
);
}
Ok(())
}
Some(SecretsSubCommand::Usage) => {
print_secrets_help()?;
Ok(())
}
Some(SecretsSubCommand::List(args)) => match cmd.provider {
SecretsProviderKind::Github => {
list_secrets(&project_root, args.file.as_deref(), args.format.as_deref())
}
SecretsProviderKind::Cloudflare => unsupported_sync_capability("list"),
},
Some(SecretsSubCommand::Push(args)) => match cmd.provider {
SecretsProviderKind::Github => {
push_secrets(
&project_root,
args,
environment,
repo_override,
token_override,
)
.await
}
SecretsProviderKind::Cloudflare => unsupported_sync_capability("push"),
},
Some(SecretsSubCommand::Pull(args)) => match cmd.provider {
SecretsProviderKind::Github => {
pull_secrets(
&project_root,
args,
environment,
repo_override,
token_override,
)
.await
}
SecretsProviderKind::Cloudflare => unsupported_sync_capability("pull"),
},
Some(SecretsSubCommand::GenerateDefault(args)) => {
scanner::generate_env_default(&project_root, args.output.as_deref())
}
Some(SecretsSubCommand::GenerateExample(GenerateExampleCmd {
output,
clean,
include_prefix,
exclude_prefix,
})) => scanner::generate_env_example(
&project_root,
output.as_deref(),
clean,
&include_prefix,
&exclude_prefix,
),
Some(SecretsSubCommand::Diff) => match cmd.provider {
SecretsProviderKind::Github => {
diff_secrets(&project_root, environment, repo_override, token_override).await
}
SecretsProviderKind::Cloudflare => unsupported_sync_capability("diff"),
},
Some(SecretsSubCommand::Verify) => match cmd.provider {
SecretsProviderKind::Github => verify::verify_envs(&project_root).await,
SecretsProviderKind::Cloudflare => unsupported_sync_capability("verify"),
},
Some(SecretsSubCommand::Diag) => match cmd.provider {
SecretsProviderKind::Github => {
diag_github(&project_root, environment, repo_override, token_override).await
}
SecretsProviderKind::Cloudflare => {
diag_cloudflare(token_override, account_id_override).await
}
},
Some(SecretsSubCommand::Stores(stores)) => {
run_cloudflare_stores(stores, token_override, account_id_override).await
}
Some(SecretsSubCommand::Secrets(secrets)) => {
run_cloudflare_secrets(secrets, token_override, account_id_override).await
}
Some(SecretsSubCommand::Quota(quota)) => {
run_cloudflare_quota(quota, token_override, account_id_override).await
}
None => list_secrets(&project_root, None, None),
};
outcome.map(|_| {
if debug {
println!("[secrets] Completed secrets command");
}
})
}
async fn push_secrets(
project_root: &Path,
args: PushCmd,
environment: &str,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<(), String> {
let env_path = resolve_env_file(project_root, args.file)?;
let loader = Loader::start("Pushing GitHub environment variables");
loader.update("[1/5] Reading local variables");
let secrets = parse_env_file(&env_path)?;
if secrets.is_empty() {
loader.success_with(&format!("No secrets found in {}", env_path.display()));
return Ok(());
}
if args.dry_run {
loader.success_with("Dry run complete");
println!(
"[dry-run] Would push {} variable(s) from {} to GitHub Actions environment `{}`:",
secrets.len(),
env_path.display(),
environment
);
for name in secrets.keys() {
println!(" {}", name);
}
return Ok(());
}
loader.update("[2/5] Resolving GitHub repository and token");
let client =
build_github_client(project_root, environment, repo_override, token_override).await?;
let mut progress = |message: &str| loader.update(message);
client.upsert(&secrets, &mut progress).await?;
loader.success_with(&format!(
"Pushed {} variable(s) from {} to GitHub Actions environment `{}`",
secrets.len(),
env_path.display(),
environment
));
Ok(())
}
async fn pull_secrets(
project_root: &Path,
args: PullCmd,
environment: &str,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<(), String> {
let loader = Loader::start("Pulling GitHub environment variables");
loader.update("[1/4] Resolving GitHub repository and token");
let client =
resolve_github_client_with_setup(project_root, environment, repo_override, token_override)
.await?;
loader.update("[2/4] Fetching GitHub environment variables");
let mut progress = |message: &str| loader.update(message);
let variables = client.list_with_progress(&mut progress).await?;
if variables.is_empty() {
loader.success_with(&format!(
"No variables found in GitHub Actions environment `{}`",
environment
));
return Ok(());
}
let output_path = args
.output
.map(PathBuf::from)
.map(|path| {
if path.is_relative() {
project_root.join(path)
} else {
path
}
})
.unwrap_or_else(|| project_root.join(".env.local"));
let mut names = variables;
names.sort_by(|left, right| left.name.cmp(&right.name));
let mut content = String::new();
for variable in names {
content.push_str(&format!("{}={}\n", variable.name, variable.value));
}
loader.update("[4/4] Writing local env file");
fs::write(&output_path, content)
.map_err(|error| format!("Failed to write {}: {}", output_path.display(), error))?;
loader.success_with(&format!(
"Pulled variables from GitHub Actions environment `{}` into {}",
environment,
output_path.display()
));
Ok(())
}
async fn diff_secrets(
project_root: &Path,
environment: &str,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<(), String> {
let env_path = choose_env_for_list(project_root)?;
let local = parse_env_file(&env_path)?;
let client =
build_github_client(project_root, environment, repo_override, token_override).await?;
let remote = client
.list()
.await?
.into_iter()
.map(|variable| (variable.name, variable.value))
.collect::<HashMap<_, _>>();
let local_keys = local
.keys()
.cloned()
.collect::<std::collections::HashSet<_>>();
let remote_keys = remote
.keys()
.cloned()
.collect::<std::collections::HashSet<_>>();
let mut only_local = local_keys
.difference(&remote_keys)
.cloned()
.collect::<Vec<_>>();
let mut only_remote = remote_keys
.difference(&local_keys)
.cloned()
.collect::<Vec<_>>();
let mut differing = local_keys
.intersection(&remote_keys)
.filter(|key| local.get(*key) != remote.get(*key))
.cloned()
.collect::<Vec<_>>();
only_local.sort();
only_remote.sort();
differing.sort();
println!("Local: {} (from {})", local.len(), env_path.display());
println!(
"Remote: {} variable(s) in GitHub Actions environment `{}`\n",
remote.len(),
environment
);
if !only_local.is_empty() {
println!("Only in local:");
for key in &only_local {
println!(" + {}", key);
}
println!();
}
if !only_remote.is_empty() {
println!("Only in remote:");
for key in &only_remote {
println!(" - {}", key);
}
println!();
}
if !differing.is_empty() {
println!("Different values:");
for key in &differing {
println!(" ~ {}", key);
}
}
if only_local.is_empty() && only_remote.is_empty() && differing.is_empty() {
println!("Local and remote are in sync.");
}
Ok(())
}
async fn diag_github(
project_root: &Path,
environment: &str,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<(), String> {
let client =
build_github_client(project_root, environment, repo_override, token_override).await?;
let response = client.diag().await?;
println!("{}", response.message);
Ok(())
}
async fn diag_cloudflare(
token_override: Option<&str>,
account_id_override: Option<&str>,
) -> Result<(), String> {
let client = build_cloudflare_client(token_override, account_id_override)?;
let quota = client.get_quota().await?;
println!(
"Cloudflare access ok. Secret quota: {} used / {} total.",
quota.secrets.usage, quota.secrets.quota
);
Ok(())
}
async fn run_cloudflare_stores(
cmd: SecretsStoresCmd,
token_override: Option<&str>,
account_id_override: Option<&str>,
) -> Result<(), String> {
let client = build_cloudflare_client(token_override, account_id_override)?;
match cmd.command {
SecretsStoresSubCommand::List(_) => {
let (stores, result_info) = client.list_stores().await?;
print_json(&serde_json::json!({ "stores": stores, "result_info": result_info }))
}
SecretsStoresSubCommand::Get(CloudflareSecretsStoreGetCmd { store_id }) => {
print_json(&client.get_store(&store_id).await?)
}
SecretsStoresSubCommand::Create(CloudflareSecretsStoreCreateCmd { name }) => print_json(
&client
.create_store(&CloudflareStoreCreateRequest { name })
.await?,
),
SecretsStoresSubCommand::Delete(CloudflareSecretsStoreDeleteCmd { store_id }) => {
client.delete_store(&store_id).await?;
println!("Deleted store {}", store_id);
Ok(())
}
}
}
async fn run_cloudflare_secrets(
cmd: CloudflareSecretsCmd,
token_override: Option<&str>,
account_id_override: Option<&str>,
) -> Result<(), String> {
let client = build_cloudflare_client(token_override, account_id_override)?;
match cmd.command {
crate::cli::commands::CloudflareSecretsSubCommand::List(CloudflareSecretsListCmd {
store_id,
}) => {
let (secrets, result_info) = client.list_secrets(&store_id).await?;
print_json(
&serde_json::json!({ "store_id": store_id, "secrets": secrets, "result_info": result_info }),
)
}
crate::cli::commands::CloudflareSecretsSubCommand::Get(CloudflareSecretsGetCmd {
store_id,
secret_id,
}) => print_json(&client.get_secret(&store_id, &secret_id).await?),
crate::cli::commands::CloudflareSecretsSubCommand::Create(CloudflareSecretsCreateCmd {
store_id,
name,
value,
scopes,
comment,
}) => print_json(
&client
.create_secret(
&store_id,
&CloudflareSecretCreateRequest {
name,
value,
scopes,
comment,
},
)
.await?,
),
crate::cli::commands::CloudflareSecretsSubCommand::Edit(CloudflareSecretsEditCmd {
store_id,
secret_id,
name,
value,
scopes,
comment,
}) => print_json(
&client
.edit_secret(
&store_id,
&secret_id,
&CloudflareSecretEditRequest {
name,
value,
scopes: (!scopes.is_empty()).then_some(scopes),
comment,
},
)
.await?,
),
crate::cli::commands::CloudflareSecretsSubCommand::Delete(CloudflareSecretsDeleteCmd {
store_id,
secret_id,
}) => {
client.delete_secret(&store_id, &secret_id).await?;
println!("Deleted secret {}", secret_id);
Ok(())
}
crate::cli::commands::CloudflareSecretsSubCommand::DeleteBulk(
CloudflareSecretsBulkDeleteCmd {
store_id,
secret_ids,
},
) => {
client
.bulk_delete_secrets(&store_id, &CloudflareBulkDeleteRequest { ids: secret_ids })
.await?;
println!("Deleted secrets from store {}", store_id);
Ok(())
}
crate::cli::commands::CloudflareSecretsSubCommand::Duplicate(
CloudflareSecretsDuplicateCmd {
store_id,
secret_id,
name,
scopes,
comment,
},
) => print_json(
&client
.duplicate_secret(
&store_id,
&secret_id,
&CloudflareSecretDuplicateRequest {
name,
scopes,
comment,
},
)
.await?,
),
}
}
async fn run_cloudflare_quota(
cmd: SecretsQuotaCmd,
token_override: Option<&str>,
account_id_override: Option<&str>,
) -> Result<(), String> {
let client = build_cloudflare_client(token_override, account_id_override)?;
match cmd.command {
SecretsQuotaSubCommand::Get(_) => print_json(&client.get_quota().await?),
}
}
async fn build_github_client(
project_root: &Path,
environment: &str,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<GitHubEnvironmentClient, String> {
let (owner, repo) = github::resolve_repository(project_root, repo_override).await?;
let token = github::resolve_token(token_override).await?;
let client = GitHubEnvironmentClient::new(owner, repo, environment.to_string(), token)?;
client.validate_repo_access().await?;
Ok(client)
}
async fn resolve_github_client_with_setup(
project_root: &Path,
environment: &str,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<GitHubEnvironmentClient, String> {
match build_github_client(project_root, environment, repo_override, token_override).await {
Ok(client) => Ok(client),
Err(error) if repo_override.is_none() && github::needs_repo_setup(&error) => {
println!("GitHub repository could not be detected for this project.");
let repo = prompt_for_input("Enter GitHub repository (owner/repo): ")?;
let repo = repo.trim();
if repo.is_empty() {
return Err(error);
}
build_github_client(project_root, environment, Some(repo), token_override).await
}
Err(error) => Err(error),
}
}
fn build_cloudflare_client(
token_override: Option<&str>,
account_id_override: Option<&str>,
) -> Result<CloudflareClient, String> {
let token = token_override
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(resolve_cloudflare_api_token)
.ok_or_else(|| {
"No Cloudflare API token found. Use `--token`, `CLOUDFLARE_API_TOKEN`, or `xbp config cloudflare set-key`.".to_string()
})?;
let account_id = account_id_override
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(resolve_cloudflare_account_id)
.ok_or_else(|| {
"No Cloudflare account ID found. Use `--account-id`, `CLOUDFLARE_ACCOUNT_ID`, or `xbp config cloudflare set-account-id`.".to_string()
})?;
CloudflareClient::new(token, account_id)
}
fn unsupported_sync_capability(capability: &str) -> Result<(), String> {
Err(format!(
"Capability `{}` is not supported for `--provider cloudflare` in v1. Use `xbp secrets stores ...`, `xbp secrets secrets ...`, or `xbp secrets quota get --provider cloudflare`.",
capability
))
}
fn list_secrets(
project_root: &Path,
file_override: Option<&str>,
format_override: Option<&str>,
) -> Result<(), String> {
let env_file = if let Some(file) = file_override {
let path = PathBuf::from(file);
let resolved = if path.is_relative() {
project_root.join(path)
} else {
path
};
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") {
println!(
"{}",
serde_json::to_string_pretty(&secrets)
.map_err(|error| format!("Failed to encode JSON output: {}", error))?
);
} else {
println!(
"Found {} variable(s) in {}",
secrets.len(),
env_file.display()
);
let mut names = secrets.keys().cloned().collect::<Vec<_>>();
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");
match (env_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(|error| format!("Failed to run selection prompt: {}", error))?;
Ok(if options[selection] == ".env" {
env
} else {
env_local
})
}
_ => 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.".to_string())
}
fn parse_env_file(path: &Path) -> Result<HashMap<String, String>, String> {
parse_shared_env_file(path)
}
fn print_secrets_help() -> Result<(), String> {
println!("xbp secrets providers");
println!("xbp secrets --provider github push");
println!("xbp secrets stores list --provider cloudflare");
println!("xbp secrets secrets create --provider cloudflare --store-id <id> --name API_KEY --value secret --scopes workers");
println!("xbp secrets quota get --provider cloudflare");
Ok(())
}
fn resolve_project_root() -> Result<PathBuf, String> {
let current_dir =
env::current_dir().map_err(|error| format!("Failed to read current dir: {}", error))?;
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 here."
.to_string()
})
}
fn print_json(value: &impl serde::Serialize) -> Result<(), String> {
println!(
"{}",
serde_json::to_string_pretty(value)
.map_err(|error| format!("Failed to encode JSON output: {}", error))?
);
Ok(())
}