diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4bd65..2205008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes ## Unreleased +- `voltage context --format env` prints the effective organization, environment, and wallet as `VOLTAGE_*` dotenv lines, the names the `voltage-api` agent skill's helper reads, so a profile's scope can be handed to it. It leaves out unset values, never prints a credential, and fails with exit code 2 when more than one environment is selected. - A command that ignores a set `VOLTAGE_*` variable, because `--profile` or a flag such as `--wallet` takes precedence, says so on stderr with one `warning:` line per variable, even with `--quiet`. Before, only `voltage context` reported it, so the CLI and a tool that lets the variable win could act on different wallets in one shell. Stdout is unchanged. `auth import-key` and `profiles create`, which save what their flags name, do not warn about the flags they were given. - `organizations list`, `environments list`, and `profiles list` accept `--all` and ignore it, like the other lists that return every item at once. Before, they rejected it. - `ctutils` 0.4.5, whose constant-time comparisons no longer branch on secret values (RUSTSEC-2026-0385). It reaches the CLI through `sha2` and `digest`. diff --git a/docs/guides/login-and-profiles.md b/docs/guides/login-and-profiles.md index cebe279..d49cd0a 100644 --- a/docs/guides/login-and-profiles.md +++ b/docs/guides/login-and-profiles.md @@ -85,6 +85,22 @@ voltage context --profile staging voltage context --json | jq -r '.data.organization_id.value' ``` +### Hand the scope to another tool + +`voltage context --format env` prints the effective organization, environment, and wallet as dotenv lines, named as the `voltage-api` agent skill's helper and the [environment variables](../reference/environment.md) read them. Redirect it into the helper's dotenv file, or pass it to `env`: + +```console +$ voltage context --profile staging --format env +VOLTAGE_ORGANIZATION_ID=ORG_ID +VOLTAGE_ENVIRONMENT_ID=ENV_ID +VOLTAGE_WALLET_ID=WALLET_ID +$ voltage context --profile staging --format env > scope.env +``` + +A value that is not set is left out rather than printed empty, because a tool can treat an empty variable as a value. More than one environment, as from repeated `--env`, fails with exit code 2 instead of choosing one. It cannot be combined with `--json`, `--output`, or `--output-file`; redirect stdout instead. + +It never prints a credential: like the rest of `voltage context`, it reads no saved secret. Give the other tool an API key some other way, and use a key for the printed organization and environment, or its requests go elsewhere or fail. + ## Configuration directory Configuration lives in `$XDG_CONFIG_HOME/voltage` or `~/.config/voltage`; override the directory with `VOLTAGE_CONFIG_DIR` or `--config-dir`. The directory and everything in it are owner-only. diff --git a/docs/guides/scripting.md b/docs/guides/scripting.md index 9bace58..6d36d20 100644 --- a/docs/guides/scripting.md +++ b/docs/guides/scripting.md @@ -7,6 +7,8 @@ voltage payments list --profile prod --json | jq '.data.items[].status' voltage context --json | jq -r '.data.organization_id.value' ``` +To hand a profile's organization, environment, and wallet to a tool that reads `VOLTAGE_*` variables, use [`voltage context --format env`](login-and-profiles.md#hand-the-scope-to-another-tool). + ## Send changes from a script Commands that change something only describe their request unless you pass `--execute`, then exit with code 6 so a script that forgot it fails loudly. To send every change in a script or chain of commands, set `VOLTAGE_EXECUTE=1` (or `true`) instead: diff --git a/src/cli.rs b/src/cli.rs index c825101..153cb30 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -18,6 +18,7 @@ use crate::{ }; use clap::{ Arg, ArgAction, ArgMatches, Args, Command as ClapCommand, FromArgMatches, Subcommand, + ValueEnum, builder::{PossibleValuesParser, TypedValueParser}, error::ErrorKind, }; @@ -211,7 +212,7 @@ pub enum LocalCommand { command: ProfileCommand, }, /// Show the effective profile, scope, credential, and config path without secrets - Context, + Context(ContextFlags), /// Show the BTC/USD price and the sats one dollar buys Price(PriceFlags), /// Convert an amount between BTC and USD at the current or a past price @@ -223,6 +224,44 @@ pub enum LocalCommand { }, } +#[derive(Debug, Args)] +pub struct ContextFlags { + /// Print the organization, environment, and wallet as VOLTAGE_* dotenv lines instead + #[arg(long, value_enum, value_name = "FORMAT")] + pub format: Option, +} + +impl ContextFlags { + /// `--format` writes its own lines to stdout, so it takes no result format or file. Checked + /// on the typed flags because clap does not compare a global flag given before the + /// subcommand with the subcommand's own flags. + fn check(&self, global: &GlobalFlags) -> Result<()> { + let result_flag = if global.json { + Some("--json") + } else if global.output.is_some() { + Some("--output") + } else if global.output_file.is_some() { + Some("--output-file") + } else { + None + }; + match (self.format, result_flag) { + (Some(_), Some(flag)) => Err(Error::usage(format!( + "--format cannot be combined with {flag}" + )) + .with_hint("Redirect stdout to save the lines, as in `voltage context --format env > scope.env`.")), + _ => Ok(()), + } + } +} + +/// Another tool's format for `voltage context`. +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum ContextFormat { + /// `NAME=VALUE` lines, the names the `voltage-api` skill's helper reads + Env, +} + #[derive(Debug, Args)] pub struct PriceFlags { /// Price at this UTC time, such as 2026-09-18T17:30:00Z, instead of now @@ -754,6 +793,9 @@ impl Invocation { } Err(error) => return Err(Error::usage(error.to_string())), }; + if let Command::Local(LocalCommand::Context(flags)) = &command { + flags.check(&global)?; + } Ok(Self { global, command }) } } @@ -1350,7 +1392,9 @@ fn local_examples(path: &[&str]) -> Option<&'static str> { } ["context"] => { " # Show what a command with this profile would use, and where each value came from - voltage context --profile prod" + voltage context --profile prod + # Hand the profile's organization, environment, and wallet to another tool as dotenv lines + voltage context --profile prod --format env > scope.env" } ["convert"] => { " # What $10 buys in bitcoin now, and 21,000 sats in dollars on a past date @@ -1449,6 +1493,45 @@ mod tests { } } + #[test] + fn context_format_refuses_a_result_format_or_file_before_or_after_the_command() { + for (args, flag) in [ + ( + &["voltage", "--json", "context", "--format", "env"][..], + "--json", + ), + ( + &["voltage", "context", "--format", "env", "--json"], + "--json", + ), + ( + &["voltage", "-o", "table", "context", "--format", "env"], + "--output", + ), + ( + &[ + "voltage", + "context", + "--format", + "env", + "--output-file", + "scope.env", + ], + "--output-file", + ), + ] { + let error = Invocation::try_parse_from(args).err().unwrap(); + assert_eq!(error.kind, Kind::Usage, "{args:?}"); + assert_eq!( + error.message, + format!("--format cannot be combined with {flag}"), + "{args:?}" + ); + } + assert!(Invocation::try_parse_from(["voltage", "context", "--json"]).is_ok()); + assert!(Invocation::try_parse_from(["voltage", "context", "--format", "shell"]).is_err()); + } + #[test] fn command_tree_is_valid() { command().debug_assert(); @@ -1947,7 +2030,15 @@ mod tests { Invocation::try_parse_from(["voltage", "context"]) .unwrap() .command, - Command::Local(LocalCommand::Context) + Command::Local(LocalCommand::Context(ContextFlags { format: None })) + )); + assert!(matches!( + Invocation::try_parse_from(["voltage", "context", "--format", "env"]) + .unwrap() + .command, + Command::Local(LocalCommand::Context(ContextFlags { + format: Some(ContextFormat::Env) + })) )); assert!(matches!( Invocation::try_parse_from(["voltage", "price"]) diff --git a/src/context.rs b/src/context.rs index d8244db..fc02e29 100644 --- a/src/context.rs +++ b/src/context.rs @@ -5,7 +5,7 @@ //! makes a network request, or creates the configuration directory. use crate::{ - Result, + Error, Result, auth::{self, Rejected, Selected}, cli::GlobalFlags, config::{ @@ -249,6 +249,35 @@ pub fn inspect(settings: &Settings, global: &GlobalFlags) -> Result { }) } +impl Context { + /// The organization, environment, and wallet as dotenv lines, named as the `voltage-api` + /// skill's helper reads them, for `voltage context --format env`. An unset value is left + /// out, because the helper treats an empty variable as an override. A credential is never + /// printed: inspection reads no secret. A tool takes one environment, so more than one is + /// refused rather than choosing. + pub fn dotenv(&self) -> Result { + let environment = match self.environment_ids.value.as_slice() { + [] => None, + [environment] => Some(*environment), + environments => { + return Err(Error::usage(format!( + "--format env takes one environment, but {} are selected", + environments.len() + )) + .with_hint("Select one with --env ENV_ID.")); + } + }; + Ok([ + ("VOLTAGE_ORGANIZATION_ID", self.organization_id.value), + ("VOLTAGE_ENVIRONMENT_ID", environment), + ("VOLTAGE_WALLET_ID", self.wallet_id.value), + ] + .into_iter() + .filter_map(|(name, value)| value.map(|id| format!("{name}={id}\n"))) + .collect()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/pick.rs b/src/pick.rs index 2d1efe3..6262f9d 100644 --- a/src/pick.rs +++ b/src/pick.rs @@ -71,7 +71,7 @@ fn needs(command: &Command) -> Needs { command: ProfileCommand::List | ProfileCommand::Get { .. } | ProfileCommand::Delete { .. }, } - | LocalCommand::Context + | LocalCommand::Context(_) | LocalCommand::Price(_) | LocalCommand::Convert(_) | LocalCommand::Completions { .. }, diff --git a/src/startup.rs b/src/startup.rs index 8ba36b3..216aa8a 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -5,8 +5,8 @@ use crate::{ api::{self, Execution, SubmissionState}, auth::{self, Discovery, Resolver}, cli::{ - self, AuthCommand, Command, ConvertFlags, GlobalFlags, Invocation, LocalCommand, - ParseFailure, PriceFlags, ProfileCommand, + self, AuthCommand, Command, ContextFlags, ContextFormat, ConvertFlags, GlobalFlags, + Invocation, LocalCommand, ParseFailure, PriceFlags, ProfileCommand, }, config::{self, Precedence, Profile, Scope, Settings}, context, @@ -150,6 +150,14 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result let mut out = output(!execution.describes())?; api::execute(&api, execution, &global, resolver, &session.scope, &mut out).await } + // Dotenv lines for another tool, not a result envelope. + Command::Local(LocalCommand::Context(ContextFlags { + format: Some(ContextFormat::Env), + })) => write_stdout( + context::inspect(&session.settings, &global)? + .dotenv()? + .as_bytes(), + ), Command::Local(local) => { let mut out = output(true)?; let envelope = session.local(local).await?; @@ -273,8 +281,10 @@ impl<'a> Session<'a> { .discover(scope, Discovery::Environments) .await?, ), - LocalCommand::Context => Envelope::settings(context::inspect(&self.settings, global)?) - .map(|context| context.titled("voltage context")), + LocalCommand::Context(_) => { + Envelope::settings(context::inspect(&self.settings, global)?) + .map(|context| context.titled("voltage context")) + } LocalCommand::Profiles { command } => { profiles(command, &mut self.settings, scope, global).await } @@ -438,7 +448,7 @@ mod tests { // Any other command comes back unchanged, so `execute` can go on with it. assert!(matches!( PriceQuery::try_from(command(&["voltage", "context"])), - Err(Command::Local(LocalCommand::Context)) + Err(Command::Local(LocalCommand::Context(_))) )); assert!(matches!( PriceQuery::try_from(command(&["voltage", "wallets", "list"])), diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index f78db8a..c50cae5 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -1059,6 +1059,51 @@ async fn profiles_are_created_from_scope_and_listed_without_ambient_state() { assert!(reloaded.config.accounts.contains_key(LOGIN_NAME)); } +#[tokio::test] +async fn context_env_format_prints_only_set_scope_and_never_a_credential() { + let (server, dir) = fixture().await; + let mut settings = seeded_api_key(dir.path(), "stage", "stage-key"); + settings.config.profiles.insert( + "stage".into(), + Profile { + organization_id: uuid(ORG), + environment_id: uuid(ENV), + account: "stage".into(), + }, + ); + settings.save().unwrap(); + let env_format = |args: &[&str]| { + Command::from_std(human_process_at(dir.path(), &server.uri())) + .args(["context", "--format", "env"]) + .args(args) + .assert() + }; + + let profiled = env_format(&["--profile", "stage", "--wallet", WALLET]).success(); + assert_eq!( + stdout(&profiled), + format!( + "VOLTAGE_ORGANIZATION_ID={ORG}\nVOLTAGE_ENVIRONMENT_ID={ENV}\nVOLTAGE_WALLET_ID={WALLET}\n" + ) + ); + // Neither the profile's saved key nor the ambient key is printed. + assert!(!stdout(&profiled).contains("stage-key")); + assert!(!stdout(&profiled).contains(ACCOUNT_KEY)); + + // An unset value is left out rather than printed empty. + let partial = env_format(&["--org", ORG]).success(); + assert_eq!(stdout(&partial), format!("VOLTAGE_ORGANIZATION_ID={ORG}\n")); + assert_eq!(stdout(&env_format(&[]).success()), ""); + + let two = env_format(&["--org", ORG, "--env", ENV, "--env", RESOURCE]).code(2); + assert_eq!(stdout(&two), ""); + assert!( + stderr(&two).contains("--format env takes one environment, but 2 are selected"), + "{}", + stderr(&two) + ); +} + #[tokio::test] async fn context_reports_effective_scope_and_sources_without_reading_secrets() { let (server, dir) = fixture().await;