diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cf43c8..ac7bc74 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
### Changed
+- On a terminal, `voltage login` offers to save a profile pinned to an organization and environment, and the picker takes the only organization or environment without asking. A wallet is still always asked for. Nothing changes off a terminal, with `--json`, or with `--no-input`.
- `voltage login` under a saved name replaces that login when it is the same user at the same auth URL, and revokes the replaced session. A different user is still refused. With `--account NAME`, a name saved for an API key or for another auth URL is refused before the browser step. An expired or revoked login's hint is now only `voltage login --account NAME`. A saved login whose secret cannot be read is refused, also before the browser step, with a hint that names `voltage logout --local --account NAME`, and `logout --local` removes an account whose secret is already gone or cannot be removed, with a warning for the latter. Without `--credential-store`, a replaced login keeps its credential store, and a new login uses the keychain. `voltage login --json` reports `replaced: true` when the login replaced a saved one. When a replacement cannot be saved, the replaced login stays saved, and the error says so if it could not be restored.
- Human tables fit the terminal width, and all API text is scrubbed of terminal control characters.
- Each item of a string list gets its own line in human output.
diff --git a/docs/guides/login-and-profiles.md b/docs/guides/login-and-profiles.md
index cf09c1c..0809e42 100644
--- a/docs/guides/login-and-profiles.md
+++ b/docs/guides/login-and-profiles.md
@@ -15,6 +15,8 @@ voltage logout --local # removes local credentials only
A login grants the permissions your account already has. Profiles do not restrict them.
+On a terminal, a login then offers to save a [profile](#profiles) pinned to an organization and environment, so later commands need only `--profile NAME`. It takes the organization and the environment when there is only one of each, and otherwise asks, as the [picker](#pick-the-scope-interactively) does. Then it asks for the profile's name (`default` when you press Enter). Esc skips the profile. When a profile already uses this login, or when stdout is not a terminal, as with `--json`, or with `--no-input`, nothing is offered. A failure while offering, such as a name that is already used, leaves the login saved and says how to create the profile later with `voltage profiles create`. Ctrl-C exits with code 130 and says that the login is saved. The profile is saved under the same lock as `voltage profiles create`, after the configuration is read again, so a change another command made while the prompt was open is kept. With table output, the result shows `profile` when one was saved.
+
Signing in again under a saved name replaces that login when it is the same user at the same auth URL, and revokes the replaced session. A different user, or a name used by an imported API key, is refused. With `--account NAME`, an API key or a login at another auth URL is refused before the browser step. Without `--credential-store`, the new login stays in the replaced login's credential store; a new name uses the keychain. With `--json`, the output has `replaced: true` when the login replaced a saved one.
## API keys
@@ -47,7 +49,7 @@ Only endpoints that filter by environment receive `--env`. Organization-wide com
## Pick the scope interactively
-On a terminal, a command that lacks the organization, environment, or wallet it needs asks for it instead of failing. The picker lists each choice as `name UUID`, and each wallet as `name CURRENCY UUID` when all of its balances are in one currency. Type to filter, by name or currency, use the arrow keys, and press Enter. Afterwards, the CLI prints the flags that select the same scope, so you can pass them next time or save a profile.
+On a terminal, a command that lacks the organization, environment, or wallet it needs asks for it instead of failing. The picker lists each choice as `name UUID`, and each wallet as `name CURRENCY UUID` when all of its balances are in one currency. Type to filter, by name or currency, use the arrow keys, and press Enter. When there is only one organization or environment, the CLI takes it without asking and says so. A wallet is always asked for, also when there is only one, because a payment spends from it. Afterwards, the CLI prints the flags that select the same scope, so you can pass them next time or save a profile.
```sh
voltage wallets list # asks for the organization
diff --git a/src/api.rs b/src/api.rs
index 5f1e0fc..55df986 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -1140,6 +1140,9 @@ pub struct SubmissionState {
/// Cleared only once the local side is done, never on drop: Ctrl-C drops the command
/// before the interruption is reported.
session: Mutex>,
+ /// The saved login whose profile offer is under way. Kept apart from `session`: the
+ /// offer's own refresh or token exchange sets and settles a session change of its own.
+ profile_offer: Mutex >,
}
/// A login change that the server and the saved credentials can disagree about.
@@ -1180,6 +1183,14 @@ impl SubmissionState {
*self.session.lock().unwrap_or_else(PoisonError::into_inner) = None;
}
+ /// Marks the profile offer after a login, which is saved by then, as under way or done.
+ pub(crate) fn offering_profile(&self, account: Option<&str>) {
+ *self
+ .profile_offer
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner) = account.map(str::to_owned);
+ }
+
/// The error for Ctrl-C. It claims a write may continue only when one may have been sent,
/// and never claims that the write was cancelled. A sent write outranks a session change,
/// which always comes first.
@@ -1207,7 +1218,16 @@ impl SubmissionState {
"Interrupted while revoking the replaced session for {account}; the new login is saved"
))
.with_hint("Account global signout ends the replaced session if it is still active."),
- None => Error::interrupted("Interrupted before any resource change was submitted"),
+ None => match &*self
+ .profile_offer
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner)
+ {
+ Some(account) => profile_offer_interrupted(account),
+ None => {
+ Error::interrupted("Interrupted before any resource change was submitted")
+ }
+ },
};
};
let message = match write.resource_id {
@@ -1224,6 +1244,17 @@ impl SubmissionState {
}
}
+/// Ctrl-C during the profile offer: the login was saved before the offer began.
+pub(crate) fn profile_offer_interrupted(account: &str) -> Error {
+ let account = shell_word(account);
+ Error::interrupted(format!(
+ "Interrupted while saving a profile; the login for {account} is saved"
+ ))
+ .with_hint(format!(
+ "Create one later with `voltage profiles create NAME --account {account}`."
+ ))
+}
+
/// A read can establish acceptance without sending the mutation again. A missing
/// projection remains uncertain and keeps the original recovery record.
async fn reconcile_payment(api: &Api, request: &Request, scope: &Scope) -> Option {
@@ -1981,6 +2012,33 @@ mod tests {
assert_eq!(detail["outcome"], "unknown");
}
+ #[test]
+ fn interruption_during_a_profile_offer_says_the_login_is_saved_until_it_ends() {
+ let submission = SubmissionState::default();
+ submission.offering_profile(Some("my team"));
+ // The offer's own token refresh settles without ending the offer.
+ submission.session_changing(SessionChange::Refresh {
+ account: "my team".into(),
+ });
+ submission.session_settled();
+ let during = submission.interrupted();
+ assert_eq!(
+ during.message,
+ "Interrupted while saving a profile; the login for 'my team' is saved"
+ );
+ assert_eq!(
+ during.hint.as_deref(),
+ Some("Create one later with `voltage profiles create NAME --account 'my team'`.")
+ );
+ assert!(during.detail.is_none());
+
+ submission.offering_profile(None);
+ assert_eq!(
+ submission.interrupted().message,
+ "Interrupted before any resource change was submitted"
+ );
+ }
+
#[test]
fn interruption_during_a_session_change_says_how_to_recover_until_it_settles() {
let submission = SubmissionState::default();
diff --git a/src/auth.rs b/src/auth.rs
index a8bb3f0..a09eb5e 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -508,6 +508,9 @@ pub struct LoginOutcome {
pub authenticated: bool,
/// The login replaced a saved login of the same user under this name.
pub replaced: bool,
+ /// The profile saved for this login on a terminal, when one was.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub profile: Option,
}
#[derive(Debug, Serialize)]
@@ -777,6 +780,7 @@ async fn save_login(
email,
authenticated: true,
replaced: replaced.is_some(),
+ profile: None,
})
}
diff --git a/src/config.rs b/src/config.rs
index d35e2b8..1887d2e 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -380,6 +380,25 @@ impl Settings {
Ok(())
}
+ /// Save a new profile under the credential lock, after re-reading the configuration, so a
+ /// change another command made meanwhile is kept and a name it took is refused. `build`
+ /// makes the profile from the re-read settings.
+ pub async fn create_profile(
+ &mut self,
+ name: &str,
+ terminal: Terminal,
+ build: impl FnOnce(&Self) -> Result,
+ ) -> Result<()> {
+ let _lock = self.lock(terminal).await?;
+ self.reload()?;
+ if self.config.profiles.contains_key(name) {
+ return Err(Error::usage(format!("Profile {name} already exists")));
+ }
+ let profile = build(self)?;
+ self.config.profiles.insert(name.to_owned(), profile);
+ self.save()
+ }
+
pub fn save(&self) -> Result<()> {
private_dir(&self.dir)?;
let text = toml::to_string_pretty(&self.config)
diff --git a/src/pick.rs b/src/pick.rs
index 9106a88..2d1efe3 100644
--- a/src/pick.rs
+++ b/src/pick.rs
@@ -2,10 +2,12 @@
//! a person at a terminal picks one instead of copying a UUID. Scripts never see a prompt.
use crate::{
- Result, api,
+ Error, Result, api,
+ api::{SubmissionState, profile_offer_interrupted},
auth::{ApiCredential, Discovery, Resolver},
cli::{BodySource, Command, GlobalFlags, LocalCommand, ProfileCommand},
- config::{Credential, Scope},
+ config::{Credential, Profile, Scope, Settings, shell_word},
+ error::ErrorKind,
output::OutputFormat,
registry::{OperationId, ParameterLocation, ScopeParameter},
terminal::{Choice, Terminal},
@@ -114,6 +116,101 @@ pub fn can_ask(global: &GlobalFlags) -> bool {
global.output_format() == OutputFormat::Table && global.terminal().can_pick()
}
+/// After `voltage login` on a terminal, offer to save a profile that pins the login to an
+/// organization and environment, so later commands need only `--profile NAME`. Nothing is
+/// offered off a terminal, or when a profile already uses this login. The login is already
+/// saved, so a failure here is a notice and never fails the login. Ctrl-C ends the command,
+/// and its error says that the login is saved.
+pub async fn offer_profile(
+ settings: &mut Settings,
+ account: &str,
+ global: &GlobalFlags,
+ submission: &SubmissionState,
+) -> Result> {
+ let used = settings
+ .config
+ .profiles
+ .values()
+ .any(|profile| profile.account == account);
+ if used || !can_ask(global) {
+ return Ok(None);
+ }
+ let terminal = global.terminal();
+ terminal.notice(
+ "Save a profile for this login, so later commands need only --profile NAME. Press Esc to skip.",
+ );
+ let later = format!(
+ "Create one later with `voltage profiles create NAME --account {}`.",
+ shell_word(account)
+ );
+ let skipped = |why: String| terminal.notice(format!("{why}{later}"));
+ // Ctrl-C from here on, at a prompt or during a request, reports the saved login.
+ submission.offering_profile(Some(account));
+ let pinned = pin(settings, account, global, submission).await;
+ submission.offering_profile(None);
+ match pinned {
+ Ok(Some(name)) => {
+ terminal.notice(format!(
+ "Saved profile {name}. Use it with --profile {name}."
+ ));
+ Ok(Some(name))
+ }
+ Ok(None) => {
+ skipped("No profile was saved. ".into());
+ Ok(None)
+ }
+ Err(error) if error.kind == ErrorKind::Interrupted => {
+ Err(profile_offer_interrupted(account))
+ }
+ Err(error) => {
+ skipped(format!("No profile was saved: {}. ", error.message));
+ Ok(None)
+ }
+ }
+}
+
+/// Pick the organization and environment, taking each when it is the only one, then ask for
+/// the profile's name and save it. `None` means Esc.
+async fn pin(
+ settings: &mut Settings,
+ account: &str,
+ global: &GlobalFlags,
+ submission: &SubmissionState,
+) -> Result > {
+ let mut scope = Scope {
+ account: Some(account.to_owned()),
+ ..Scope::default()
+ };
+ let resolver = Resolver {
+ settings,
+ flags: global,
+ submission,
+ credential_sources: None,
+ };
+ ask(Needs::new(true, true, false), global, resolver, &mut scope).await?;
+ let (Some(organization_id), [environment_id]) = (scope.org, scope.envs.as_slice()) else {
+ return Ok(None);
+ };
+ let Some(name) = global.terminal().text("Profile name:", "default").await? else {
+ return Ok(None);
+ };
+ let name = name.trim().to_owned();
+ if name.is_empty() {
+ return Err(Error::usage("A profile name cannot be empty"));
+ }
+ let profile = Profile {
+ organization_id,
+ environment_id: *environment_id,
+ account: account.to_owned(),
+ };
+ // The prompts can stay open for a long time, so the configuration is re-read under the
+ // lock before the profile is added.
+ settings
+ .create_profile(&name, global.terminal(), |_| Ok(profile))
+ .await?;
+ Ok(Some(name))
+}
+
/// The pickers themselves, with one credential resolution and at most one organization token
/// exchange. An API key cannot list organizations or environments, so only its wallets are
/// offered. Returns the flags for what was picked, which Esc or an empty list can cut short.
@@ -136,7 +233,8 @@ async fn ask(
let listed = resolver
.list(scope, Discovery::Organizations, &login.access_token)
.await?;
- let Some(org) = choose(terminal, "Organization:", "organizations", &listed).await? else {
+ let Some(org) = choose(terminal, "Organization:", "organizations", &listed, true).await?
+ else {
return Ok(picked);
};
scope.org = Some(org);
@@ -152,7 +250,8 @@ async fn ask(
return Ok(picked);
};
let listed = resolver.list(scope, Discovery::Environments, token).await?;
- let Some(env) = choose(terminal, "Environment:", "environments", &listed).await? else {
+ let Some(env) = choose(terminal, "Environment:", "environments", &listed, true).await?
+ else {
return Ok(picked);
};
scope.envs = vec![env];
@@ -160,7 +259,7 @@ async fn ask(
}
if missing.wallet {
let listed = api::wallets(global, credential, resolver.credential_sources, scope).await?;
- let Some(wallet) = choose(terminal, "Wallet:", "wallets", &listed).await? else {
+ let Some(wallet) = choose(terminal, "Wallet:", "wallets", &listed, false).await? else {
return Ok(picked);
};
scope.wallet = Some(wallet);
@@ -169,18 +268,26 @@ async fn ask(
Ok(picked)
}
-/// Pick from a listing, or say why there is nothing to pick.
+/// Pick from a listing, or say why there is nothing to pick. With `take_only`, the only
+/// choice is taken without asking. A wallet is always asked for, because a payment spends
+/// from it.
async fn choose(
terminal: Terminal,
question: &'static str,
plural: &str,
listed: &Value,
+ take_only: bool,
) -> Result > {
- let choices = choices(listed);
+ let mut choices = choices(listed);
if choices.is_empty() {
terminal.notice(format!("No {plural} to choose from."));
return Ok(None);
}
+ if take_only && choices.len() == 1 {
+ let only = choices.remove(0);
+ terminal.notice(format!("{question} {only} (the only one)"));
+ return Ok(Some(only.id()));
+ }
terminal.pick(question, choices).await
}
diff --git a/src/startup.rs b/src/startup.rs
index c5d27c5..d73d353 100644
--- a/src/startup.rs
+++ b/src/startup.rs
@@ -187,7 +187,12 @@ impl<'a> Session<'a> {
let (global, scope, submission) = (self.global, &self.scope, self.submission);
match command {
LocalCommand::Login(flags) => {
- Envelope::local(auth::login(&mut self.settings, &flags, global, submission).await?)
+ let mut outcome =
+ auth::login(&mut self.settings, &flags, global, submission).await?;
+ outcome.profile =
+ pick::offer_profile(&mut self.settings, &outcome.account, global, submission)
+ .await?;
+ Envelope::local(outcome)
}
LocalCommand::Logout(flags) => Envelope::local(
auth::logout(
@@ -286,48 +291,49 @@ struct ProfileDeleted {
deleted: bool,
}
-/// Profile edits take the credential lock and re-read the configuration first, so two
-/// concurrent commands never overwrite each other's changes.
+/// Profile commands take the credential lock and re-read the configuration first, so two
+/// concurrent commands never overwrite each other's changes. `Settings::create_profile`
+/// takes the lock itself, because a login's profile offer saves through it too.
async fn profiles(
command: ProfileCommand,
settings: &mut Settings,
scope: &Scope,
terminal: Terminal,
) -> Result {
+ if let ProfileCommand::Create { name } = command {
+ settings
+ .create_profile(&name, terminal, |settings| {
+ Ok(Profile {
+ account: settings.account_name(scope)?,
+ organization_id: scope.require_org()?,
+ environment_id: scope.single_env()?,
+ })
+ })
+ .await?;
+ return Envelope::local(ProfileCreated {
+ profile: name,
+ created: true,
+ });
+ }
let _lock = settings.lock(terminal).await?;
settings.reload()?;
+ if let ProfileCommand::Delete { name } = command {
+ if settings.config.profiles.remove(&name).is_none() {
+ return Err(settings.unknown_profile(&name));
+ }
+ settings.save()?;
+ return Envelope::local(ProfileDeleted {
+ profile: name,
+ deleted: true,
+ });
+ }
match command {
- ProfileCommand::List => Envelope::local(&settings.config.profiles),
ProfileCommand::Get { name } => {
let profile = settings.config.profiles.get(&name);
Envelope::local(profile.ok_or_else(|| settings.unknown_profile(&name))?)
}
- ProfileCommand::Create { name } => {
- if settings.config.profiles.contains_key(&name) {
- return Err(Error::usage("Profile already exists"));
- }
- let profile = Profile {
- account: settings.account_name(scope)?,
- organization_id: scope.require_org()?,
- environment_id: scope.single_env()?,
- };
- settings.config.profiles.insert(name.clone(), profile);
- settings.save()?;
- Envelope::local(ProfileCreated {
- profile: name,
- created: true,
- })
- }
- ProfileCommand::Delete { name } => {
- if settings.config.profiles.remove(&name).is_none() {
- return Err(settings.unknown_profile(&name));
- }
- settings.save()?;
- Envelope::local(ProfileDeleted {
- profile: name,
- deleted: true,
- })
- }
+ // List; Create and Delete returned above.
+ _ => Envelope::local(&settings.config.profiles),
}
}
diff --git a/src/terminal.rs b/src/terminal.rs
index 94f33be..9d9e9a6 100644
--- a/src/terminal.rs
+++ b/src/terminal.rs
@@ -2,7 +2,7 @@
//! errors are written by `output`.
use crate::{Error, Result, secret::Secret};
-use inquire::{InquireError, Select};
+use inquire::{InquireError, Select, Text};
use std::{
fmt::{self, Display},
io::{ErrorKind, IsTerminal, Write},
@@ -77,6 +77,18 @@ impl Terminal {
)
}
+ /// Ask for a line of text, offering `default` for an empty answer. `None` means Esc.
+ pub async fn text(
+ self,
+ question: &'static str,
+ default: &'static str,
+ ) -> Result> {
+ #[cfg(unix)]
+ let _modes = TerminalModes::save();
+ let _screen = PickerScreen;
+ answered(read_detached(move || Text::new(question).with_default(default).prompt()).await?)
+ }
+
/// Read a secret without echo.
pub async fn read_hidden(self, prompt: &'static str) -> Result {
// The detached read restores echo only when it returns, and Ctrl-C does not wait for
@@ -93,11 +105,15 @@ impl Terminal {
}
}
-/// What the picker's answer means: a choice, Esc as `None`, Ctrl-C as an interruption, and
-/// any other failure, such as a terminal that cannot be read, as a transport error.
fn picked(answer: std::result::Result) -> Result> {
+ Ok(answered(answer)?.map(|choice| choice.id))
+}
+
+/// What a prompt's answer means: the answer, Esc as `None`, Ctrl-C as an interruption, and
+/// any other failure, such as a terminal that cannot be read, as a transport error.
+fn answered(answer: std::result::Result) -> Result> {
match answer {
- Ok(choice) => Ok(Some(choice.id)),
+ Ok(answer) => Ok(Some(answer)),
Err(InquireError::OperationCanceled) => Ok(None),
Err(InquireError::OperationInterrupted) => Err(Error::interrupted(
"Interrupted before any resource change was submitted",
@@ -145,6 +161,10 @@ impl Choice {
pub fn name(&self) -> &str {
&self.name
}
+
+ pub fn id(&self) -> Uuid {
+ self.id
+ }
}
impl Display for Choice {
diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs
index 089548f..9f68120 100644
--- a/tests/cli_integration.rs
+++ b/tests/cli_integration.rs
@@ -4592,6 +4592,19 @@ async fn on_a_terminal(
server: &MockServer,
args: &[&str],
steps: &[(&'static str, &'static [u8])],
+) -> TerminalRun {
+ on_a_terminal_with(dir, server, args, steps, |_| {}).await
+}
+
+/// `on_a_terminal`, calling `meanwhile` with each step's index once its prompt is shown and
+/// before its keys are typed, as another command could run while a prompt is open.
+#[cfg(unix)]
+async fn on_a_terminal_with(
+ dir: &Path,
+ server: &MockServer,
+ args: &[&str],
+ steps: &[(&'static str, &'static [u8])],
+ mut meanwhile: impl FnMut(usize),
) -> TerminalRun {
use std::io::{Read, Write};
use std::os::unix::process::CommandExt;
@@ -4642,11 +4655,12 @@ async fn on_a_terminal(
}
String::from_utf8_lossy(&shown).into_owned()
});
- for &(_, keys) in steps {
+ for (index, &(_, keys)) in steps.iter().enumerate() {
tokio::time::timeout(Duration::from_secs(10), shown_prompt.recv())
.await
.unwrap()
.unwrap();
+ meanwhile(index);
if keys.is_empty() {
interrupt(&child);
} else {
@@ -4898,7 +4912,7 @@ async fn a_missing_credential_is_reported_before_the_missing_scope() {
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-async fn a_login_picks_an_organization_then_an_environment() {
+async fn a_login_takes_the_only_organization_and_environment_without_asking() {
let auth = MockServer::start().await;
let (api, dir) = fixture().await;
let mut settings = seeded_login(dir.path(), &auth, false);
@@ -4925,9 +4939,15 @@ async fn a_login_picks_an_organization_then_an_environment() {
"--output",
"table",
];
- let steps: &[(&str, &[u8])] = &[("Organization:", b"\r"), ("Environment:", b"\r")];
- let run = on_a_terminal(dir.path(), &api, &args, steps).await;
+ // The only organization and environment are taken without asking, and say so.
+ let run = on_a_terminal(dir.path(), &api, &args, &[]).await;
assert_eq!(run.code, Some(0), "{}", run.shown);
+ for taken in [
+ format!("Organization: Acme {ORG} (the only one)"),
+ format!("Environment: prod {ENV} (the only one)"),
+ ] {
+ assert!(run.shown.contains(&taken), "{}", run.shown);
+ }
assert!(
run.shown
.contains(&format!("Next time, pass --org {ORG} --env {ENV}.")),
@@ -4950,7 +4970,7 @@ async fn a_login_picks_an_organization_then_an_environment() {
"--output",
"table",
];
- let run = on_a_terminal(dir.path(), &api, &args, &[("Organization:", b"\r")]).await;
+ let run = on_a_terminal(dir.path(), &api, &args, &[]).await;
assert_eq!(run.code, Some(0), "{}", run.shown);
assert!(!run.shown.contains("Environment:"), "{}", run.shown);
auth.verify().await;
@@ -5010,13 +5030,7 @@ async fn a_failed_listing_or_exchange_ends_the_pick_with_its_own_error() {
ResponseTemplate::new(400).set_body_json(json!({"error": "invalid_grant"})),
)
.await;
- let run = on_a_terminal(
- dir.path(),
- &api,
- &CREATE_PROFILE,
- &[("Organization:", b"\r")],
- )
- .await;
+ let run = on_a_terminal(dir.path(), &api, &CREATE_PROFILE, &[]).await;
assert_failed_pick(
&run,
"The saved login for person has expired or was revoked",
@@ -5034,13 +5048,7 @@ async fn a_failed_listing_or_exchange_ends_the_pick_with_its_own_error() {
ResponseTemplate::new(500),
)
.await;
- let run = on_a_terminal(
- dir.path(),
- &api,
- &CREATE_PROFILE,
- &[("Organization:", b"\r")],
- )
- .await;
+ let run = on_a_terminal(dir.path(), &api, &CREATE_PROFILE, &[]).await;
assert_failed_pick(&run, "Authentication failed (HTTP 500");
assert!(!run.shown.contains("Environment:"), "{}", run.shown);
auth.verify().await;
@@ -5052,13 +5060,16 @@ async fn esc_at_the_organization_or_environment_picker_keeps_the_missing_scope_e
let auth = MockServer::start().await;
let (api, dir) = fixture().await;
seeded_login(dir.path(), &auth, false);
- let organizations = json!({"organizations": [{"id": ORG, "name": "Acme"}]});
+ // Two of each, so each picker asks rather than taking the only one.
+ let organizations = json!({"organizations": [
+ {"id": ORG, "name": "Acme"}, {"id": OTHER_ORG, "name": "Beta"}
+ ]});
respond(&auth, route("GET", "/users/current"), ok(organizations), 2).await;
organization_exchange(&auth, LOGIN_ACCESS, ORG, "picker-access", 1).await;
respond_once(
&auth,
route("GET", format!("/organizations/{ORG}/environments")),
- ok(json!([{"id": ENV, "name": "prod"}])),
+ ok(json!([{"id": ENV, "name": "prod"}, {"id": RESOURCE, "name": "staging"}])),
)
.await;
@@ -5830,3 +5841,428 @@ async fn wallets_list_all_orgs_refuses_an_api_key_and_a_named_organization() {
}
assert!(api.received_requests().await.unwrap().is_empty());
}
+
+/// `voltage login` on a terminal against `server` as both auth service and API, whose user
+/// sees `organizations`, each with the environment `ENV` (prod). The profile offer lists
+/// the organizations with the same request as the login's identity.
+#[cfg(unix)]
+async fn login_on_a_terminal(
+ dir: &Path,
+ organizations: Value,
+ environments: Value,
+ steps: &[(&'static str, &'static [u8])],
+) -> (TerminalRun, MockServer) {
+ login_on_a_terminal_with(dir, organizations, ok(environments), steps, |_| {}).await
+}
+
+#[cfg(unix)]
+async fn login_on_a_terminal_with(
+ dir: &Path,
+ organizations: Value,
+ environments: ResponseTemplate,
+ steps: &[(&'static str, &'static [u8])],
+ meanwhile: impl FnMut(usize),
+) -> (TerminalRun, MockServer) {
+ let server = MockServer::start().await;
+ respond_once(
+ &server,
+ route("POST", "/oauth/device_authorization"),
+ ok(json!({
+ "device_code":"device-secret","user_code":"ABCD-EFGH",
+ "verification_uri":"https://app.voltage.cloud/cli/authorize","expires_in":60,"interval":5
+ })),
+ )
+ .await;
+ respond_once(
+ &server,
+ route("POST", "/oauth/token").and(body_string_contains("device_code=device-secret")),
+ ok(json!({
+ "access_token":"login-access-secret","refresh_token":"login-refresh-secret",
+ "token_type":"Bearer","expires_in":600
+ })),
+ )
+ .await;
+ route("GET", "/users/current")
+ .respond_with(ok(
+ json!({"id": RESOURCE, "email": LOGIN_EMAIL, "organizations": organizations}),
+ ))
+ .mount(&server)
+ .await;
+ for org in [ORG, OTHER_ORG] {
+ route("POST", "/oauth/token")
+ .and(body_string_contains(format!("audience={org}")))
+ .respond_with(ok(json!({
+ "access_token": "picker-access",
+ "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
+ "token_type": "Bearer", "expires_in": 600, "scope": "read write"
+ })))
+ .mount(&server)
+ .await;
+ route("GET", format!("/organizations/{org}/environments"))
+ .respond_with(environments.clone())
+ .mount(&server)
+ .await;
+ }
+ let uri = server.uri();
+ let args = [
+ "login",
+ "--auth-url",
+ &uri,
+ "--no-browser",
+ "--account",
+ LOGIN_NAME,
+ "--credential-store",
+ "file",
+ "--output",
+ "table",
+ ];
+ let run = on_a_terminal_with(dir, &server, &args, steps, meanwhile).await;
+ (run, server)
+}
+
+#[cfg(unix)]
+fn saved_profiles(dir: &Path) -> std::collections::BTreeMap {
+ let settings = Settings::open(dir.to_path_buf()).unwrap();
+ settings.config.profiles
+}
+
+#[cfg(unix)]
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn login_on_a_terminal_saves_a_profile_pinned_to_the_only_organization() {
+ let dir = private_tempdir();
+ let (run, _server) = login_on_a_terminal(
+ dir.path(),
+ json!([{"id": ORG, "name": "Acme"}]),
+ json!([{"id": ENV, "name": "prod"}]),
+ &[("Profile name:", b"prod\r")],
+ )
+ .await;
+ assert_eq!(run.code, Some(0), "{}", run.shown);
+ for shown in [
+ "Save a profile for this login, so later commands need only --profile NAME.",
+ &format!("Organization: Acme {ORG} (the only one)"),
+ &format!("Environment: prod {ENV} (the only one)"),
+ "Saved profile prod. Use it with --profile prod.",
+ ] {
+ assert!(run.shown.contains(shown), "{shown}: {}", run.shown);
+ }
+ let profiles = saved_profiles(dir.path());
+ let profile = &profiles["prod"];
+ assert_eq!(
+ (
+ profile.organization_id,
+ profile.environment_id,
+ profile.account.as_str()
+ ),
+ (uuid(ORG), uuid(ENV), LOGIN_NAME)
+ );
+ assert!(run.stdout.contains("prod"), "{}", run.stdout);
+ assert_eq!(run.modes.0, run.modes.1);
+}
+
+#[cfg(unix)]
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn login_on_a_terminal_asks_between_organizations_and_esc_saves_no_profile() {
+ let dir = private_tempdir();
+ let (run, _server) = login_on_a_terminal(
+ dir.path(),
+ json!([{"id": ORG, "name": "Acme"}, {"id": OTHER_ORG, "name": "Beta"}]),
+ json!([{"id": ENV, "name": "prod"}]),
+ &[("Organization:", b"\r"), ("Profile name:", b"\x1b")],
+ )
+ .await;
+ assert_eq!(run.code, Some(0), "{}", run.shown);
+ assert!(
+ run.shown.contains(&format!(
+ "No profile was saved. Create one later with `voltage profiles create NAME --account {LOGIN_NAME}`."
+ )),
+ "{}",
+ run.shown
+ );
+ assert!(saved_profiles(dir.path()).is_empty());
+ // The login itself is saved.
+ assert!(
+ Settings::open(dir.path().to_path_buf())
+ .unwrap()
+ .config
+ .accounts
+ .contains_key(LOGIN_NAME)
+ );
+ assert_eq!(run.modes.0, run.modes.1);
+}
+
+#[cfg(unix)]
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn login_on_a_terminal_reports_why_no_profile_was_saved_and_still_succeeds() {
+ // A profile of that name already belongs to another login.
+ let dir = private_tempdir();
+ let mut settings = settings_at(dir.path());
+ settings.config.profiles.insert(
+ "default".into(),
+ Profile {
+ organization_id: uuid(ORG),
+ environment_id: uuid(ENV),
+ account: "someone-else".into(),
+ },
+ );
+ settings.save().unwrap();
+ let (run, _server) = login_on_a_terminal(
+ dir.path(),
+ json!([{"id": ORG, "name": "Acme"}]),
+ json!([{"id": ENV, "name": "prod"}]),
+ &[("Profile name:", b"\r")],
+ )
+ .await;
+ assert_eq!(run.code, Some(0), "{}", run.shown);
+ assert!(
+ run.shown
+ .contains("No profile was saved: Profile default already exists. Create one later"),
+ "{}",
+ run.shown
+ );
+ assert_eq!(
+ saved_profiles(dir.path())["default"].account,
+ "someone-else"
+ );
+
+ // A blank name is refused the same way.
+ let dir = private_tempdir();
+ let (run, _server) = login_on_a_terminal(
+ dir.path(),
+ json!([{"id": ORG, "name": "Acme"}]),
+ json!([{"id": ENV, "name": "prod"}]),
+ &[("Profile name:", b" \r")],
+ )
+ .await;
+ assert_eq!(run.code, Some(0), "{}", run.shown);
+ assert!(
+ run.shown
+ .contains("No profile was saved: A profile name cannot be empty."),
+ "{}",
+ run.shown
+ );
+
+ // Ctrl-C at the name ends the command as an interruption, though the login is saved.
+ let dir = private_tempdir();
+ let (run, _server) = login_on_a_terminal(
+ dir.path(),
+ json!([{"id": ORG, "name": "Acme"}]),
+ json!([{"id": ENV, "name": "prod"}]),
+ &[("Profile name:", b"\x03")],
+ )
+ .await;
+ assert_eq!(run.code, Some(130), "{}", run.shown);
+ for shown in [
+ format!("Interrupted while saving a profile; the login for {LOGIN_NAME} is saved"),
+ format!("Create one later with `voltage profiles create NAME --account {LOGIN_NAME}`."),
+ ] {
+ assert!(run.shown.contains(&shown), "{shown}: {}", run.shown);
+ }
+ assert!(!run.shown.contains("No profile was saved"), "{}", run.shown);
+ assert!(saved_profiles(dir.path()).is_empty());
+ assert_eq!(run.modes.0, run.modes.1);
+
+ // Ctrl-C while the offer lists environments is a signal, not a key at a prompt, and
+ // reports the saved login the same way.
+ let dir = private_tempdir();
+ let (run, _server) = login_on_a_terminal_with(
+ dir.path(),
+ json!([{"id": ORG, "name": "Acme"}]),
+ ok(json!([{"id": ENV, "name": "prod"}])).set_delay(Duration::from_secs(10)),
+ &[("(the only one)", b"")],
+ |_| {},
+ )
+ .await;
+ assert_eq!(run.code, Some(130), "{}", run.shown);
+ for shown in [
+ format!("Interrupted while saving a profile; the login for {LOGIN_NAME} is saved"),
+ format!("Create one later with `voltage profiles create NAME --account {LOGIN_NAME}`."),
+ ] {
+ assert!(run.shown.contains(&shown), "{shown}: {}", run.shown);
+ }
+ assert!(saved_profiles(dir.path()).is_empty());
+ assert!(
+ Settings::open(dir.path().to_path_buf())
+ .unwrap()
+ .config
+ .accounts
+ .contains_key(LOGIN_NAME)
+ );
+
+ // No environment to pin: nothing to save.
+ let dir = private_tempdir();
+ let (run, _server) = login_on_a_terminal(
+ dir.path(),
+ json!([{"id": ORG, "name": "Acme"}]),
+ json!([]),
+ &[],
+ )
+ .await;
+ assert_eq!(run.code, Some(0), "{}", run.shown);
+ assert!(
+ run.shown.contains("No environments to choose from."),
+ "{}",
+ run.shown
+ );
+ assert!(run.shown.contains("No profile was saved."), "{}", run.shown);
+ assert!(saved_profiles(dir.path()).is_empty());
+}
+
+#[cfg(unix)]
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn login_on_a_terminal_offers_no_profile_when_one_already_uses_the_login() {
+ let dir = private_tempdir();
+ let mut settings = settings_at(dir.path());
+ settings.config.profiles.insert(
+ "prod".into(),
+ Profile {
+ organization_id: uuid(ORG),
+ environment_id: uuid(ENV),
+ account: LOGIN_NAME.into(),
+ },
+ );
+ settings.save().unwrap();
+ let (run, server) = login_on_a_terminal(
+ dir.path(),
+ json!([{"id": ORG, "name": "Acme"}]),
+ json!([{"id": ENV, "name": "prod"}]),
+ &[],
+ )
+ .await;
+ assert_eq!(run.code, Some(0), "{}", run.shown);
+ assert!(!run.shown.contains("Save a profile"), "{}", run.shown);
+ // Only the login's own identity request; nothing is listed for a profile.
+ let requests = server.received_requests().await.unwrap();
+ let listed = requests
+ .iter()
+ .filter(|request| request.url.path() == "/users/current")
+ .count();
+ assert_eq!(listed, 1);
+ assert!(
+ requests
+ .iter()
+ .all(|request| !request.url.path().ends_with("/environments"))
+ );
+}
+
+#[cfg(unix)]
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn the_only_wallet_is_still_asked_for_because_a_payment_spends_from_it() {
+ let (server, dir) = fixture().await;
+ respond_once(
+ &server,
+ route("GET", wallets_path(ORG)).and(query_param("environment_id", ENV)),
+ ok(json!([{"id": WALLET, "name": "ops", "balances": [{"currency": "btc"}]}])),
+ )
+ .await;
+ let send = [
+ "payments",
+ "send",
+ "--org",
+ ORG,
+ "--env",
+ ENV,
+ "--currency",
+ "btc",
+ "--invoice",
+ "lnbc1",
+ "--output",
+ "table",
+ ];
+ let run = on_a_terminal(dir.path(), &server, &send, &[("Wallet:", b"\r")]).await;
+ assert_eq!(run.code, Some(6), "{}", run.shown);
+ assert!(!run.shown.contains("(the only one)"), "{}", run.shown);
+ assert!(run.stdout.contains(WALLET), "{}", run.stdout);
+ assert_eq!(run.modes.0, run.modes.1);
+}
+
+#[cfg(unix)]
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn a_profile_saved_after_login_keeps_changes_made_while_its_prompt_was_open() {
+ let other = |account: &str| Profile {
+ organization_id: uuid(ORG),
+ environment_id: uuid(ENV),
+ account: account.into(),
+ };
+ // While the name prompt is open, another command saves a profile.
+ let dir = private_tempdir();
+ let path = dir.path().to_path_buf();
+ let (run, _server) = login_on_a_terminal_with(
+ dir.path(),
+ json!([{"id": ORG, "name": "Acme"}]),
+ ok(json!([{"id": ENV, "name": "prod"}])),
+ &[("Profile name:", b"prod\r")],
+ |_| {
+ let mut settings = Settings::open(path.clone()).unwrap();
+ settings
+ .config
+ .profiles
+ .insert("ci".into(), other("ci-key"));
+ settings.save().unwrap();
+ },
+ )
+ .await;
+ assert_eq!(run.code, Some(0), "{}", run.shown);
+ let profiles = saved_profiles(dir.path());
+ assert_eq!(profiles["ci"].account, "ci-key");
+ assert_eq!(profiles["prod"].account, LOGIN_NAME);
+ // The login, saved before the prompt, is kept as well.
+ assert!(
+ Settings::open(dir.path().to_path_buf())
+ .unwrap()
+ .config
+ .accounts
+ .contains_key(LOGIN_NAME)
+ );
+
+ // A name another command takes while the prompt is open is refused, and theirs is kept.
+ let dir = private_tempdir();
+ let path = dir.path().to_path_buf();
+ let (run, _server) = login_on_a_terminal_with(
+ dir.path(),
+ json!([{"id": ORG, "name": "Acme"}]),
+ ok(json!([{"id": ENV, "name": "prod"}])),
+ &[("Profile name:", b"prod\r")],
+ |_| {
+ let mut settings = Settings::open(path.clone()).unwrap();
+ settings
+ .config
+ .profiles
+ .insert("prod".into(), other("ci-key"));
+ settings.save().unwrap();
+ },
+ )
+ .await;
+ assert_eq!(run.code, Some(0), "{}", run.shown);
+ assert!(
+ run.shown
+ .contains("No profile was saved: Profile prod already exists."),
+ "{}",
+ run.shown
+ );
+ assert_eq!(saved_profiles(dir.path())["prod"].account, "ci-key");
+}
+
+#[tokio::test]
+async fn profiles_create_refuses_a_name_already_saved() {
+ let (server, dir) = fixture().await;
+ seeded_api_key(dir.path(), "saved", ACCOUNT_KEY);
+ let create = || {
+ cli(dir.path(), &server)
+ .args([
+ "profiles",
+ "create",
+ "prod",
+ "--account",
+ "saved",
+ "--org",
+ ORG,
+ "--env",
+ ENV,
+ ])
+ .assert()
+ };
+ create().success();
+ let error = json_error(&create().code(2));
+ assert_eq!(error["error"]["message"], "Profile prod already exists");
+}