diff --git a/README.md b/README.md index acbd6b2..b405b25 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,22 @@ $ envelope import dev .env Export variables to your shell: ```console +# sh / bash / zsh $ export $(envelope list dev) +# or, if the above fails: +$ eval "$(envelope list dev --shell sh)" + +# fish +$ envelope list dev --shell fish | source + +# nu +$ envelope list dev --shell nu | from nuon | load-env + +# cmd +> for /f "delims=" %i in ('envelope list example --shell cmd') do @%i + +# powershell +PS> envelope list dev --shell powershell | Invoke-Expression ``` Verify which environment is active: @@ -165,6 +180,18 @@ SECRET_KEY=mysecretkey123 SMTP_HOST=smtp.example.com ``` +Generate shell-specific output with `--shell`: +```console + $ envelope list dev --shell kv # KEY=value + $ envelope list dev --shell sh # export KEY='value' + $ envelope list dev --shell fish # set -gx KEY 'value' + $ envelope list dev --shell nu # {"KEY": "value"} (nuon record) + > envelope list dev --shell cmd # set "KEY=value" +PS> envelope list dev --shell powershell # $env:KEY = "value" +``` + +Supported aliases: `bash` and `zsh` for `sh`, `nushell` for `nu`, and `pwsh` for `powershell`. + Pretty print with a table format: ```console $ envelope list dev --pretty-print diff --git a/man/envelope.1.md b/man/envelope.1.md index ad38ec4..602b176 100644 --- a/man/envelope.1.md +++ b/man/envelope.1.md @@ -51,7 +51,35 @@ Lists all environments. ```bash envelope list dev ``` -Lists all environment variables in the 'dev' environment. +Lists all environment variables in the 'dev' environment using the default `kv` format (`KEY=value`). + +```bash +envelope list dev --shell sh +``` +Lists all environment variables in the 'dev' environment as `export KEY='value'` commands compatible with POSIX-compliant shells (`sh`, `bash`, `zsh`). + +```bash +envelope list dev --shell fish +``` +Lists all environment variables in the 'dev' environment as `set -gx KEY 'value'` commands for Fish. + +```bash +envelope list dev --shell nu +``` +Lists all environment variables in the 'dev' environment as a Nushell record suitable for `load-env`. + +```bash +envelope list dev --shell cmd +``` +Lists all environment variables in the 'dev' environment as `set "KEY=value"` commands for Windows Command Prompt. + +```bash +envelope list dev --shell powershell +``` +Lists all environment variables in the 'dev' environment as `$env:KEY = "value"` commands for PowerShell. + +`--shell` supports: `kv`, `sh`, `fish`, `nu`, `cmd`, `powershell`. +Aliases: `bash`/`zsh` -> `sh`, `nushell` -> `nu`, `pwsh` -> `powershell`. ```bash envelope duplicate dev dev-local diff --git a/src/command/client/list.rs b/src/command/client/list.rs index 80a0bac..5cc6fd8 100644 --- a/src/command/client/list.rs +++ b/src/command/client/list.rs @@ -34,6 +34,34 @@ impl Sort { } } +/// Valid shell output formats for raw key/value listing. +#[derive(Debug, Clone, clap::ValueEnum)] +enum Shell { + Kv, + #[clap(alias = "bash")] + #[clap(alias = "zsh")] + Sh, + Fish, + #[clap(alias = "nushell")] + Nu, + Cmd, + #[clap(alias = "pwsh")] + Powershell, +} + +impl Shell { + fn to_output_format(&self) -> ops::RawOutputFormat { + match self { + Self::Kv => ops::RawOutputFormat::Kv, + Self::Sh => ops::RawOutputFormat::Sh, + Self::Fish => ops::RawOutputFormat::Fish, + Self::Nu => ops::RawOutputFormat::Nu, + Self::Cmd => ops::RawOutputFormat::Cmd, + Self::Powershell => ops::RawOutputFormat::PowerShell, + } + } +} + /// List saved environments and/or their variables #[derive(Parser)] pub struct Cmd { @@ -44,6 +72,10 @@ pub struct Cmd { #[arg(long, short)] pretty_print: bool, + /// Output variables for the specified shell/format (default: kv). + #[arg(long, requires = "env", conflicts_with = "pretty_print")] + shell: Option, + #[arg(long, short)] truncate: bool, @@ -58,7 +90,20 @@ impl Cmd { None => ops::list_envs(&mut std::io::stdout(), db).await?, Some(env) => { if !self.pretty_print { - ops::list_raw(&mut std::io::stdout(), db, env, self.sort.to_str()).await?; + let output_format = self + .shell + .as_ref() + .map(Shell::to_output_format) + .unwrap_or(ops::RawOutputFormat::Kv); + + ops::list_raw( + &mut std::io::stdout(), + db, + env, + self.sort.to_str(), + output_format, + ) + .await?; } else { let truncate = match self.truncate { true => db::Truncate::Max(60), @@ -72,3 +117,45 @@ impl Cmd { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_pwsh_alias_for_shell_flag() { + let cmd = ::try_parse_from(["list", "dev", "--shell", "pwsh"]) + .expect("--shell pwsh should parse"); + + assert_eq!( + cmd.shell + .as_ref() + .expect("shell should be set") + .to_output_format(), + ops::RawOutputFormat::PowerShell + ); + } + + #[test] + fn parses_bash_alias_for_shell_flag() { + let cmd = ::try_parse_from(["list", "dev", "--shell", "bash"]) + .expect("--shell bash should parse"); + + assert_eq!( + cmd.shell + .as_ref() + .expect("shell should be set") + .to_output_format(), + ops::RawOutputFormat::Sh + ); + } + + #[test] + fn shell_flag_requires_environment_argument() { + let err = ::try_parse_from(["list", "--shell", "sh"]) + .err() + .expect("--shell without env should fail"); + + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } +} diff --git a/src/ops/list.rs b/src/ops/list.rs index 1d90c59..641d8cf 100644 --- a/src/ops/list.rs +++ b/src/ops/list.rs @@ -7,6 +7,12 @@ use prettytable::{Table, row}; use crate::db::model::{Environment, EnvironmentRow}; use crate::db::{EnvelopeDb, Truncate}; +mod format; + +pub use format::RawOutputFormat; + +use self::format::{write_nu_record, write_raw_entry}; + pub async fn print_from_stdin() -> Result<()> { let mut table = Table::new(); table.add_row(row!["VARIABLE", "VALUE"]); @@ -65,6 +71,7 @@ pub async fn list_raw( db: &EnvelopeDb, env: &str, sort: &str, + output_format: RawOutputFormat, ) -> Result<()> { ensure!( db.env_exists(env).await?, @@ -72,8 +79,13 @@ pub async fn list_raw( ); let envs: Vec = db.list_kv_in_env_alt(env, Truncate::None, sort).await?; + + if output_format == RawOutputFormat::Nu { + return write_nu_record(writer, &envs); + } + for env in envs { - writeln!(writer, "{}={}", &env.key, &env.value)?; + write_raw_entry(writer, &env.key, &env.value, output_format)?; } Ok(()) diff --git a/src/ops/list/format.rs b/src/ops/list/format.rs new file mode 100644 index 0000000..adc77b8 --- /dev/null +++ b/src/ops/list/format.rs @@ -0,0 +1,267 @@ +use std::io::Write; + +use anyhow::Result; + +use crate::db::model::EnvironmentRow; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RawOutputFormat { + Kv, + Sh, + Fish, + Nu, + Cmd, + PowerShell, +} + +fn quote_sh_value(value: &str) -> String { + let mut quoted = String::with_capacity(value.len() + 2); + quoted.push('\''); + + for ch in value.chars() { + if ch == '\'' { + quoted.push_str("'\"'\"'"); + } else { + quoted.push(ch); + } + } + + quoted.push('\''); + quoted +} + +fn quote_fish_value(value: &str) -> String { + let mut quoted = String::with_capacity(value.len() + 2); + quoted.push('\''); + + for ch in value.chars() { + match ch { + '\\' => quoted.push_str("\\\\"), + '\'' => quoted.push_str("\\'"), + _ => quoted.push(ch), + } + } + + quoted.push('\''); + quoted +} + +fn escape_cmd_value(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '^' => escaped.push_str("^^"), + '"' => escaped.push_str("^\""), + _ => escaped.push(ch), + } + } + + escaped +} + +fn escape_nu_value(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '\\' => escaped.push_str("\\\\"), + '"' => escaped.push_str("\\\""), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + _ => escaped.push(ch), + } + } + + escaped +} + +fn escape_powershell_value(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '`' => escaped.push_str("``"), + '"' => escaped.push_str("`\""), + '$' => escaped.push_str("`$"), + _ => escaped.push(ch), + } + } + + escaped +} + +pub fn write_nu_record(writer: &mut W, envs: &[EnvironmentRow]) -> Result<()> { + write!(writer, "{{")?; + + for (idx, env) in envs.iter().enumerate() { + if idx > 0 { + write!(writer, ", ")?; + } + + write!( + writer, + "\"{}\": \"{}\"", + escape_nu_value(&env.key), + escape_nu_value(&env.value) + )?; + } + + writeln!(writer, "}}")?; + + Ok(()) +} + +pub fn write_raw_entry( + writer: &mut W, + key: &str, + value: &str, + output_format: RawOutputFormat, +) -> Result<()> { + match output_format { + RawOutputFormat::Kv => writeln!(writer, "{}={}", key, value)?, + RawOutputFormat::Sh => writeln!(writer, "export {}={}", key, quote_sh_value(value))?, + RawOutputFormat::Fish => writeln!(writer, "set -gx {} {}", key, quote_fish_value(value))?, + RawOutputFormat::Nu => unreachable!("nu output is rendered as a single record"), + RawOutputFormat::Cmd => writeln!(writer, "set \"{}={}\"", key, escape_cmd_value(value))?, + RawOutputFormat::PowerShell => writeln!( + writer, + "$env:{} = \"{}\"", + key, + escape_powershell_value(value) + )?, + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn render_entry(output_format: RawOutputFormat, key: &str, value: &str) -> String { + let mut output = Vec::new(); + write_raw_entry(&mut output, key, value, output_format).unwrap(); + String::from_utf8(output).unwrap() + } + + fn render_nu_record(envs: &[EnvironmentRow]) -> String { + let mut output = Vec::new(); + write_nu_record(&mut output, envs).unwrap(); + String::from_utf8(output).unwrap() + } + + #[test] + fn test_write_raw_entry_kv_output_cases() { + let cases = [ + ("KEY", "plain", "KEY=plain\n"), + ( + "KEY", + "value with spaces and 'quote' and $HOME", + "KEY=value with spaces and 'quote' and $HOME\n", + ), + ("KEY", "", "KEY=\n"), + ]; + + for (key, value, expected) in cases { + assert_eq!(expected, render_entry(RawOutputFormat::Kv, key, value)); + } + } + + #[test] + fn test_write_raw_entry_sh_output_cases() { + let cases = [ + ("KEY", "plain", "export KEY='plain'\n"), + ( + "KEY", + "value with 'single' and $HOME", + "export KEY='value with '\"'\"'single'\"'\"' and $HOME'\n", + ), + ("KEY", "", "export KEY=''\n"), + ]; + + for (key, value, expected) in cases { + assert_eq!(expected, render_entry(RawOutputFormat::Sh, key, value)); + } + } + + #[test] + fn test_write_raw_entry_fish_output_cases() { + let cases = [ + ("KEY", "plain", "set -gx KEY 'plain'\n"), + ( + "KEY", + "path\\to\\it's", + "set -gx KEY 'path\\\\to\\\\it\\'s'\n", + ), + ("KEY", "", "set -gx KEY ''\n"), + ]; + + for (key, value, expected) in cases { + assert_eq!(expected, render_entry(RawOutputFormat::Fish, key, value)); + } + } + + #[test] + fn test_write_raw_entry_cmd_output_cases() { + let cases = [ + ("KEY", "plain", "set \"KEY=plain\"\n"), + ("KEY", "a^b\"c%PATH%d", "set \"KEY=a^^b^\"c%PATH%d\"\n"), + ("KEY", "", "set \"KEY=\"\n"), + ]; + + for (key, value, expected) in cases { + assert_eq!(expected, render_entry(RawOutputFormat::Cmd, key, value)); + } + } + + #[test] + fn test_write_raw_entry_powershell_output_cases() { + let cases = [ + ("KEY", "plain", "$env:KEY = \"plain\"\n"), + ( + "KEY", + "value \"$HOME\" and `tick`", + "$env:KEY = \"value `\"`$HOME`\" and ``tick``\"\n", + ), + ("KEY", "", "$env:KEY = \"\"\n"), + ]; + + for (key, value, expected) in cases { + assert_eq!( + expected, + render_entry(RawOutputFormat::PowerShell, key, value) + ); + } + } + + #[test] + #[should_panic(expected = "nu output is rendered as a single record")] + fn test_write_raw_entry_nu_panics() { + let _ = render_entry(RawOutputFormat::Nu, "KEY", "value"); + } + + #[test] + fn test_write_nu_record_output_plain_case() { + let envs = vec![ + EnvironmentRow::from("dev", "API_KEY", "value1"), + EnvironmentRow::from("dev", "DATABASE_URL", "postgres://localhost:5432/db"), + ]; + + assert_eq!( + "{\"API_KEY\": \"value1\", \"DATABASE_URL\": \"postgres://localhost:5432/db\"}\n", + render_nu_record(&envs) + ); + } + + #[test] + fn test_write_nu_record_output_escaped_case() { + let envs = vec![ + EnvironmentRow::from("dev", "K\"EY", "line1\nline2\t\"x\"\\y"), + EnvironmentRow::from("dev", "NORMAL", "plain"), + ]; + + assert_eq!( + "{\"K\\\"EY\": \"line1\\nline2\\t\\\"x\\\"\\\\y\", \"NORMAL\": \"plain\"}\n", + render_nu_record(&envs) + ); + } +}