From 05d5fd48bdd54aa9b4048fffc1c942399502b859 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:53:29 -0500 Subject: [PATCH] feat(cli): greet bare voltage and name next steps on a terminal voltage with no command, on a terminal, shows the wordmark, who is signed in (from the configuration, without a network request), the saved profile, organization, and wallet, and three commands to try, and exits 0. Off a terminal or with --json it stays a usage error with exit code 2. On a terminal, an empty wallets, payments, or webhooks list ends with a line naming the command that makes the first one, and profiles create names a command that uses the new profile. --- CHANGELOG.md | 1 + docs/getting-started.md | 2 + docs/reference/output.md | 4 + src/api.rs | 52 ++++++++- src/cli.rs | 54 ++++++++- src/greeting.rs | 231 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/output.rs | 44 +++++++- src/startup.rs | 40 ++++++- tests/cli_integration.rs | 129 +++++++++++++++++++++- 10 files changed, 544 insertions(+), 14 deletions(-) create mode 100644 src/greeting.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index de2c602..39995ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - `--qr` on a terminal draws the invoice black on white with a quiet zone, so it scans on dark themes, under a `Scan to pay` caption with the amount, memo, and expiry, and after the result so it stays on screen; `--wait completed` counts down to the expiry. The caption leads with the network, its dot in the website's color. - A payment that settles on a terminal closes with `⚡ Payment ID completed` instead of a check mark, printed after the payment's record so it stays on screen. - `voltage payments list --watch` follows payments until Ctrl-C: a live table that marks new and changed payments on a terminal, or one `snapshot`, `new`, or `updated` event per payment when piped or with `--json`. `--interval` sets the refresh (default 5 seconds). +- `voltage` with no command, on a terminal, greets you with the wordmark, who you are signed in as, your saved scope, and commands to try; off a terminal it is still a usage error. An empty wallet, payment, or webhook list, and a new profile, end with a `›` line naming the next command. ## 0.2.0 - 2026-10-22 diff --git a/docs/getting-started.md b/docs/getting-started.md index a62517c..72b4636 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -10,6 +10,8 @@ Install the CLI from the [install guide](guides/install.md), then sign in throug voltage login --account me ``` +Run `voltage` on its own at any time to see who you are signed in as, your saved scope, and a few commands to try. + ## 2. Save your scope as a profile Find your organization and environment, then save both with your login as a profile: diff --git a/docs/reference/output.md b/docs/reference/output.md index 895c0da..cb6698e 100644 --- a/docs/reference/output.md +++ b/docs/reference/output.md @@ -13,6 +13,10 @@ On a terminal, results are readable tables; when piped, they are JSON. `--json` - A single resource is shown as aligned `field value` lines, with nested fields joined by dots. `voltage context` shows each setting as `name value (source)`. - On a terminal, `voltage context` and `voltage auth status` draw those lines inside a titled card (rounded corners, or `+-|` without a UTF-8 locale). A card wider than the terminal falls back to the plain lines, and redirected output always gets them. +- With human output on a terminal, an unfiltered empty `wallets list`, `payments list`, or `webhooks list` ends with a `›` line naming the command that makes the first one, and `profiles create` ends with a command that uses the new profile (keeping `--config-dir`). The line comes after the result. `--quiet` hides it, and JSON or redirected output never has it. + +`voltage` with no command, on a terminal, greets you: the wordmark, who you are signed in as (read from the configuration, without a network request), your profile, organization, and wallet when set, and three commands to try; it exits 0. When commands would reject the credential, the greeting leads with that problem instead, such as `VOLTAGE_API_KEY is empty` or `Multiple credentials are saved`, with its next step first. Off a terminal, or with `--json`, it prints its usage and exits 2 as before. + The table layout may change between releases; scripts should use JSON. ## JSON diff --git a/src/api.rs b/src/api.rs index 148f22b..8e13c73 100644 --- a/src/api.rs +++ b/src/api.rs @@ -18,7 +18,7 @@ use crate::{ dry_run, error::{CredentialSources, ErrorDetail, ErrorKind, ResponseLimit}, input::{PaginationMode, Request}, - output::{Envelope, LiveScreen, Outcome, Output}, + output::{Envelope, LiveScreen, NextStep, Outcome, Output}, payment::{Currency, PaymentDirection, PaymentView, ReceiveKind, WaitProgress, WaitTarget}, price::{PRICE_URL, PriceService, UsdPerBtc}, qr, @@ -942,7 +942,21 @@ impl Call<'_> { if operation.id == OperationId::GetWallet && self.out.on_terminal() { self.lead_with_balance(&response.body).await?; } - self.write_pages(response, Outcome::Retrieved, follow).await + let empty = watch::items(&response.body).is_empty(); + self.write_pages(response, Outcome::Retrieved, follow) + .await?; + // Only an unfiltered list is empty because nothing was made yet. + let unfiltered = + self.invocation.filters.is_empty() && self.invocation.query.is_empty(); + if let Some((what, command)) = + empty_list_next_step(operation.id).filter(|_| empty && unfiltered) + { + self.out.suggest(&NextStep { + what, + command: command.into(), + }); + } + Ok(()) } } } @@ -1613,6 +1627,20 @@ impl Call<'_> { } } +/// What to run when a list a person reads is empty: how to make its first item. +fn empty_list_next_step(operation: OperationId) -> Option<(&'static str, &'static str)> { + match operation { + OperationId::GetAllOrganizationsWalletsAsUser => { + Some(("Create a wallet", "voltage wallets create --help")) + } + OperationId::GetPayments => Some(("Receive a payment", "voltage payments receive --help")), + OperationId::GetWebhooksAsUser => { + Some(("Create a webhook", "voltage webhooks create --help")) + } + _ => None, + } +} + /// The live watch's first line: what it is, how often it refreshes, how to stop, and why /// the last refresh failed, if it did. fn watch_header(interval: Duration, failure: Option<&Error>, terminal: Terminal) -> Line { @@ -1823,6 +1851,26 @@ fn next_page(body: &Value, request: &Request) -> Result mod tests { use super::*; + #[test] + fn empty_lists_suggest_a_command_that_exists() { + for operation in [ + OperationId::GetAllOrganizationsWalletsAsUser, + OperationId::GetPayments, + OperationId::GetWebhooksAsUser, + ] { + let (_, command) = empty_list_next_step(operation).unwrap(); + let error = crate::cli::command() + .try_get_matches_from(command.split(' ')) + .unwrap_err(); + assert_eq!( + error.kind(), + clap::error::ErrorKind::DisplayHelp, + "{command}" + ); + } + assert_eq!(empty_list_next_step(OperationId::GetWallet), None); + } + #[test] fn the_watch_header_says_how_to_stop_and_why_a_refresh_failed() { let terminal = Terminal::new(false, true); diff --git a/src/cli.rs b/src/cli.rs index 6b5422f..d3899d4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -22,7 +22,12 @@ use clap::{ error::ErrorKind, }; use clap_complete::Shell; -use std::{collections::BTreeSet, ffi::OsStr, path::PathBuf, time::Duration}; +use std::{ + collections::BTreeSet, + ffi::{OsStr, OsString}, + path::PathBuf, + time::Duration, +}; use uuid::Uuid; /// Friendly payment commands beside `create_payment`'s generated command. @@ -131,6 +136,28 @@ impl GlobalFlags { OutputFormat::select(self.json, self.output) } + /// The global flags of a command line that names no command, such as bare `voltage` or + /// `voltage --profile prod`; `None` when it names one or does not parse. + pub fn without_command(args: &[OsString]) -> Option { + let missing = command() + .try_get_matches_from(args) + .err() + .is_some_and(|error| { + matches!( + error.kind(), + ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + | ErrorKind::MissingSubcommand + ) + }); + if !missing { + return None; + } + // The same flags in front of `context`, the local command that reads only them. + let with_context = args.iter().cloned().chain([OsString::from("context")]); + let matches = command().try_get_matches_from(with_context).ok()?; + parse(leaf(&matches).1).ok() + } + /// `--color` or `--no-color`, else `FORCE_COLOR`, else automatic. pub fn color(&self) -> ColorChoice { if self.no_color { @@ -1618,6 +1645,31 @@ mod tests { assert!(api.body.is_none()); } + #[test] + fn global_flags_without_a_command_are_read_for_the_greeting() { + let args = |list: &[&str]| list.iter().map(OsString::from).collect::>(); + let bare = GlobalFlags::without_command(&args(&["voltage"])).unwrap(); + assert_eq!(bare.profile, None); + let profiled = + GlobalFlags::without_command(&args(&["voltage", "--profile", "prod"])).unwrap(); + assert_eq!(profiled.profile.as_deref(), Some("prod")); + assert!( + GlobalFlags::without_command(&args(&["voltage", "--json"])) + .unwrap() + .json + ); + for named in [ + &["voltage", "context"][..], + &["voltage", "--help"], + &["voltage", "--bogus"], + ] { + assert!( + GlobalFlags::without_command(&args(named)).is_none(), + "{named:?}" + ); + } + } + #[test] fn only_payments_list_watches_and_its_interval_needs_the_watch() { let watched = api(&["payments", "list", "--watch"]); diff --git a/src/greeting.rs b/src/greeting.rs new file mode 100644 index 0000000..ff6a1b6 --- /dev/null +++ b/src/greeting.rs @@ -0,0 +1,231 @@ +//! What bare `voltage` shows on a terminal: the wordmark, who is signed in, the saved scope, +//! and a few commands to start with. It reads only the configuration, never the network. Off +//! a terminal, bare `voltage` still prints its usage and fails, as scripts expect. + +use crate::{ + Error, Result, auth, + cli::GlobalFlags, + config::{self, Settings}, + context, + style::{Glyphs, Line, Role}, + wordmark, +}; +use serde_json::Value; +use std::{ffi::OsString, io::IsTerminal}; + +/// `voltage` with no command, perhaps with global flags such as `--profile`, on an interactive +/// stdout and stderr and without machine-readable output: those flags, for the greeting. +pub fn wanted(args: &[OsString]) -> Option { + if !std::io::stdout().is_terminal() || !std::io::stderr().is_terminal() { + return None; + } + GlobalFlags::without_command(args) + .filter(|global| !global.output_format().is_machine_readable()) +} + +pub fn show(global: &GlobalFlags) -> Result<()> { + let terminal = global.terminal(); + let settings = Settings::open(config::directory(global.config_dir.clone())?)?; + let context = serde_json::to_value(context::inspect(&settings, global)?)?; + let email = context + .pointer("/account/value") + .and_then(Value::as_str) + .and_then(|name| settings.config.accounts.get(name)) + .and_then(|account| account.email.clone()); + // Why commands would reject the credential, in the words and next step they would use. + let (scope, _) = settings.sourced_scope(global.scope_selection())?; + let problem = auth::select(&settings, &scope, global) + .err() + .map(|rejected| rejected.problem.error(&scope, &settings)); + wordmark::show(terminal); + for line in lines( + &context, + email.as_deref(), + problem.as_ref(), + terminal.glyphs(), + ) { + terminal.important_line(line); + } + Ok(()) +} + +/// Who is signed in, the saved scope, and what to try, from `voltage context`'s data. A +/// credential `problem` leads instead, with its own next step first. +fn lines( + context: &Value, + email: Option<&str>, + problem: Option<&Error>, + glyphs: Glyphs, +) -> Vec { + let setting = |name: &str| { + context + .pointer(&format!("/{name}/value")) + .and_then(Value::as_str) + .map(str::to_owned) + }; + let mut lines = Vec::new(); + lines.push(match (problem, setting("account"), email) { + (Some(problem), ..) => Line::default() + .paint(Role::Caution, "!") + .text(&format!(" {}", problem.message)), + (None, Some(account), Some(email)) => Line::default() + .paint(Role::Success, glyphs.done) + .text(" Signed in as ") + .styled(Role::Emphasis, email) + .text(&format!(" (account: {account})")), + (None, Some(account), None) => Line::default() + .paint(Role::Success, glyphs.done) + .text(" Signed in with account ") + .styled(Role::Emphasis, &account), + (None, None, _) => Line::default() + .paint(Role::Success, glyphs.done) + .text(" Using the API key in VOLTAGE_API_KEY"), + }); + for (label, name) in [ + ("Profile ", "profile"), + ("Organization ", "organization_id"), + ("Wallet ", "wallet_id"), + ] { + if let Some(value) = setting(name) { + lines.push(Line::default().paint(Role::Muted, label).text(&value)); + } + } + lines.push(Line::default()); + lines.push(Line::default().styled(Role::Emphasis, "Try")); + let steps: Vec = match problem { + Some(problem) => problem + .hint + .iter() + .map(|hint| hint.to_string()) + .chain(["voltage context".into(), "voltage --help".into()]) + .collect(), + None => [ + "voltage wallets list", + "voltage payments list --watch", + "voltage --help", + ] + .map(str::to_owned) + .into(), + }; + for step in steps { + lines.push( + Line::default() + .paint(Role::Hint, glyphs.step) + .text(&format!(" {step}")), + ); + } + lines +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CredentialProblem, Scope}; + use serde_json::json; + + fn shown(lines: &[Line]) -> Vec { + lines + .iter() + .map(|line| anstream::adapter::strip_str(line.as_str()).to_string()) + .collect() + } + + #[test] + fn a_signed_in_person_sees_who_and_where_then_what_to_try() { + let context = json!({ + "account": {"value": "me", "source": "flag"}, + "credential": {"kind": "user", "location": "file"}, + "credential_problem": null, + "profile": {"value": "staging", "source": "default"}, + "organization_id": {"value": "org-1", "source": "profile"}, + "wallet_id": {"value": null, "source": "unset"} + }); + assert_eq!( + shown(&lines( + &context, + Some("you@example.com"), + None, + Glyphs::UNICODE + )), + [ + "✓ Signed in as you@example.com (account: me)", + "Profile staging", + "Organization org-1", + "", + "Try", + "› voltage wallets list", + "› voltage payments list --watch", + "› voltage --help", + ] + ); + let unnamed = shown(&lines(&context, None, None, Glyphs::ASCII)); + assert_eq!(unnamed[0], "ok Signed in with account me"); + } + + fn problem(problem: CredentialProblem) -> Error { + let dir = tempfile::tempdir().unwrap(); + let settings = Settings::open(dir.path().to_path_buf()).unwrap(); + problem.error(&Scope::default(), &settings) + } + + #[test] + fn a_credential_problem_leads_with_its_own_message_and_next_step() { + let context = json!({"account": {"value": null}, "credential": null}); + let none = shown(&lines( + &context, + None, + Some(&problem(CredentialProblem::NoCredential)), + Glyphs::UNICODE, + )); + assert_eq!(none[0], "! No credential is available"); + assert!( + none[3].starts_with("› Sign in with `voltage login`"), + "{none:?}" + ); + // A blank VOLTAGE_API_KEY is fixed by unsetting it, which `voltage login` cannot do. + let empty = shown(&lines( + &context, + None, + Some(&problem(CredentialProblem::EmptyApiKey)), + Glyphs::UNICODE, + )); + assert_eq!(empty[0], "! VOLTAGE_API_KEY is empty"); + assert_eq!( + empty[3], + "› Unset VOLTAGE_API_KEY to use a saved credential, or set it to a key." + ); + // Several saved credentials and no default: choose one, not sign in again. + let several = shown(&lines( + &context, + None, + Some(&problem(CredentialProblem::MultipleCredentials)), + Glyphs::UNICODE, + )); + assert_eq!(several[0], "! Multiple credentials are saved"); + assert!( + several[3].contains("--account NAME or --profile NAME"), + "{several:?}" + ); + assert_eq!(&several[4..], ["› voltage context", "› voltage --help"]); + let key = json!({"account": {"value": null}, "credential": {"kind": "api_key"}}); + assert_eq!( + shown(&lines(&key, None, None, Glyphs::UNICODE))[0], + "✓ Using the API key in VOLTAGE_API_KEY" + ); + } + + #[test] + fn every_suggestion_is_a_valid_command() { + for command in [ + "voltage wallets list", + "voltage login", + "voltage payments list --watch", + ] { + let args: Vec<&str> = command.split(' ').collect(); + assert!( + crate::cli::command().try_get_matches_from(args).is_ok(), + "{command}" + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index bed9218..2ba5f87 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod config; mod context; mod dry_run; mod error; +mod greeting; mod input; mod output; mod payment; diff --git a/src/output.rs b/src/output.rs index 89f2d3d..74bdfb3 100644 --- a/src/output.rs +++ b/src/output.rs @@ -130,6 +130,25 @@ pub struct Envelope { /// On a terminal, a single result is drawn as a card with this title. #[serde(skip)] pub card: Option<&'static str>, + /// The command to run next, shown after human output on a terminal. + #[serde(skip)] + pub next: Option, +} + +/// A `›` line after a result: what to do, and the command that does it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NextStep { + pub what: &'static str, + pub command: String, +} + +impl NextStep { + pub fn line(&self, glyphs: Glyphs) -> Line { + Line::default() + .paint(Role::Hint, glyphs.step) + .text(&format!(" {}: ", self.what)) + .styled(Role::Emphasis, &self.command) + } } /// How table output lays out a single result. Decided by the command that built the data, not @@ -158,6 +177,7 @@ impl Envelope { event: None, layout: Layout::Fields, card: None, + next: None, } } @@ -202,6 +222,8 @@ pub struct Output { show_secrets: bool, file: Option, color: ColorChoice, + /// `--quiet`, which hides a result's next step. + quiet: bool, } /// Reserve `--output-file`. It is reserved before any request, so a refusal sent nothing; the @@ -228,6 +250,7 @@ impl Output { show_secrets: bool, output_file: Option<&Path>, color: ColorChoice, + quiet: bool, ) -> Result { let file = output_file.map(reserve_output_file).transpose()?; Ok(Self { @@ -235,9 +258,24 @@ impl Output { show_secrets, file, color, + quiet, }) } + /// Whether a next step belongs after the output: human output on interactive stdout and + /// stderr, without `--quiet`. Redirected or machine-readable output never gets one. + pub fn suggests(&self) -> bool { + !self.quiet && self.on_terminal() && std::io::stderr().is_terminal() + } + + /// The `›` line after the output, when it belongs there. + pub fn suggest(&self, next: &NextStep) { + if self.suggests() { + let glyphs = Glyphs::detect(|name| std::env::var(name).ok()); + style::write_stderr(self.color, &format!("{}\n", next.line(glyphs).as_str())); + } + } + /// One-time secrets may only go to a private file or an explicitly unredacted stream. pub fn secure_destination(&self) -> bool { self.show_secrets || self.file.is_some() @@ -341,7 +379,11 @@ impl Output { }; let mut buf = Vec::new(); human::render(&mut buf, &envelope, view)?; - return write_stdout_styled(&buf, self.color); + write_stdout_styled(&buf, self.color)?; + if let Some(next) = &envelope.next { + self.suggest(next); + } + return Ok(()); } let mut bytes = serde_json::to_vec(&envelope)?; bytes.push(b'\n'); diff --git a/src/startup.rs b/src/startup.rs index e742298..ab3a316 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -11,11 +11,11 @@ use crate::{ config::{self, Profile, Scope, Settings}, context, error::{CredentialSources, ErrorKind}, - output::{Envelope, Output, OutputFormat, report_error, write_stdout}, + greeting, + output::{Envelope, NextStep, Output, OutputFormat, report_error, write_stdout}, pick, price::{PRICE_URL, PriceService}, style::ColorChoice, - terminal::Terminal, wallet_currency, wallet_shortlist, }; use serde::Serialize; @@ -27,6 +27,12 @@ pub async fn run() -> ExitCode { let json_errors = cli::json_errors_requested(std::env::args_os()); let color = ColorChoice::requested(std::env::args_os()) .with_environment(|name| std::env::var(name).ok()); + if let Some(global) = greeting::wanted(&std::env::args_os().collect::>()) { + return match greeting::show(&global) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => fail(error, OutputFormat::Table, color), + }; + } let invocation = match Invocation::from_env(color) { Ok(invocation) => invocation, Err(failure) => return fail_parse(failure, json_errors, color), @@ -117,6 +123,7 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result global.show_secrets, global.output_file.as_deref().filter(|_| reserve), global.color(), + global.quiet, ) }; // Price commands need neither configuration nor credentials. @@ -241,7 +248,7 @@ impl<'a> Session<'a> { 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.terminal()).await + profiles(command, &mut self.settings, scope, global).await } LocalCommand::Completions { .. } | LocalCommand::Price(_) @@ -315,8 +322,9 @@ async fn profiles( command: ProfileCommand, settings: &mut Settings, scope: &Scope, - terminal: Terminal, + global: &GlobalFlags, ) -> Result { + let terminal = global.terminal(); if let ProfileCommand::Create { name } = command { settings .create_profile(&name, terminal, |settings| { @@ -327,9 +335,31 @@ async fn profiles( }) }) .await?; - return Envelope::local(ProfileCreated { + // A command that uses the new profile, with the same configuration directory. + let config_dir = global + .config_dir + .as_ref() + .map(|dir| { + format!( + " --config-dir {}", + config::shell_word(&dir.to_string_lossy()) + ) + }) + .unwrap_or_default(); + let next = NextStep { + what: "Use it", + command: format!( + "voltage wallets list --profile {}{config_dir}", + config::shell_word(&name) + ), + }; + let created = Envelope::local(ProfileCreated { profile: name, created: true, + })?; + return Ok(Envelope { + next: Some(next), + ..created }); } let _lock = settings.lock(terminal).await?; diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index b1534b9..53c22e4 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -338,14 +338,18 @@ fn stderr(assertion: &Assert) -> String { String::from_utf8_lossy(&assertion.get_output().stderr).into_owned() } -/// Text without SGR styles (`ESC [ ... m`), the only escape sequences color adds. +/// Text without CSI sequences (`ESC [`, parameters, one final byte): the styles color adds +/// and the line clearing a status line does. fn strip_styles(text: &str) -> String { let mut plain = String::with_capacity(text.len()); let mut rest = text; while let Some(start) = rest.find("\u{1b}[") { plain.push_str(&rest[..start]); - let end = rest[start..].find('m').unwrap(); - rest = &rest[start + end + 1..]; + let after = &rest[start + 2..]; + let parameters = after + .find(|c: char| !(c.is_ascii_digit() || c == ';' || c == '?')) + .unwrap_or(after.len()); + rest = after.get(parameters + 1..).unwrap_or(""); } plain.push_str(rest); plain @@ -5894,6 +5898,18 @@ async fn a_described_change_on_a_terminal_says_what_it_would_do() { /// would, and return everything it showed. #[cfg(unix)] async fn fully_on_a_terminal(dir: &Path, server: &MockServer, args: &[&str]) -> String { + let (code, shown) = fully_on_a_terminal_ending(dir, server, args).await; + assert_eq!(code, Some(0), "{shown}"); + shown +} + +/// `fully_on_a_terminal`, for a command that may fail: its exit code and what it showed. +#[cfg(unix)] +async fn fully_on_a_terminal_ending( + dir: &Path, + server: &MockServer, + args: &[&str], +) -> (Option, String) { use std::io::Read; use std::process::Stdio; let (mut controller, terminal) = open_pty(); @@ -5920,8 +5936,8 @@ async fn fully_on_a_terminal(dir: &Path, server: &MockServer, args: &[&str]) -> let output = tokio::task::spawn_blocking(move || child.wait_with_output().unwrap()) .await .unwrap(); - assert!(output.status.success()); - strip_styles(&String::from_utf8_lossy(&reader.await.unwrap())) + let shown = strip_styles(&String::from_utf8_lossy(&reader.await.unwrap())); + (output.status.code(), shown) } #[cfg(unix)] @@ -6614,6 +6630,109 @@ async fn a_watch_on_a_terminal_is_a_live_table_that_marks_changes() { } } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn voltage_without_a_command_greets_on_a_terminal_and_fails_when_piped() { + let (server, dir) = fixture().await; + // The harness passes only global flags (`--config-dir`, `--api-url`), so no command. + let shown = fully_on_a_terminal(dir.path(), &server, &[]).await; + assert!(shown.contains("/_ / |___/"), "{shown}"); + assert!( + shown.contains("✓ Using the API key in VOLTAGE_API_KEY\r\n\r\nTry\r\n"), + "{shown}" + ); + assert!(shown.contains("› voltage wallets list\r\n"), "{shown}"); + assert!(shown.contains("› voltage --help"), "{shown}"); + assert!(server.received_requests().await.unwrap().is_empty()); + // Off a terminal it stays a usage error. + let piped = Command::from_std(human_process_at(dir.path(), &server.uri())) + .assert() + .code(2); + assert!(stderr(&piped).contains("requires a subcommand")); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn an_empty_list_and_a_new_profile_name_the_next_command_on_a_terminal() { + let (server, dir) = fixture().await; + route("GET", wallets_path(ORG)) + .respond_with(in_order(vec![ + ok(json!({"items": []})), + ok(json!({"items": [{"id": WALLET, "name": "Treasury"}]})), + ])) + .mount(&server) + .await; + let list = ["wallets", "list", "--org", ORG]; + let empty = fully_on_a_terminal(dir.path(), &server, &list).await; + let hint = empty + .find("› Create a wallet: voltage wallets create --help") + .expect(&empty); + // The hint follows the result. + assert!(empty.find("No results.").expect(&empty) < hint, "{empty}"); + let listed = fully_on_a_terminal(dir.path(), &server, &list).await; + assert!(!listed.contains("Create a wallet"), "{listed}"); + // Redirected stdout never gets a hint, even with stderr on a terminal. + let piped = on_a_terminal( + dir.path(), + &server, + &[&list[..], &["--output", "table"]].concat(), + &[], + ) + .await; + assert!( + !strip_styles(&piped.shown).contains("Create a wallet"), + "{}", + piped.shown + ); + + let _settings = seeded_login(dir.path(), &server, false); + let create = |name: &'static str| { + [ + "profiles", + "create", + name, + "--org", + ORG, + "--env", + ENV, + "--account", + LOGIN_NAME, + ] + }; + let created = fully_on_a_terminal(dir.path(), &server, &create("stage")).await; + // After the result, and with the configuration directory the command used. + let use_it = format!( + "› Use it: voltage wallets list --profile stage --config-dir {}", + dir.path().display() + ); + let hint = created.find(&use_it).expect(&created); + assert!(created.find("created").expect(&created) < hint, "{created}"); + // `--json` and `--quiet` get no hint. + for flag in ["--json", "--quiet"] { + let args = [ + &create(if flag == "--json" { "json" } else { "quiet" })[..], + &[flag], + ] + .concat(); + let shown = fully_on_a_terminal(dir.path(), &server, &args).await; + assert!(!shown.contains("Use it"), "{flag}: {shown}"); + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_greeting_that_cannot_read_its_scope_reports_the_error() { + let (server, dir) = fixture().await; + let (code, shown) = + fully_on_a_terminal_ending(dir.path(), &server, &["--profile", "missing"]).await; + assert_eq!(code, Some(2), "{shown}"); + assert!( + shown.contains("error:") && shown.contains("missing"), + "{shown}" + ); + assert!(!shown.contains("Try"), "{shown}"); +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn a_failed_payment_on_a_terminal_ends_with_a_cross_before_the_error() {