mod env_selection;
pub mod github;
pub mod scanner;
pub mod verify;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Select};
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, TaskBar};
use crate::commands::cloudflare_credentials::{
build_cloudflare_client as build_resolved_cloudflare_client, CloudflareCredentialOverrides,
};
use crate::commands::cli_session::{
post_secrets_sync, CliSecretsSyncPayload, CliSecretsSyncVariable,
};
use crate::commands::secrets::env_selection::{
print_skipped_empty_variables, resolve_push_env_input, split_empty_variables,
};
use crate::commands::ssh_helpers::prompt_for_input;
use crate::provider_support::cloudflare::{
CloudflareBulkDeleteRequest, CloudflareSecretCreateRequest, CloudflareSecretDuplicateRequest,
CloudflareSecretEditRequest, CloudflareStoreCreateRequest,
};
use crate::provider_support::{secrets_providers, GitHubEnvironmentClient};
use crate::strategies::{get_all_services, ServiceConfig};
use crate::utils::{
find_xbp_config_upwards, format_env_assignment, parse_env_file as parse_shared_env_file,
resolve_service_root,
};
use tokio::process::Command;
#[derive(Debug, Clone)]
struct SecretsScope {
project_root: PathBuf,
local_root: PathBuf,
service_name: String,
remote_environment: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EnvSyncLane {
App,
CloudflareDevVars,
}
#[derive(Debug, Clone)]
struct PreparedPushTarget {
lane: EnvSyncLane,
environment: String,
display_source: String,
pushable: HashMap<String, String>,
skipped_empty: Vec<String>,
}
#[derive(Debug, Clone)]
struct PullTarget {
lane: EnvSyncLane,
environment: String,
output_path: PathBuf,
}
pub async fn run_secrets(cmd: SecretsCmd, debug: bool) -> Result<(), String> {
let project_root = resolve_project_root()?;
let scope =
resolve_secrets_scope(&project_root, cmd.service.as_deref(), &cmd.environment).await?;
let repo_override = cmd.repo.as_deref();
let token_override = cmd.token.as_deref();
let account_id_override = cmd.account_id.as_deref();
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(
&scope.local_root,
args.file.as_deref(),
args.format.as_deref(),
),
SecretsProviderKind::Cloudflare => unsupported_sync_capability("list"),
SecretsProviderKind::Railway | SecretsProviderKind::Vercel => {
unsupported_sync_capability("list")
}
},
Some(SecretsSubCommand::Push(args)) => match cmd.provider {
SecretsProviderKind::Github => {
push_secrets(
&scope.project_root,
&scope.local_root,
args,
&scope.remote_environment,
repo_override,
token_override,
)
.await
}
SecretsProviderKind::Cloudflare => unsupported_sync_capability("push"),
SecretsProviderKind::Railway | SecretsProviderKind::Vercel => {
unsupported_sync_capability("push")
}
},
Some(SecretsSubCommand::Pull(args)) => match cmd.provider {
SecretsProviderKind::Github => {
pull_secrets(
&scope.project_root,
&scope.local_root,
args,
&scope.remote_environment,
repo_override,
token_override,
)
.await
}
SecretsProviderKind::Cloudflare => unsupported_sync_capability("pull"),
SecretsProviderKind::Railway => pull_railway_secrets(&scope, args).await,
SecretsProviderKind::Vercel => pull_vercel_secrets(&scope, args).await,
},
Some(SecretsSubCommand::GenerateDefault(args)) => {
scanner::generate_env_default(&scope.local_root, args.output.as_deref())
}
Some(SecretsSubCommand::GenerateExample(GenerateExampleCmd {
output,
clean,
include_prefix,
exclude_prefix,
})) => scanner::generate_env_example(
&scope.local_root,
output.as_deref(),
clean,
&include_prefix,
&exclude_prefix,
),
Some(SecretsSubCommand::Diff) => match cmd.provider {
SecretsProviderKind::Github => {
diff_secrets(
&scope.project_root,
&scope.local_root,
&scope.remote_environment,
repo_override,
token_override,
)
.await
}
SecretsProviderKind::Cloudflare => unsupported_sync_capability("diff"),
SecretsProviderKind::Railway | SecretsProviderKind::Vercel => {
unsupported_sync_capability("diff")
}
},
Some(SecretsSubCommand::Verify) => match cmd.provider {
SecretsProviderKind::Github => verify::verify_envs(&project_root).await,
SecretsProviderKind::Cloudflare => unsupported_sync_capability("verify"),
SecretsProviderKind::Railway | SecretsProviderKind::Vercel => {
unsupported_sync_capability("verify")
}
},
Some(SecretsSubCommand::Diag) => match cmd.provider {
SecretsProviderKind::Github => {
diag_github(
&scope.project_root,
&scope.remote_environment,
repo_override,
token_override,
)
.await
}
SecretsProviderKind::Cloudflare => {
diag_cloudflare(token_override, account_id_override).await
}
SecretsProviderKind::Railway | SecretsProviderKind::Vercel => {
diag_cli_provider(cmd.provider).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(&scope.local_root, None, None),
};
outcome.map(|_| {
if debug {
println!("[secrets] Completed secrets command");
}
})
}
async fn push_secrets(
project_root: &Path,
local_root: &Path,
args: PushCmd,
environment: &str,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<(), String> {
let targets = prepare_push_targets(local_root, environment, &args)?;
if targets.is_empty() {
println!("No env files found to push.");
return Ok(());
}
for target in targets {
push_secrets_target(
project_root,
target,
repo_override,
token_override,
args.dry_run,
)
.await?;
}
Ok(())
}
async fn push_secrets_target(
project_root: &Path,
target: PreparedPushTarget,
repo_override: Option<&str>,
token_override: Option<&str>,
dry_run: bool,
) -> Result<(), String> {
let label = sync_lane_label(target.lane);
let loader = Loader::start(&format!(
"Pushing {label} variables to GitHub environment `{}`",
target.environment
));
loader.update("[1/5] Reading local variables");
let mut secret_names = target.pushable.keys().cloned().collect::<Vec<_>>();
secret_names.sort();
if target.pushable.is_empty() {
let message = if target.skipped_empty.is_empty() {
format!("No secrets found in {}", target.display_source)
} else {
format!("No non-empty variables found in {}", target.display_source)
};
loader.success_with(&message);
print_skipped_empty_variables(&target.display_source, &target.skipped_empty);
return Ok(());
}
if dry_run {
loader.success_with("Dry run complete");
println!(
"[dry-run] Would push {} {} variable(s) from {} to GitHub Actions environment `{}`",
target.pushable.len(),
label,
target.display_source,
target.environment
);
println!(
"[dry-run] Would also sync the same keys into the xbp.app secrets vault (always)."
);
for name in &secret_names {
println!(" {}", name);
}
print_skipped_empty_variables(&target.display_source, &target.skipped_empty);
return Ok(());
}
loader.update("[2/6] Syncing secrets vault on xbp.app");
match sync_secrets_to_xbp_app(project_root, &target, repo_override).await {
Ok(Some(upserted)) => {
loader.log(&format!(
" {} Vault: stored {} secret(s) on xbp.app for `{}`",
"✓".bright_green(),
upserted,
target.environment
));
}
Ok(None) => {
loader.log(&format!(
" {} Vault: skipped (run `xbp login` to store secrets on xbp.app)",
"i".bright_blue()
));
}
Err(error) => {
loader.log(&format!(
" {} Vault: {}",
"!".bright_yellow(),
error
));
}
}
loader.update("[3/6] Resolving GitHub repository and token");
let client = match build_github_client(
project_root,
&target.environment,
repo_override,
token_override,
)
.await
{
Ok(client) => client,
Err(error) => {
loader.fail(&error);
return Err(error);
}
};
loader.update("[4/6] Uploading variables to GitHub");
loader.finish_for_handoff();
let total = target.pushable.len() as u64;
let bar = TaskBar::start(
&format!(
"Pushing {label} → `{}` ({})",
target.environment, target.display_source
),
total,
);
let mut progress = |completed: usize, total_count: usize, message: &str| {
if total_count == 0 {
bar.set_message(message);
} else {
bar.tick(completed as u64, message);
}
};
match client
.upsert_with_counts(&target.pushable, &mut progress)
.await
{
Ok(()) => {
bar.success_with(&format!(
"Pushed {} {} variable(s) from {} to GitHub Actions environment `{}`",
target.pushable.len(),
label,
target.display_source,
target.environment
));
print_skipped_empty_variables(&target.display_source, &target.skipped_empty);
Ok(())
}
Err(error) => {
bar.fail(&error);
Err(error)
}
}
}
async fn pull_secrets(
project_root: &Path,
local_root: &Path,
args: PullCmd,
environment: &str,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<(), String> {
let targets = prepare_pull_targets(local_root, environment, &args);
if targets.is_empty() {
println!("No GitHub environment targets selected for pull.");
return Ok(());
}
for target in targets {
pull_secrets_target(project_root, target, repo_override, token_override).await?;
}
Ok(())
}
async fn pull_secrets_target(
project_root: &Path,
target: PullTarget,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<(), String> {
let label = sync_lane_label(target.lane);
let loader = Loader::start(&format!(
"Pulling {label} variables from GitHub environment `{}`",
target.environment
));
loader.update("[1/4] Resolving GitHub repository and token");
let client = resolve_github_client_with_setup(
project_root,
&target.environment,
repo_override,
token_override,
)
.await?;
client.ensure_environment_exists().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 `{}`",
target.environment
));
return Ok(());
}
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_env_assignment(&variable.name, &variable.value));
content.push('\n');
}
loader.update("[4/4] Writing local env file");
fs::write(&target.output_path, content).map_err(|error| {
format!(
"Failed to write {}: {}",
target.output_path.display(),
error
)
})?;
loader.success_with(&format!(
"Pulled {} variables from GitHub Actions environment `{}` into {}",
label,
target.environment,
target.output_path.display()
));
Ok(())
}
async fn diff_secrets(
project_root: &Path,
local_root: &Path,
environment: &str,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<(), String> {
let env_path = choose_env_for_list(local_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(())
}
fn prepare_push_targets(
local_root: &Path,
environment: &str,
args: &PushCmd,
) -> Result<Vec<PreparedPushTarget>, String> {
let mut targets = Vec::new();
let explicit_file_is_dev_vars = args.file.as_deref().is_some_and(is_dev_vars_file_name);
if !explicit_file_is_dev_vars {
let prepared_input = resolve_push_env_input(local_root, args.file.clone())?;
targets.push(PreparedPushTarget {
lane: EnvSyncLane::App,
environment: environment.to_string(),
display_source: prepared_input.display_source,
pushable: prepared_input.pushable,
skipped_empty: prepared_input.skipped_empty,
});
}
if args.skip_dev_vars {
return Ok(targets);
}
if let Some(dev_vars_path) = resolve_dev_vars_push_path(
local_root,
args.dev_vars_file.as_deref(),
explicit_file_is_dev_vars
.then_some(args.file.as_deref())
.flatten(),
)? {
let variables = parse_env_file(&dev_vars_path)?;
let (pushable, skipped_empty) = split_empty_variables(variables);
targets.push(PreparedPushTarget {
lane: EnvSyncLane::CloudflareDevVars,
environment: cloudflare_environment_name(environment),
display_source: dev_vars_path.display().to_string(),
pushable,
skipped_empty,
});
}
Ok(targets)
}
fn prepare_pull_targets(local_root: &Path, environment: &str, args: &PullCmd) -> Vec<PullTarget> {
let mut targets = vec![PullTarget {
lane: EnvSyncLane::App,
environment: environment.to_string(),
output_path: resolve_output_path(local_root, args.output.clone()),
}];
if !args.skip_dev_vars {
if let Some(output_path) = resolve_dev_vars_pull_output(local_root, args) {
targets.push(PullTarget {
lane: EnvSyncLane::CloudflareDevVars,
environment: cloudflare_environment_name(environment),
output_path,
});
}
}
targets
}
fn resolve_dev_vars_push_path(
local_root: &Path,
dev_vars_file: Option<&str>,
explicit_file: Option<&str>,
) -> Result<Option<PathBuf>, String> {
if let Some(file) = dev_vars_file.or(explicit_file) {
let resolved = resolve_local_path(local_root, file);
if !resolved.exists() {
return Err(format!(
"Specified Cloudflare .dev.vars file does not exist: {}",
resolved.display()
));
}
return Ok(Some(resolved));
}
let default = local_root.join(".dev.vars");
Ok(default.exists().then_some(default))
}
fn resolve_dev_vars_pull_output(local_root: &Path, args: &PullCmd) -> Option<PathBuf> {
if let Some(output) = args.dev_vars_output.as_deref() {
return Some(resolve_local_path(local_root, output));
}
let default = local_root.join(".dev.vars");
(args.include_dev_vars || default.exists()).then_some(default)
}
fn resolve_local_path(local_root: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_relative() {
local_root.join(path)
} else {
path
}
}
fn is_dev_vars_file_name(value: &str) -> bool {
Path::new(value)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(".dev.vars"))
}
fn cloudflare_environment_name(environment: &str) -> String {
let trimmed = environment.trim();
if trimmed.is_empty() {
return "xbp-cf-dev".to_string();
}
if trimmed.starts_with("xbp-cf-") {
return trimmed.to_string();
}
if let Some(rest) = trimmed.strip_prefix("xbp-") {
return format!("xbp-cf-{rest}");
}
format!("xbp-cf-{trimmed}")
}
fn sync_lane_label(lane: EnvSyncLane) -> &'static str {
match lane {
EnvSyncLane::App => "app env",
EnvSyncLane::CloudflareDevVars => "Cloudflare .dev.vars",
}
}
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_secrets_cloudflare_client(token_override, account_id_override).await?;
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_secrets_cloudflare_client(token_override, account_id_override).await?;
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_secrets_cloudflare_client(token_override, account_id_override).await?;
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_secrets_cloudflare_client(token_override, account_id_override).await?;
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 sync_secrets_to_xbp_app(
project_root: &Path,
target: &PreparedPushTarget,
repo_override: Option<&str>,
) -> Result<Option<usize>, String> {
let (repository_owner, repository_name) =
match github::resolve_repository(project_root, repo_override).await {
Ok((owner, repo)) => (Some(owner), Some(repo)),
Err(_) => (None, None),
};
let mut variables = target
.pushable
.iter()
.map(|(name, value)| CliSecretsSyncVariable {
name: name.clone(),
value: value.clone(),
})
.collect::<Vec<_>>();
variables.sort_by(|left, right| left.name.cmp(&right.name));
let payload = CliSecretsSyncPayload {
environment_name: target.environment.clone(),
source: "cli-push".to_string(),
display_source: Some(target.display_source.clone()),
repository_owner,
repository_name,
project_path: Some(project_root.display().to_string()),
variables,
};
match post_secrets_sync(&payload).await? {
Some(response) => Ok(Some(response.upserted)),
None => Ok(None),
}
}
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),
}
}
async fn build_secrets_cloudflare_client(
token_override: Option<&str>,
account_id_override: Option<&str>,
) -> Result<crate::provider_support::cloudflare::CloudflareClient, String> {
build_resolved_cloudflare_client(
CloudflareCredentialOverrides {
token: token_override.map(str::to_string),
account_id: account_id_override.map(str::to_string),
},
true,
)
.await
}
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 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(())
}
async fn resolve_secrets_scope(
project_root: &Path,
service_override: Option<&str>,
environment: &str,
) -> Result<SecretsScope, String> {
let current_dir =
env::current_dir().map_err(|error| format!("Failed to read current dir: {}", error))?;
let (_, config) = crate::commands::service::load_xbp_config_with_root().await?;
let services = get_all_services(&config);
let service =
resolve_service_for_secrets(project_root, ¤t_dir, &services, service_override)?;
let local_root = service_local_root(project_root, &service);
let is_root_service = is_root_service(project_root, &local_root);
let remote_environment = scoped_environment_name(environment, &service.name, is_root_service);
Ok(SecretsScope {
project_root: project_root.to_path_buf(),
local_root,
service_name: service.name,
remote_environment,
})
}
fn resolve_service_for_secrets(
project_root: &Path,
current_dir: &Path,
services: &[ServiceConfig],
service_override: Option<&str>,
) -> Result<ServiceConfig, String> {
if services.is_empty() {
return Err(
"No services found in .xbp/xbp.yaml. Run `xbp generate config --update`.".to_string(),
);
}
if let Some(name) = service_override {
return services
.iter()
.find(|service| service.name == name)
.cloned()
.ok_or_else(|| format!("Service `{}` not found in .xbp/xbp.yaml.", name));
}
if let Some(service) = services
.iter()
.filter(|service| current_dir.starts_with(service_local_root(project_root, service)))
.max_by_key(|service| {
service_local_root(project_root, service)
.components()
.count()
})
{
return Ok(service.clone());
}
if services.len() == 1 {
return Ok(services[0].clone());
}
let options = services
.iter()
.map(|service| {
let root = service.root_directory.as_deref().unwrap_or(".");
format!("{} ({})", service.name, root)
})
.collect::<Vec<_>>();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Multiple XBP services found, choose one for secrets sync")
.items(&options)
.default(0)
.interact()
.map_err(|error| format!("Failed to run service selection prompt: {}", error))?;
Ok(services[selection].clone())
}
fn service_local_root(project_root: &Path, service: &ServiceConfig) -> PathBuf {
resolve_service_root(project_root, service.root_directory.as_deref())
}
fn is_root_service(project_root: &Path, local_root: &Path) -> bool {
canonicalize_or_fallback(project_root) == canonicalize_or_fallback(local_root)
}
fn canonicalize_or_fallback(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn scoped_environment_name(
base_environment: &str,
service_name: &str,
is_root_service: bool,
) -> String {
let trimmed = base_environment.trim();
if is_root_service || trimmed.is_empty() {
return trimmed.to_string();
}
let slug = service_slug(service_name);
if trimmed.ends_with(&format!("-{}", slug)) {
trimmed.to_string()
} else {
format!("{}-{}", trimmed, slug)
}
}
fn service_slug(service_name: &str) -> String {
let mut slug = String::new();
let mut last_dash = false;
for ch in service_name.chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
last_dash = false;
} else if !last_dash {
slug.push('-');
last_dash = true;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
"service".to_string()
} else {
slug
}
}
async fn pull_railway_secrets(scope: &SecretsScope, args: PullCmd) -> Result<(), String> {
let output_path = resolve_output_path(&scope.local_root, args.output);
let output = Command::new("railway")
.args([
"variables",
"--json",
"--service",
&scope.service_name,
"--environment",
&scope.remote_environment,
])
.current_dir(&scope.local_root)
.output()
.await
.map_err(|error| format!("Failed to run `railway variables`: {}", error))?;
if !output.status.success() {
return Err(format!(
"`railway variables` failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let variables: HashMap<String, serde_json::Value> = serde_json::from_slice(&output.stdout)
.map_err(|error| {
format!(
"Failed to parse `railway variables --json` output as an object: {}",
error
)
})?;
let mut rendered = String::new();
let mut names = variables.keys().cloned().collect::<Vec<_>>();
names.sort();
for name in names {
let value = variables
.get(&name)
.and_then(|value| value.as_str())
.map(str::to_string)
.unwrap_or_else(|| variables[&name].to_string());
rendered.push_str(&format!("{}={}\n", name, value));
}
fs::write(&output_path, rendered)
.map_err(|error| format!("Failed to write {}: {}", output_path.display(), error))?;
println!(
"Pulled Railway variables for service `{}` environment `{}` into {}.",
scope.service_name,
scope.remote_environment,
output_path.display()
);
Ok(())
}
async fn pull_vercel_secrets(scope: &SecretsScope, args: PullCmd) -> Result<(), String> {
let output_path = resolve_output_path(&scope.local_root, args.output);
let vercel_environment = vercel_environment_name(&scope.remote_environment);
let mut command = Command::new("vercel");
command
.args(["env", "pull"])
.arg(&output_path)
.current_dir(&scope.local_root);
if vercel_environment != "development" {
command.arg(format!("--environment={}", vercel_environment));
}
let output = command
.output()
.await
.map_err(|error| format!("Failed to run `vercel env pull`: {}", error))?;
if !output.status.success() {
return Err(format!(
"`vercel env pull` failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
println!(
"Pulled Vercel variables for service `{}` environment `{}` into {}.",
scope.service_name,
vercel_environment,
output_path.display()
);
Ok(())
}
fn resolve_output_path(local_root: &Path, output: Option<String>) -> PathBuf {
output
.map(PathBuf::from)
.map(|path| {
if path.is_relative() {
local_root.join(path)
} else {
path
}
})
.unwrap_or_else(|| local_root.join(".env.local"))
}
fn vercel_environment_name(environment: &str) -> String {
let lower = environment.to_ascii_lowercase();
if lower.contains("prod") {
"production".to_string()
} else if lower.contains("preview") {
"preview".to_string()
} else {
"development".to_string()
}
}
async fn diag_cli_provider(provider: SecretsProviderKind) -> Result<(), String> {
let binary = match provider {
SecretsProviderKind::Railway => "railway",
SecretsProviderKind::Vercel => "vercel",
SecretsProviderKind::Github | SecretsProviderKind::Cloudflare => {
return Ok(());
}
};
let output = Command::new(binary)
.arg("--version")
.output()
.await
.map_err(|error| format!("Failed to run `{}`: {}", binary, error))?;
if !output.status.success() {
return Err(format!(
"`{} --version` failed: {}",
binary,
String::from_utf8_lossy(&output.stderr).trim()
));
}
println!(
"{} CLI available: {}",
binary,
String::from_utf8_lossy(&output.stdout).trim()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
cloudflare_environment_name, prepare_pull_targets, prepare_push_targets,
scoped_environment_name, service_slug, EnvSyncLane,
};
use crate::cli::commands::{PullCmd, PushCmd};
use std::fs;
use std::path::PathBuf;
fn temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-secrets-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("temp dir should be created");
dir
}
fn push_cmd() -> PushCmd {
PushCmd {
file: None,
dev_vars_file: None,
skip_dev_vars: false,
force: false,
dry_run: false,
}
}
fn pull_cmd() -> PullCmd {
PullCmd {
output: None,
dev_vars_output: None,
include_dev_vars: false,
skip_dev_vars: false,
}
}
#[test]
fn root_service_keeps_legacy_environment_name() {
assert_eq!(scoped_environment_name("xbp-dev", "xbp", true), "xbp-dev");
}
#[test]
fn nested_service_appends_manifest_service_slug() {
assert_eq!(
scoped_environment_name("xbp-prod", "@acme/web-app", false),
"xbp-prod-acme-web-app"
);
}
#[test]
fn scoped_environment_does_not_double_append_slug() {
assert_eq!(
scoped_environment_name("xbp-preview-web", "web", false),
"xbp-preview-web"
);
}
#[test]
fn provider_environment_names_map_to_vercel_targets() {
assert_eq!(super::vercel_environment_name("xbp-prod-web"), "production");
assert_eq!(super::vercel_environment_name("xbp-preview-web"), "preview");
assert_eq!(super::vercel_environment_name("xbp-dev-web"), "development");
}
#[test]
fn service_slug_is_ascii_and_stable() {
assert_eq!(service_slug("@xylex/Web App"), "xylex-web-app");
}
#[test]
fn cloudflare_environment_names_are_separate_from_app_envs() {
assert_eq!(cloudflare_environment_name("xbp-dev"), "xbp-cf-dev");
assert_eq!(cloudflare_environment_name("xbp-prod"), "xbp-cf-prod");
assert_eq!(
cloudflare_environment_name("xbp-staging-web"),
"xbp-cf-staging-web"
);
assert_eq!(cloudflare_environment_name("dev"), "xbp-cf-dev");
assert_eq!(cloudflare_environment_name("xbp-cf-dev"), "xbp-cf-dev");
}
#[test]
fn push_targets_include_dev_vars_when_present() {
let root = temp_dir("push-targets");
fs::write(root.join(".env.local"), "APP_SECRET=app\n").expect("write env");
fs::write(root.join(".dev.vars"), "CF_SECRET=worker\n").expect("write dev vars");
let targets =
prepare_push_targets(&root, "xbp-prod", &push_cmd()).expect("prepare targets");
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].lane, EnvSyncLane::App);
assert_eq!(targets[0].environment, "xbp-prod");
assert_eq!(targets[1].lane, EnvSyncLane::CloudflareDevVars);
assert_eq!(targets[1].environment, "xbp-cf-prod");
assert_eq!(
targets[1].pushable.get("CF_SECRET"),
Some(&"worker".to_string())
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn explicit_dev_vars_file_pushes_only_cloudflare_lane() {
let root = temp_dir("explicit-dev-vars");
fs::write(root.join(".dev.vars"), "CF_SECRET=worker\n").expect("write dev vars");
let mut cmd = push_cmd();
cmd.file = Some(".dev.vars".to_string());
let targets = prepare_push_targets(&root, "xbp-dev", &cmd).expect("prepare targets");
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].lane, EnvSyncLane::CloudflareDevVars);
assert_eq!(targets[0].environment, "xbp-cf-dev");
let _ = fs::remove_dir_all(root);
}
#[test]
fn pull_targets_include_dev_vars_when_local_file_exists() {
let root = temp_dir("pull-targets");
fs::write(root.join(".dev.vars"), "CF_SECRET=old\n").expect("write dev vars");
let targets = prepare_pull_targets(&root, "xbp-preview", &pull_cmd());
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].lane, EnvSyncLane::App);
assert_eq!(targets[0].environment, "xbp-preview");
assert_eq!(targets[0].output_path, root.join(".env.local"));
assert_eq!(targets[1].lane, EnvSyncLane::CloudflareDevVars);
assert_eq!(targets[1].environment, "xbp-cf-preview");
assert_eq!(targets[1].output_path, root.join(".dev.vars"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn pull_targets_can_force_dev_vars_creation() {
let root = temp_dir("pull-force-dev-vars");
let mut cmd = pull_cmd();
cmd.include_dev_vars = true;
let targets = prepare_pull_targets(&root, "xbp-staging", &cmd);
assert_eq!(targets.len(), 2);
assert_eq!(targets[1].environment, "xbp-cf-staging");
assert_eq!(targets[1].output_path, root.join(".dev.vars"));
let _ = fs::remove_dir_all(root);
}
}