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 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;
use crate::commands::secrets::env_selection::{
print_skipped_empty_variables, resolve_push_env_input,
};
use crate::commands::ssh_helpers::prompt_for_input;
use crate::commands::cloudflare_credentials::{
build_cloudflare_client as build_resolved_cloudflare_client, CloudflareCredentialOverrides,
};
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::{
expand_home_in_string, find_xbp_config_upwards, parse_env_file as parse_shared_env_file,
};
use tokio::process::Command;
#[derive(Debug, Clone)]
struct SecretsScope {
project_root: PathBuf,
local_root: PathBuf,
service_name: String,
remote_environment: String,
}
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 prepared_input = resolve_push_env_input(local_root, args.file)?;
let loader = Loader::start("Pushing GitHub environment variables");
loader.update("[1/5] Reading local variables");
let mut secret_names = prepared_input.pushable.keys().cloned().collect::<Vec<_>>();
secret_names.sort();
if prepared_input.pushable.is_empty() {
let message = if prepared_input.skipped_empty.is_empty() {
format!("No secrets found in {}", prepared_input.display_source)
} else {
format!(
"No non-empty variables found in {}",
prepared_input.display_source
)
};
loader.success_with(&message);
print_skipped_empty_variables(
&prepared_input.display_source,
&prepared_input.skipped_empty,
);
return Ok(());
}
if args.dry_run {
loader.success_with("Dry run complete");
println!(
"[dry-run] Would push {} variable(s) from {} to GitHub Actions environment `{}`:",
prepared_input.pushable.len(),
prepared_input.display_source,
environment
);
for name in &secret_names {
println!(" {}", name);
}
print_skipped_empty_variables(
&prepared_input.display_source,
&prepared_input.skipped_empty,
);
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(&prepared_input.pushable, &mut progress)
.await?;
loader.success_with(&format!(
"Pushed {} variable(s) from {} to GitHub Actions environment `{}`",
prepared_input.pushable.len(),
prepared_input.display_source,
environment
));
print_skipped_empty_variables(
&prepared_input.display_source,
&prepared_input.skipped_empty,
);
Ok(())
}
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 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() {
local_root.join(path)
} else {
path
}
})
.unwrap_or_else(|| local_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,
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(())
}
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 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 {
let Some(root_directory) = service.root_directory.as_deref() else {
return project_root.to_path_buf();
};
let expanded = expand_home_in_string(root_directory);
let root = PathBuf::from(expanded);
if root.is_absolute() {
root
} else if root_directory == "." {
project_root.to_path_buf()
} else {
project_root.join(root)
}
}
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::{scoped_environment_name, service_slug, vercel_environment_name};
#[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!(vercel_environment_name("xbp-prod-web"), "production");
assert_eq!(vercel_environment_name("xbp-preview-web"), "preview");
assert_eq!(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");
}
}