From 793aa1fc9e4d8f1a1f174e9efba3d1248d3b7d04 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Sun, 27 Sep 2026 00:33:01 -0700 Subject: [PATCH 1/2] feat: interactive envx send and KEY=VALUE input detection Bare `envx send` in a terminal picks a trusted friend, asks whether to type a hidden secret or send a file, and offers an expiry. Piped or file input that is entirely conventional KEY=VALUE lines is sent as variables (noted on stderr); --text forces plain text and --env still forces variables. Co-Authored-By: Claude Opus 5.5 --- src/commands/friends.rs | 1 + src/commands/send.rs | 198 ++++++++++++++++++++++++++++++++++++---- tests/social_pty.py | 40 +++++++- 3 files changed, 217 insertions(+), 22 deletions(-) diff --git a/src/commands/friends.rs b/src/commands/friends.rs index 893f4ad..4320718 100644 --- a/src/commands/friends.rs +++ b/src/commands/friends.rs @@ -175,6 +175,7 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> { file: None, stdin: false, env: false, + text: false, expires: None, json: false, retry: None, diff --git a/src/commands/send.rs b/src/commands/send.rs index 7af9c85..3627305 100644 --- a/src/commands/send.rs +++ b/src/commands/send.rs @@ -1,7 +1,7 @@ use super::*; use crate::utils::{ config::Config, - messaging::{self, Client, Message}, + messaging::{self, Client, Friend, Message}, messaging_crypto::{self as crypto, Envelope, Payload}, }; use reqwest::Method; @@ -13,23 +13,33 @@ use std::{ }; /// Send a signed, encrypted secret to a friend; values never go in arguments +/// +/// Run without arguments in a terminal to pick a friend and secret +/// interactively. Piped input is read automatically; KEY=VALUE lines are +/// sent as variables unless --text is given. #[derive(Parser, Debug)] pub struct Args { - #[arg(required_unless_present = "retry")] + /// Friend UUID or local alias (prompted for when omitted in a terminal) pub friend: Option, + /// Read the secret from a file #[arg(long, conflicts_with = "stdin")] pub file: Option, + /// Read the secret from stdin even when it is a terminal #[arg(long)] pub stdin: bool, /// Parse input as KEY=VALUE lines instead of plain text - #[arg(long)] + #[arg(long, conflicts_with = "text")] pub env: bool, + /// Send input as plain text even if it looks like KEY=VALUE lines + #[arg(long)] + pub text: bool, + /// Expire the message after a duration such as 30m, 24h, or 7d #[arg(long)] pub expires: Option, #[arg(long)] pub json: bool, /// Retry a previously prepared send without creating a duplicate - #[arg(long, conflicts_with_all = ["friend", "file", "stdin", "env", "expires"])] + #[arg(long, conflicts_with_all = ["friend", "file", "stdin", "env", "text", "expires"])] pub retry: Option, } pub async fn command(args: Args, config: &mut Config) -> Result<()> { @@ -69,23 +79,48 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> { crate::utils::user_display::UserDisplay::from_state(&client.state)?; (id, body, names.name(&friend.user.id, &friend.user.username)) } else { - let target = args - .friend - .context("Specify a friend's UUID or local alias")?; - let friend = client.resolve(&target).await?; + // Wizard only when nothing was specified and a human can answer. + let wizard = args.friend.is_none() + && args.file.is_none() + && !args.stdin + && crate::utils::prompt::is_interactive(); + let friend = match args.friend { + Some(target) => client.resolve(&target).await?, + None if wizard => select_friend(&client).await?, + None => anyhow::bail!( + "Specify a friend's UUID or local alias, or run `envx send` in a terminal to choose one" + ), + }; let pin = client.trusted(&friend.user)?; - let text = input(args.file, args.stdin)?; + let (file, expires) = if wizard { + let file = prompt_source()?; + let expires = match args.expires { + Some(expires) => Some(expires), + None => prompt_expiry()?, + }; + (file, expires) + } else { + (args.file, args.expires) + }; + // Validate expiry before asking for the secret. + let expires_at = + expires.as_deref().map(messaging::expires).transpose()?; + let (text, typed) = input(file, args.stdin)?; let payload = if args.env { Payload::Variables(parse_variables(&text)?) + } else if args.text || typed { + Payload::Text(text) + } else if let Some(variables) = detect_variables(&text) { + eprintln!( + "Detected {} KEY=VALUE variable{}; pass --text to send as plain text.", + variables.len(), + if variables.len() == 1 { "" } else { "s" } + ); + Payload::Variables(variables) } else { Payload::Text(text) }; let id = uuid::Uuid::new_v4().to_string(); - let expires_at = args - .expires - .as_deref() - .map(messaging::expires) - .transpose()?; let envelope = Envelope { version: 1, server: client.origin.clone(), @@ -134,7 +169,90 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> { } Ok(()) } -fn input(file: Option, stdin: bool) -> Result { +/// Presentation wrapper so the picker shows names while returning the friend. +struct Choice(String, T); +impl std::fmt::Display for Choice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +async fn select_friend(client: &Client) -> Result { + let names = + crate::utils::user_display::UserDisplay::from_state(&client.state)?; + let mut choices = Vec::new(); + for friend in client.friends().await? { + client.learn_from_receipt(&friend)?; + // Only offer friends whose current key matches the local pin. + if client.trusted(&friend.user).is_ok() { + choices.push(Choice( + names.row(&friend.user.id, &friend.user.username, false), + friend, + )); + } + } + if choices.is_empty() { + anyhow::bail!( + "No trusted friends to send to. Share an `envx friend-link` code, or verify a friend with `envx friends --accept-key`." + ); + } + Ok(inquire::Select::new("Send to:", choices) + .with_render_config(crate::utils::prompt::get_render_config()) + .prompt() + .context("Failed to choose a friend")? + .1) +} + +/// None means type the secret at a hidden prompt. +fn prompt_source() -> Result> { + let choices = vec![ + Choice("Type a secret (hidden)".into(), false), + Choice( + "Send a file (KEY=VALUE files become variables)".into(), + true, + ), + ]; + let from_file = inquire::Select::new("What to send:", choices) + .with_render_config(crate::utils::prompt::get_render_config()) + .prompt() + .context("Failed to choose what to send")? + .1; + if !from_file { + return Ok(None); + } + let path = inquire::Text::new("File path:") + .with_render_config(crate::utils::prompt::get_render_config()) + .with_validator(|path: &str| { + Ok(if std::path::Path::new(path.trim()).is_file() { + inquire::validator::Validation::Valid + } else { + inquire::validator::Validation::Invalid( + "No file at that path".into(), + ) + }) + }) + .prompt() + .context("Failed to read file path")?; + Ok(Some(PathBuf::from(path.trim()))) +} + +fn prompt_expiry() -> Result> { + let choices = vec![ + Choice("Never".into(), None), + Choice("1 hour".into(), Some("1h")), + Choice("24 hours".into(), Some("24h")), + Choice("7 days".into(), Some("7d")), + ]; + Ok(inquire::Select::new("Expires:", choices) + .with_render_config(crate::utils::prompt::get_render_config()) + .prompt() + .context("Failed to choose an expiry")? + .1 + .map(Into::into)) +} + +/// Returns the secret and whether it was typed at the hidden prompt. +fn input(file: Option, stdin: bool) -> Result<(String, bool)> { let mut bytes = Vec::new(); if let Some(path) = file { std::fs::File::open(path)? @@ -146,14 +264,15 @@ fn input(file: Option, stdin: bool) -> Result { .take(65537) .read_to_end(&mut bytes)?; } else { - return inquire::Password::new("Secret:") + let secret = inquire::Password::new("Secret:") .without_confirmation() .with_render_config(crate::utils::prompt::get_render_config()) .with_help_message( - "Hidden input. Use --stdin or --file for multiline secrets.", + "Hidden input. Pipe input or use --file for multiline secrets.", ) .prompt() - .context("Failed to read secret"); + .context("Failed to read secret")?; + return Ok((secret, true)); } if bytes.len() > 65536 { anyhow::bail!("Secret input exceeds 64 KiB"); @@ -161,7 +280,28 @@ fn input(file: Option, stdin: bool) -> Result { if bytes.is_empty() { anyhow::bail!("Secret input is empty"); } - String::from_utf8(bytes).context("Secret input must be UTF-8 text") + let text = + String::from_utf8(bytes).context("Secret input must be UTF-8 text")?; + Ok((text, false)) +} + +/// Conservative: every line must look like a conventional env assignment, +/// so a bare token such as base64 with `=` padding stays plain text. +fn detect_variables(text: &str) -> Option> { + let conventional = text.lines().all(|line| { + let line = line.trim_start(); + if line.trim().is_empty() || line.starts_with('#') { + return true; + } + line.split_once('=').is_some_and(|(name, value)| { + name.bytes().any(|b| b.is_ascii_uppercase()) + && name.bytes().all(|b| { + b == b'_' || b.is_ascii_uppercase() || b.is_ascii_digit() + }) + && !value.starts_with('=') + }) + }); + conventional.then(|| parse_variables(text).ok()).flatten() } pub fn parse_variables(text: &str) -> Result> { let mut variables = BTreeMap::new(); @@ -211,4 +351,24 @@ mod tests { assert!(!error.contains("private-value")); } } + + #[test] + fn detects_only_conventional_env_input_as_variables() { + let values = + detect_variables("# comment\nTOKEN=a=b\n\nAPI_KEY_2=\n").unwrap(); + assert_eq!(values["TOKEN"], "a=b"); + assert_eq!(values["API_KEY_2"], ""); + for text in [ + "hunter2", + "QUJDRA==", + "password=secret", + "TOKEN=a\nplain line", + "export TOKEN=a", + "A=1\nA=2", + "_=1", + "", + ] { + assert!(detect_variables(text).is_none(), "{text:?}"); + } + } } diff --git a/tests/social_pty.py b/tests/social_pty.py index e5c19f1..7750626 100644 --- a/tests/social_pty.py +++ b/tests/social_pty.py @@ -124,9 +124,43 @@ def finish(self): terminal.send(b'\r') assert terminal.finish() == 0 +# Bare `envx send` walks through friend, source, expiry, then hidden entry. terminal = Terminal('alice', 'send') -assert terminal.finish() != 0 -assert b"FRIEND" in terminal.output and b"Usage:" in terminal.output +terminal.until('Send to:') +assert b'local-bob' in terminal.output.split(b'Send to:', 1)[1], 'Picker should show the local alias' +terminal.send(b'\r') +terminal.until('What to send:') +terminal.send(b'\r') +terminal.until('Expires:') +terminal.send(b'\x1b[B\x1b[B\r') +terminal.until('Secret:') +wizard_secret = 'synthetic-wizard-pty-secret-3319' +terminal.send(wizard_secret.encode()+b'\r') +assert terminal.finish() == 0, terminal.output.decode(errors='replace') +assert wizard_secret.encode() not in terminal.output, 'Wizard secret echoed' +latest = doc('bob', 'inbox', '--json') +message = next(item for item in latest if item['id'] not in {x['id'] for x in messages}) +assert message['expires_at'], 'Wizard expiry choice was not applied' +assert run('bob', 'read', message['id']) == wizard_secret + +# The wizard can send a file; KEY=VALUE content is detected as variables. +env_file = root / 'alice' / 'wizard.env' +env_file.write_text('WIZARD_TOKEN=synthetic-wizard-value\n') +terminal = Terminal('alice', 'send') +terminal.until('Send to:') +terminal.send(b'\r') +terminal.until('What to send:') +terminal.send(b'\x1b[B\r') +terminal.until('File path:') +terminal.send(str(env_file).encode()+b'\r') +terminal.until('Expires:') +terminal.send(b'\r') +assert terminal.finish() == 0, terminal.output.decode(errors='replace') +assert b'Detected 1 KEY=VALUE variable' in terminal.output +assert b'synthetic-wizard-value' not in terminal.output +newest = doc('bob', 'inbox', '--json') +message = next(item for item in newest if item['id'] not in {x['id'] for x in latest}) +assert 'WIZARD_TOKEN' in run('bob', 'read', message['id']) assert secret.encode() not in run('bob', 'inbox').encode() -print('PASS: PTY hidden entry and cancel, friend menu, missing-argument guidance, metadata-only inbox') +print('PASS: PTY hidden entry and cancel, friend menu, send wizard (typed and file), metadata-only inbox') print('FIXTURE_ROOT='+str(root)) From b4ca12f9629ac3ffdec40c9da3fed3e7b1cdb127 Mon Sep 17 00:00:00 2001 From: Alexander Ng Date: Sun, 27 Sep 2026 00:33:01 -0700 Subject: [PATCH 2/2] feat: default to KEY=VALUE output instead of tables `envx variables` now prints KEY=VALUE lines by default; --table opts into the box table and --kv is kept as a hidden no-op. `envx read` prints variable messages as KEY=VALUE lines instead of JSON, and `envx get config` prints key=value lines. Co-Authored-By: Claude Opus 5.5 --- docs/desired-ux.md | 4 ++-- src/commands/get/config.rs | 28 +++++++++------------------- src/commands/read.rs | 8 +++++--- src/commands/variables.rs | 12 ++++++++---- tests/social_e2e.py | 11 +++++++++++ 5 files changed, 35 insertions(+), 28 deletions(-) diff --git a/docs/desired-ux.md b/docs/desired-ux.md index 304bc6d..39ad056 100644 --- a/docs/desired-ux.md +++ b/docs/desired-ux.md @@ -57,8 +57,8 @@ Three things make this feel good: ### `envx variables` -- Prints a nice unicode box-drawing table by default. -- Has `--kv` (KEY=VALUE lines), `--json`, `--filter `, `--all`. +- Prints KEY=VALUE lines by default, so output can be redirected to a `.env` file. +- Has `--table` (unicode box-drawing table), `--json`, `--filter `, `--all`. `--kv` is a hidden no-op kept for existing scripts. - This one is already polished. ### `envx shell` diff --git a/src/commands/get/config.rs b/src/commands/get/config.rs index 90d24a8..468c6ce 100644 --- a/src/commands/get/config.rs +++ b/src/commands/get/config.rs @@ -1,11 +1,9 @@ use super::*; use crate::utils::config::Config; -use crate::utils::table::Table; use anyhow::Context; use anyhow::Result; -use std::collections::BTreeMap; -/// Get the configuration either as a table or as a JSON output +/// Get the configuration as KEY=VALUE lines or as JSON #[derive(Parser)] pub struct Args { /// Show only the primary key @@ -31,14 +29,10 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> { return Ok(()); } - let mut map = BTreeMap::new(); if let Some(key) = key { - map.insert( - key.fingerprint.chars().skip(30).collect(), - key.primary_user_id.clone(), - ); + println!("fingerprint={}", key.fingerprint); + println!("primary_user_id={}", key.primary_user_id); } - Table::new("Fingerprint | Key ID".into(), map).print()?; return Ok(()); } @@ -57,16 +51,12 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> { return Ok(()); }; - Table::new( - "Configuration".into(), - display - .as_object() - .context("Invalid config shape")? - .iter() - .map(|(k, v)| (k.clone(), v.to_string())) - .collect(), - ) - .print()?; + for (k, v) in display.as_object().context("Invalid config shape")? { + match v { + serde_json::Value::String(v) => println!("{k}={v}"), + v => println!("{k}={v}"), + } + } Ok(()) } diff --git a/src/commands/read.rs b/src/commands/read.rs index 01bdb75..c6d7e6a 100644 --- a/src/commands/read.rs +++ b/src/commands/read.rs @@ -25,9 +25,11 @@ pub async fn command(args: Args, config: &mut Config) -> Result<()> { } else { match envelope.payload { Payload::Text(text) => text.into_bytes(), - Payload::Variables(variables) => { - serde_json::to_vec_pretty(&variables)? - } + Payload::Variables(variables) => variables + .iter() + .map(|(name, value)| format!("{name}={value}\n")) + .collect::() + .into_bytes(), } }; if let Some(path) = args.output { diff --git a/src/commands/variables.rs b/src/commands/variables.rs index c7ee5a3..1d1b67d 100644 --- a/src/commands/variables.rs +++ b/src/commands/variables.rs @@ -18,8 +18,12 @@ pub struct Args { #[arg(long)] json: bool, - /// Output as a list of key=value pairs + /// Output as a table instead of KEY=VALUE lines #[arg(long)] + table: bool, + + /// KEY=VALUE lines; the default, kept for existing scripts + #[arg(long, hide = true, conflicts_with = "table")] kv: bool, /// Output all variables (this project only) @@ -83,10 +87,10 @@ impl Mode { fn from_args(args: &Args) -> Self { if args.json { Self::Json - } else if args.kv { - Self::KV - } else { + } else if args.table { Self::Table + } else { + Self::KV } } } diff --git a/tests/social_e2e.py b/tests/social_e2e.py index 6b796bb..dc150f7 100644 --- a/tests/social_e2e.py +++ b/tests/social_e2e.py @@ -108,6 +108,17 @@ def config(user): assert result['applied'] is True values = doc('bob', 'variables', '--project-id', project_id, '--all', '--json') assert values == {'TOKEN': 'new-value', 'KEEP': 'untouched', 'NEW': 'added'} +# Piped input needs no --stdin; conventional KEY=VALUE lines become variables. +detected = run('alice', 'send', 'bob', '--json', data='AUTO_TOKEN=auto-value\n') +assert 'Detected 1 KEY=VALUE variable' in detected.stderr and 'auto-value' not in detected.stderr +assert doc('bob', 'read', json.loads(detected.stdout)['id'], '--json')['payload'] == {'kind': 'variables', 'value': {'AUTO_TOKEN': 'auto-value'}} +assert run('bob', 'read', json.loads(detected.stdout)['id']).stdout == 'AUTO_TOKEN=auto-value\n' +forced = doc('alice', 'send', 'bob', '--text', '--json', data='AUTO_TOKEN=auto-value\n') +assert run('bob', 'read', forced['id']).stdout == 'AUTO_TOKEN=auto-value\n' +padded = doc('alice', 'send', 'bob', '--json', data='QUJDRA==') +assert run('bob', 'read', padded['id']).stdout == 'QUJDRA==' +missing = run('alice', 'send', '--json', data='never-sent', ok=False) +assert 'friend' in missing.stderr.lower() and 'never-sent' not in missing.stderr run('alice', 'friends', '--remove', 'bob', '--json') run('alice', 'send', 'bob', '--stdin', data='blocked', ok=False) assert doc('bob', 'read', variables['id'], '--json')['payload']['kind'] == 'variables'