From 5613f4b8be60926a4c475724dc19a1daa7ac6967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Fri, 16 Oct 2026 15:41:14 +0100 Subject: [PATCH] feat(cli): support profile selection for import --- CHANGELOG.md | 3 + docs/src/content/docs/reference/cli.mdx | 17 +++ secretspec/src/cli/mod.rs | 10 +- secretspec/tests/import_profile.rs | 147 ++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 secretspec/tests/import_profile.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ce700078..0edc217b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Select the profile for `secretspec import` with `--profile` or `-P`, + including when moving secrets with `--delete-source`. + - Pin static binary installations using a version in the installer URL, such as `https://install.secretspec.dev/v0.21.1`. The URL without a version still installs the latest release. diff --git a/docs/src/content/docs/reference/cli.mdx b/docs/src/content/docs/reference/cli.mdx index 9ad4d3e0..4ae6a6e9 100644 --- a/docs/src/content/docs/reference/cli.mdx +++ b/docs/src/content/docs/reference/cli.mdx @@ -929,6 +929,23 @@ $ secretspec import [--delete-source] The destination provider and profile are determined from your configuration. Secrets that already exist in the destination provider will not be overwritten. +#### Profile selection + + + +Use `-P, --profile ` to import the selected profile's secrets. The +selected profile applies to both source and destination addresses, including +secrets inherited from the default profile. The flag overrides +`SECRETSPEC_PROFILE` and the configured default profile. Without the flag, +profile selection is unchanged. + +```bash +# Import into the production profile (SecretSpec 0.22+) +$ secretspec import dotenv:.env.production --profile production +``` + +#### Import behavior + In SecretSpec 0.19+, the source and destination resolve their addresses independently. A source alias can use its own [provider `ref` template or per-secret scoped ref](/concepts/references/#different-coordinates-per-provider-019), diff --git a/secretspec/src/cli/mod.rs b/secretspec/src/cli/mod.rs index c44480ad..a27cc0ad 100644 --- a/secretspec/src/cli/mod.rs +++ b/secretspec/src/cli/mod.rs @@ -420,6 +420,9 @@ enum Commands { /// Provider backend to import from (secrets will be imported to the default provider) #[arg(add = clap_complete::ArgValueCompleter::new(completion::providers))] from_provider: String, + /// Profile to import secrets into (0.22+) + #[arg(short = 'P', long, env = "SECRETSPEC_PROFILE", add = clap_complete::ArgValueCompleter::new(completion::profiles))] + profile: Option, /// Delete a source value only after the destination contains the same value (0.18+) #[arg(long)] delete_source: bool, @@ -1813,9 +1816,13 @@ pub fn main() -> Result<()> { // Import secrets from one provider to another Commands::Import { from_provider, + profile, delete_source, } => { - let app = load_secrets(&cli.file, &cli.reason, &caller)?; + let mut app = load_secrets(&cli.file, &cli.reason, &caller)?; + if let Some(profile) = profile { + app.set_profile(profile); + } if delete_source { app.import_with_delete_source(&from_provider) .into_diagnostic() @@ -2864,6 +2871,7 @@ API_KEY = { description = "Existing" } Commands::Import { from_provider, delete_source: true, + .. } if from_provider == "dotenv://.env" )); } diff --git a/secretspec/tests/import_profile.rs b/secretspec/tests/import_profile.rs new file mode 100644 index 00000000..7d9c33fd --- /dev/null +++ b/secretspec/tests/import_profile.rs @@ -0,0 +1,147 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::process::Command; +use tempfile::TempDir; + +fn env_values(path: &Path) -> HashMap { + dotenv::EnvLoader::with_path(path) + .sequence(dotenv::EnvSequence::InputOnly) + .load() + .unwrap() + .into_iter() + .collect() +} + +#[test] +fn import_profile_selection_controls_secrets_and_both_provider_addresses() { + for (args, environment_profile, configured_profile, expected_profile) in [ + ( + vec!["--profile", "production"], + Some("development"), + Some("default"), + "production", + ), + ( + vec!["-P", "production"], + None, + Some("development"), + "production", + ), + ( + vec!["--profile", "production", "--delete-source"], + Some("development"), + Some("default"), + "production", + ), + ( + vec![], + Some("production"), + Some("development"), + "production", + ), + (vec![], None, Some("production"), "production"), + (vec![], None, None, "default"), + ( + vec!["--profile", "default"], + Some("production"), + Some("production"), + "default", + ), + ] { + let temp = TempDir::new().unwrap(); + let project = temp.path(); + fs::write( + project.join("secretspec.toml"), + r#" +[project] +name = "import-profile" +revision = "1.0" +require_reason = false + +[providers] +source = { uri = "dotenv:.env.source", ref = { item = "SOURCE_{profile}_{key}" } } +target = { uri = "dotenv:.env.target", ref = { item = "TARGET_{profile}_{key}" } } + +[profiles.default] +SHARED = { description = "Inherited secret" } + +[profiles.production] +TOKEN = { description = "Production token" } + +[profiles.development] +DEV_ONLY = { description = "Development token" } +"#, + ) + .unwrap(); + let config_home = project.join("config"); + let config_dir = if cfg!(windows) { + config_home.join("secretspec/config") + } else { + config_home.join("secretspec") + }; + fs::create_dir_all(&config_dir).unwrap(); + let profile_setting = configured_profile + .map(|profile| format!("profile = \"{profile}\"\n")) + .unwrap_or_default(); + fs::write( + config_dir.join("config.toml"), + format!("[defaults]\nprovider = \"target\"\n{profile_setting}"), + ) + .unwrap(); + let source_path = project.join(".env.source"); + fs::write( + &source_path, + "SOURCE_default_SHARED=default-value\n\ + SOURCE_production_SHARED=production-shared\n\ + SOURCE_production_TOKEN=production-token\n\ + SOURCE_development_SHARED=development-shared\n\ + SOURCE_development_DEV_ONLY=development-token\n", + ) + .unwrap(); + let original_source = env_values(&source_path); + let mut command = Command::new(env!("CARGO_BIN_EXE_secretspec")); + command + .args(["import", "source"]) + .args(&args) + .current_dir(project) + .env("HOME", project) + .env("XDG_CONFIG_HOME", &config_home) + .env("APPDATA", &config_home) + .env("XDG_STATE_HOME", project.join("state")) + .env("LOCALAPPDATA", project.join("state")) + .env_remove("SECRETSPEC_PROVIDER") + .env_remove("SECRETSPEC_PROFILE") + .env_remove("SECRETSPEC_SCOPE") + .env_remove("SECRETSPEC_REASON"); + if let Some(profile) = environment_profile { + command.env("SECRETSPEC_PROFILE", profile); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "args {args:?}, env {environment_profile:?}, config {configured_profile:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let expected_keys = if expected_profile == "production" { + vec!["SHARED", "TOKEN"] + } else { + vec!["SHARED"] + }; + let mut expected_source = original_source.clone(); + let mut expected_target = HashMap::new(); + for key in expected_keys { + let source_key = format!("SOURCE_{expected_profile}_{key}"); + expected_target.insert( + format!("TARGET_{expected_profile}_{key}"), + original_source[&source_key].clone(), + ); + if args.contains(&"--delete-source") { + expected_source.remove(&source_key); + } + } + assert_eq!(env_values(&project.join(".env.target")), expected_target); + assert_eq!(env_values(&source_path), expected_source); + } +}