mod env_selection;
pub mod github;
pub mod scanner;
pub mod verify;
use std::collections::{BTreeSet, HashMap};
use std::env;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use colored::Colorize;
use dialoguer::Input;
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::interactive::{
pad_label, print_picker_header, searchable_select, select_one, xbp_theme,
};
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 xbp_providers::cloudflare::{
CloudflareBulkDeleteRequest, CloudflareSecretCreateRequest, CloudflareSecretDuplicateRequest,
CloudflareSecretEditRequest, CloudflareStoreCreateRequest,
};
use xbp_providers::{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, upsert_env_key,
};
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.as_deref(),
)
.await?;
println!(
"{} service `{}` · remote env `{}` · local `{}`",
"secrets".bright_magenta().bold(),
scope.service_name.bright_white().bold(),
scope.remote_environment.bright_cyan().bold(),
scope.local_root.display().to_string().bright_black()
);
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 keys = normalize_pull_keys(&args.keys);
if !keys.is_empty() {
return pull_secrets_by_keys(
project_root,
local_root,
&keys,
environment,
&args,
repo_override,
token_override,
)
.await;
}
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(())
}
fn normalize_pull_keys(keys: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut seen = BTreeSet::new();
for key in keys {
let trimmed = key.trim();
if trimmed.is_empty() {
continue;
}
let upper = trimmed.to_string();
if seen.insert(upper.to_ascii_uppercase()) {
out.push(upper);
}
}
out
}
#[derive(Debug, Clone)]
struct KeyHit {
environment: String,
value: String,
}
async fn pull_secrets_by_keys(
project_root: &Path,
local_root: &Path,
keys: &[String],
preferred_environment: &str,
args: &PullCmd,
repo_override: Option<&str>,
token_override: Option<&str>,
) -> Result<(), String> {
let output_path = resolve_output_path(local_root, args.output.clone());
let loader = Loader::start(&format!(
"Looking up {} key(s) across GitHub environments",
keys.len()
));
loader.update("[1/3] Resolving GitHub repository and token");
let base_client = resolve_github_client_with_setup(
project_root,
preferred_environment,
repo_override,
token_override,
)
.await?;
loader.update("[2/3] Listing repository environments");
let mut progress = |message: &str| loader.update(message);
let mut environments = base_client
.list_repository_environments_with_progress(&mut progress)
.await?;
if !preferred_environment.trim().is_empty()
&& !environments
.iter()
.any(|env| env.eq_ignore_ascii_case(preferred_environment))
{
environments.push(preferred_environment.to_string());
environments.sort();
environments.dedup();
}
if environments.is_empty() {
loader.fail("No GitHub Actions environments found for this repository.");
return Err(
"No GitHub Actions environments found. Create one with `xbp secrets push` or on GitHub."
.to_string(),
);
}
loader.update(&format!(
"[3/3] Scanning {} environment(s) for {} key(s)",
environments.len(),
keys.len()
));
let mut hits_by_key: HashMap<String, Vec<KeyHit>> = HashMap::new();
for key in keys {
hits_by_key.insert(key.clone(), Vec::new());
}
for (index, env_name) in environments.iter().enumerate() {
loader.update(&format!(
"Scanning environment `{}` ({}/{})",
env_name,
index + 1,
environments.len()
));
let client = base_client.with_environment(env_name.clone());
for key in keys {
match client.get_variable(key).await {
Ok(Some(variable)) => {
hits_by_key
.get_mut(key)
.expect("key map pre-seeded")
.push(KeyHit {
environment: env_name.clone(),
value: variable.value,
});
}
Ok(None) => {}
Err(error) => {
if error.contains("404") || error.contains("Not Found") {
continue;
}
eprintln!(
"{} skipped `{}` in `{}`: {}",
"warn".bright_yellow().bold(),
key,
env_name,
error
);
}
}
}
}
loader.success_with(&format!(
"Scanned {} GitHub environment(s) for {} key(s)",
environments.len(),
keys.len()
));
let mut written = 0usize;
let mut missing = Vec::new();
for key in keys {
let hits = hits_by_key.get(key).cloned().unwrap_or_default();
if hits.is_empty() {
missing.push(key.clone());
println!(
"{} `{}` not found in any of {} environment(s)",
"miss".bright_red().bold(),
key.bright_white(),
environments.len()
);
continue;
}
let env_names: Vec<&str> = hits.iter().map(|h| h.environment.as_str()).collect();
println!(
"{} `{}` set in {} environment(s): {}",
"found".bright_green().bold(),
key.bright_white(),
hits.len(),
env_names.join(", ")
);
let selected = select_key_hit(key, &hits, preferred_environment);
if hits.len() > 1 {
let distinct_values: BTreeSet<&str> = hits.iter().map(|h| h.value.as_str()).collect();
if distinct_values.len() > 1 {
println!(
" {} values differ across environments — using `{}`",
"note".bright_yellow().bold(),
selected.environment.bright_cyan()
);
for hit in &hits {
let marker = if hit.environment == selected.environment {
"→"
} else {
" "
};
println!(
" {} `{}` (value fingerprint={})",
marker,
hit.environment,
short_value_fingerprint(&hit.value)
);
}
} else {
println!(
" {} same value in all environments — using `{}`",
"·".bright_black(),
selected.environment.bright_cyan()
);
}
}
upsert_env_key(&output_path, key, &selected.value)?;
written += 1;
println!(
" {} wrote `{}` from environment `{}` → {}",
"ok".bright_green().bold(),
key,
selected.environment.bright_cyan(),
output_path.display()
);
}
if written > 0 {
println!(
"\n{} upserted {} key(s) into {}",
"pull".bright_magenta().bold(),
written,
output_path.display()
);
}
if !missing.is_empty() {
return Err(format!(
"Missing key(s) in all GitHub environments: {}. Scanned: {}.",
missing.join(", "),
environments.join(", ")
));
}
Ok(())
}
fn select_key_hit<'a>(key: &str, hits: &'a [KeyHit], preferred_environment: &str) -> &'a KeyHit {
let _ = key;
if let Some(hit) = hits
.iter()
.find(|h| h.environment.eq_ignore_ascii_case(preferred_environment))
{
return hit;
}
if let Some(hit) = hits
.iter()
.find(|h| !h.environment.starts_with("xbp-cf-"))
{
return hit;
}
&hits[0]
}
fn short_value_fingerprint(value: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
let hash = hasher.finish();
format!("{:012x}", hash & 0x0000_ffff_ffff_ffff)
}
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<xbp_providers::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 --provider github pull");
println!("xbp secrets --provider github pull --key HEROUI_AUTH_TOKEN");
println!("xbp secrets --provider github pull --key FOO --key BAR --environment production");
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_override: Option<&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 = is_root_service(project_root, &local_root);
let base_environment =
resolve_environment_base(&config, environment_override, &service.name, is_root)?;
let remote_environment =
scoped_environment_name(&base_environment, &service.name, is_root);
Ok(SecretsScope {
project_root: project_root.to_path_buf(),
local_root,
service_name: service.name,
remote_environment,
})
}
fn secrets_is_interactive() -> bool {
std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
&& env::var_os("XBP_NON_INTERACTIVE").is_none()
}
fn resolve_environment_base(
config: &crate::strategies::XbpConfig,
environment_override: Option<&str>,
service_name: &str,
is_root_service: bool,
) -> Result<String, String> {
if let Some(raw) = environment_override
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Ok(raw.to_string());
}
if !secrets_is_interactive() {
return Err(
"No secrets environment specified.\n\
Pass `--environment <name>` (e.g. `production`, `staging`, `development`).\n\
Nested services map to `<env>-<service>` on GitHub (e.g. `production-web`).\n\
In a TTY, omit `--environment` to pick interactively."
.into(),
);
}
let candidates = environment_candidates(config);
print_picker_header(
"xbp secrets · environment",
"GitHub Actions environment base name. Nested services append -<service> automatically.",
);
if !is_root_service {
println!(
" {} service `{}` is nested — remote name will be `{{env}}-{}`",
"·".bright_cyan(),
service_name.bright_white(),
service_slug(service_name).bright_yellow()
);
}
let mut labels: Vec<String> = candidates
.iter()
.map(|env| {
let remote = scoped_environment_name(env, service_name, is_root_service);
if remote == *env {
format!("{env}")
} else {
format!("{env} → remote `{remote}`")
}
})
.collect();
labels.push("custom…".to_string());
let default_idx = candidates
.iter()
.position(|e| {
config
.deploy
.as_ref()
.and_then(|d| d.default_env.as_deref())
== Some(e.as_str())
})
.or_else(|| candidates.iter().position(|e| e == "production"))
.unwrap_or(0);
let idx = select_one("Environment", &labels, default_idx)?
.ok_or_else(|| "Environment selection cancelled".to_string())?;
if idx < candidates.len() {
return Ok(candidates[idx].clone());
}
let custom: String = Input::with_theme(&xbp_theme())
.with_prompt("Custom environment name (base, no service suffix)")
.interact_text()
.map_err(|e| format!("Failed to read environment name: {e}"))?;
let custom = custom.trim().to_string();
if custom.is_empty() {
return Err("Environment name cannot be empty.".into());
}
if custom.contains(char::is_whitespace) {
return Err("Environment name must not contain whitespace.".into());
}
Ok(custom)
}
fn environment_candidates(config: &crate::strategies::XbpConfig) -> Vec<String> {
let mut set = BTreeSet::new();
if let Some(default) = config
.deploy
.as_ref()
.and_then(|d| d.default_env.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
{
set.insert(default.to_string());
}
for service in get_all_services(config) {
if let Some(deploy) = &service.deploy {
for env_name in deploy.envs.keys() {
let t = env_name.trim();
if !t.is_empty() {
set.insert(t.to_string());
}
}
}
}
for common in [
"production",
"staging",
"development",
"preview",
"xbp-prod",
"xbp-dev",
"xbp-preview",
] {
set.insert(common.to_string());
}
set.into_iter().collect()
}
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));
}
let cwd_matches: Vec<&ServiceConfig> = services
.iter()
.filter(|service| current_dir.starts_with(service_local_root(project_root, service)))
.collect();
if cwd_matches.len() == 1 {
return Ok(cwd_matches[0].clone());
}
if cwd_matches.len() > 1 {
return pick_service_interactively(project_root, &cwd_matches);
}
if services.len() == 1 {
return Ok(services[0].clone());
}
if !secrets_is_interactive() {
let names: Vec<_> = services.iter().map(|s| s.name.as_str()).collect();
return Err(format!(
"Multiple services found; pass `--service <name>` in non-interactive mode.\n\
Available: {}",
names.join(", ")
));
}
pick_service_interactively(project_root, &services.iter().collect::<Vec<_>>())
}
fn pick_service_interactively(
project_root: &Path,
services: &[&ServiceConfig],
) -> Result<ServiceConfig, String> {
if services.is_empty() {
return Err("No services available for secrets sync.".into());
}
if services.len() == 1 {
return Ok(services[0].clone());
}
let name_w = services
.iter()
.map(|s| s.name.chars().count())
.max()
.unwrap_or(8)
.clamp(8, 36);
let labels: Vec<String> = services
.iter()
.map(|service| {
let root = service.root_directory.as_deref().unwrap_or(".");
let abs = service_local_root(project_root, service);
let rel = if abs == project_root {
".".to_string()
} else {
abs.strip_prefix(project_root)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| root.to_string())
};
format!(
"{} · {} · :{}",
pad_label(&service.name, name_w),
pad_label(&rel, 28),
service.port
)
})
.collect();
print_picker_header(
"xbp secrets · service",
"Which service's .env to sync? Type to fuzzy-filter · Esc cancels",
);
let idx = searchable_select("Service for secrets", &labels, 0)?
.ok_or_else(|| "Service selection cancelled".to_string())?;
Ok(services[idx].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.clone());
let keys = normalize_pull_keys(&args.keys);
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
)
})?;
if !keys.is_empty() {
let mut missing = Vec::new();
for key in &keys {
let value = variables.iter().find_map(|(name, raw)| {
if name.eq_ignore_ascii_case(key) {
Some(
raw.as_str()
.map(str::to_string)
.unwrap_or_else(|| raw.to_string()),
)
} else {
None
}
});
match value {
Some(value) => {
upsert_env_key(&output_path, key, &value)?;
println!(
"{} wrote `{}` from Railway environment `{}` → {}",
"ok".bright_green().bold(),
key,
scope.remote_environment.bright_cyan(),
output_path.display()
);
}
None => missing.push(key.clone()),
}
}
if !missing.is_empty() {
return Err(format!(
"Missing key(s) in Railway environment `{}`: {}",
scope.remote_environment,
missing.join(", ")
));
}
return Ok(());
}
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_env_assignment(&name, &value));
rendered.push('\n');
}
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,
keys: Vec::new(),
}
}
#[test]
fn normalize_pull_keys_dedupes_case_insensitively() {
let keys = super::normalize_pull_keys(&[
"HEROUI_AUTH_TOKEN".to_string(),
" heroui_auth_token ".to_string(),
"OTHER".to_string(),
"".to_string(),
]);
assert_eq!(keys, vec!["HEROUI_AUTH_TOKEN".to_string(), "OTHER".to_string()]);
}
#[test]
fn select_key_hit_prefers_scoped_environment() {
let hits = vec![
super::KeyHit {
environment: "xbp-dev".to_string(),
value: "a".to_string(),
},
super::KeyHit {
environment: "xbp-prod".to_string(),
value: "b".to_string(),
},
];
let selected = super::select_key_hit("K", &hits, "xbp-prod");
assert_eq!(selected.environment, "xbp-prod");
assert_eq!(selected.value, "b");
}
#[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);
}
}