diff --git a/crates/cli/src/auth/login.rs b/crates/cli/src/auth/login.rs index af8542b..47d8ad5 100644 --- a/crates/cli/src/auth/login.rs +++ b/crates/cli/src/auth/login.rs @@ -1,8 +1,10 @@ use anyhow::{Context, Result, anyhow}; use clap::Args; +use serde::Serialize; use std::io::{self, Write}; -use textbin_client::Client; +use textbin_client::{ApiTokenMetadata, AuthenticatedUser, Client, Identity}; +use crate::output::print_json; use crate::settings::{Settings, environment_token}; #[derive(Args)] @@ -22,6 +24,29 @@ pub struct LoginArgs { /// Import an existing API token through a hidden prompt #[arg(long)] with_token: bool, + + /// Print the outcome as JSON; prompts still go to the terminal + #[arg(long, default_value_t = false)] + json: bool, +} + +/// How the profile came to be authenticated. The API token itself is never +/// printed; scripts that need it should pass it in through TEXTBIN_TOKEN. +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +enum LoginStatus { + LoggedIn, + AlreadyLoggedIn, + Environment, +} + +#[derive(Serialize)] +struct LoginOutput<'a> { + status: LoginStatus, + profile: &'a str, + server: &'a str, + user: &'a AuthenticatedUser, + token: &'a ApiTokenMetadata, } pub fn handle(args: &LoginArgs, settings: &Settings) -> Result<()> { @@ -41,12 +66,13 @@ pub fn handle(args: &LoginArgs, settings: &Settings) -> Result<()> { let client = Client::try_new(&server_url)?.with_api_token(Some(token)); let identity = client.identity()?; settings.save_environment_login(&profile_name, &server_url, &identity)?; - print_identity( - "Authenticated through TEXTBIN_TOKEN", + return report( + args, + LoginStatus::Environment, + &profile_name, &server_url, - &identity.user.email, + &identity, ); - return Ok(()); } if args.server.is_none() @@ -57,8 +83,13 @@ pub fn handle(args: &LoginArgs, settings: &Settings) -> Result<()> { Ok(identity) => { settings.migrate_login(&profile_name, &identity)?; settings.activate_profile(&profile_name)?; - print_identity("Already logged in", client.base_url(), &identity.user.email); - return Ok(()); + return report( + args, + LoginStatus::AlreadyLoggedIn, + &profile_name, + client.base_url(), + &identity, + ); } Err(error) if error.unauthorized() => { settings.forget_login(&profile_name)?; @@ -99,14 +130,20 @@ pub fn handle(args: &LoginArgs, settings: &Settings) -> Result<()> { return Err(storage_error); } - print_identity("Logged in", &server_url, &identity.user.email); - - Ok(()) + report( + args, + LoginStatus::LoggedIn, + &profile_name, + &server_url, + &identity, + ) } +// Prompts go to stderr so stdout carries only the result, which `--json` +// relies on. rpassword already reads the password from the terminal. fn prompt(label: &str) -> Result { - print!("{label}"); - io::stdout().flush().context("could not write prompt")?; + eprint!("{label}"); + io::stderr().flush().context("could not write prompt")?; let mut value = String::new(); io::stdin() @@ -115,8 +152,30 @@ fn prompt(label: &str) -> Result { Ok(value.trim().to_string()) } -fn print_identity(prefix: &str, server_url: &str, email: &str) { - println!("{prefix} as {email} on {server_url}"); +fn report( + args: &LoginArgs, + status: LoginStatus, + profile: &str, + server: &str, + identity: &Identity, +) -> Result<()> { + if args.json { + return print_json(&LoginOutput { + status, + profile, + server, + user: &identity.user, + token: &identity.token, + }); + } + + let prefix = match status { + LoginStatus::LoggedIn => "Logged in", + LoginStatus::AlreadyLoggedIn => "Already logged in", + LoginStatus::Environment => "Authenticated through TEXTBIN_TOKEN", + }; + println!("{prefix} as {} on {server}", identity.user.email); + Ok(()) } #[cfg(test)] @@ -159,6 +218,7 @@ mod tests { profile: Some("demo".to_string()), name: "Textbin CLI".to_string(), with_token: false, + json: false, }, &settings, ); @@ -175,6 +235,25 @@ mod tests { fs::remove_dir_all(path.parent().unwrap()).unwrap(); } + #[test] + fn json_output_names_the_outcome_without_the_secret() { + let identity = identity(); + let output = serde_json::to_value(LoginOutput { + status: LoginStatus::AlreadyLoggedIn, + profile: "demo", + server: "https://textbin.example.com", + user: &identity.user, + token: &identity.token, + }) + .unwrap(); + + assert_eq!(output["status"], "already_logged_in"); + assert_eq!(output["profile"], "demo"); + assert_eq!(output["user"]["email"], identity.user.email); + assert_eq!(output["token"]["id"], identity.token.id); + assert!(output["token"].get("api_token").is_none()); + } + #[test] fn environment_token_profile_becomes_active() { let _environment = TEST_ENVIRONMENT.lock().unwrap(); @@ -200,6 +279,7 @@ mod tests { profile: Some("demo".to_string()), name: "Textbin CLI".to_string(), with_token: false, + json: false, }, &settings, ); diff --git a/crates/cli/src/auth/logout.rs b/crates/cli/src/auth/logout.rs index ededddd..db02f4d 100644 --- a/crates/cli/src/auth/logout.rs +++ b/crates/cli/src/auth/logout.rs @@ -1,7 +1,9 @@ use anyhow::{Context, Result, bail}; use clap::Args; +use serde::Serialize; use textbin_client::Client; +use crate::output::print_json; use crate::settings::{Settings, environment_token}; #[derive(Args)] @@ -13,6 +15,20 @@ pub struct LogoutArgs { /// Configuration profile to log out #[arg(long)] profile: Option, + + /// Print the outcome as JSON + #[arg(long, default_value_t = false)] + json: bool, +} + +/// `profile` is absent when TEXTBIN_TOKEN was revoked, since no stored +/// profile was involved; `server` is present only when a token was revoked. +#[derive(Serialize)] +struct LogoutOutput<'a> { + profile: Option<&'a str>, + server: Option<&'a str>, + revoked: bool, + logged_out: bool, } pub fn handle(args: &LogoutArgs, settings: &Settings) -> Result<()> { @@ -34,6 +50,16 @@ pub fn handle(args: &LogoutArgs, settings: &Settings) -> Result<()> { }; let client = Client::try_new(server_url)?.with_api_token(Some(token)); client.revoke_current_token()?; + + if args.json { + return print_json(&LogoutOutput { + profile: None, + server: Some(client.base_url()), + revoked: true, + logged_out: false, + }); + } + println!("Revoked the API token on {}", client.base_url()); println!("TEXTBIN_TOKEN is still set but now references a revoked token"); return Ok(()); @@ -45,16 +71,30 @@ pub fn handle(args: &LogoutArgs, settings: &Settings) -> Result<()> { .unwrap_or(settings.active_profile_name()?); settings.validate_profile_name(&profile_name)?; - if args.revoke { + let revoked_on = if args.revoke { let client = settings .stored_client(&profile_name)? .context("profile is not authenticated; run `textbin auth login`")?; client.revoke_current_token()?; - println!("Revoked the API token on {}", client.base_url()); - } + if !args.json { + println!("Revoked the API token on {}", client.base_url()); + } + Some(client.base_url().to_string()) + } else { + None + }; let removed = settings.forget_login(&profile_name)?; + if args.json { + return print_json(&LogoutOutput { + profile: Some(&profile_name), + server: revoked_on.as_deref(), + revoked: revoked_on.is_some(), + logged_out: removed, + }); + } + if removed { println!("Logged out of profile {profile_name}"); } else { @@ -100,6 +140,7 @@ mod tests { &LogoutArgs { revoke: true, profile: Some("demo".to_string()), + json: false, }, &settings, ); @@ -145,6 +186,7 @@ mod tests { &LogoutArgs { revoke: false, profile: Some("demo".to_string()), + json: false, }, &settings, ); @@ -178,6 +220,7 @@ mod tests { &LogoutArgs { revoke: true, profile: None, + json: false, }, &settings, ); diff --git a/crates/cli/src/auth/mod.rs b/crates/cli/src/auth/mod.rs index c1ec839..750c580 100644 --- a/crates/cli/src/auth/mod.rs +++ b/crates/cli/src/auth/mod.rs @@ -13,13 +13,13 @@ pub enum Commands { /// Log out of a Textbin server Logout(logout::LogoutArgs), /// Show the authenticated account - Whoami, + Whoami(whoami::WhoamiArgs), } pub fn handle(command: &Commands, settings: &Settings) -> anyhow::Result<()> { match command { Commands::Login(args) => login::handle(args, settings), Commands::Logout(args) => logout::handle(args, settings), - Commands::Whoami => whoami::handle(settings), + Commands::Whoami(args) => whoami::handle(args, settings), } } diff --git a/crates/cli/src/auth/whoami.rs b/crates/cli/src/auth/whoami.rs index e7d3f10..edda9bc 100644 --- a/crates/cli/src/auth/whoami.rs +++ b/crates/cli/src/auth/whoami.rs @@ -1,11 +1,37 @@ use anyhow::Result; +use clap::Args; +use serde::Serialize; +use textbin_client::{ApiTokenMetadata, AuthenticatedUser}; +use crate::output::print_json; use crate::settings::Settings; -pub fn handle(settings: &Settings) -> Result<()> { +#[derive(Args)] +pub struct WhoamiArgs { + /// Print the account, server and token as JSON + #[arg(long, default_value_t = false)] + json: bool, +} + +#[derive(Serialize)] +struct WhoamiOutput<'a> { + server: &'a str, + user: &'a AuthenticatedUser, + token: &'a ApiTokenMetadata, +} + +pub fn handle(args: &WhoamiArgs, settings: &Settings) -> Result<()> { let client = settings.client()?; let identity = client.identity()?; + if args.json { + return print_json(&WhoamiOutput { + server: client.base_url(), + user: &identity.user, + token: &identity.token, + }); + } + println!("{}", identity.user.email); println!("Server: {}", client.base_url()); println!("Token: {} ({})", identity.token.name, identity.token.id); diff --git a/crates/cli/src/base/create.rs b/crates/cli/src/base/create.rs index 6c20466..87b3dc8 100644 --- a/crates/cli/src/base/create.rs +++ b/crates/cli/src/base/create.rs @@ -1,4 +1,4 @@ -use crate::base::output::print_json; +use crate::output::print_json; use crate::settings::Settings; use clap::{Args, ValueEnum}; use std::fs; diff --git a/crates/cli/src/base/delete.rs b/crates/cli/src/base/delete.rs index 2aad5dd..adc7484 100644 --- a/crates/cli/src/base/delete.rs +++ b/crates/cli/src/base/delete.rs @@ -1,4 +1,4 @@ -use crate::base::output::print_json; +use crate::output::print_json; use crate::settings::Settings; use clap::Args; use serde::Serialize; diff --git a/crates/cli/src/base/deleted.rs b/crates/cli/src/base/deleted.rs index 68bcb29..3ec4a6d 100644 --- a/crates/cli/src/base/deleted.rs +++ b/crates/cli/src/base/deleted.rs @@ -1,4 +1,4 @@ -use crate::base::output::print_json; +use crate::output::print_json; use crate::settings::Settings; use clap::Args; use textbin_client::RemovedPaste; diff --git a/crates/cli/src/base/mod.rs b/crates/cli/src/base/mod.rs index 62a8364..8add2ab 100644 --- a/crates/cli/src/base/mod.rs +++ b/crates/cli/src/base/mod.rs @@ -1,7 +1,6 @@ mod create; mod delete; mod deleted; -mod output; mod restore; mod show; diff --git a/crates/cli/src/base/restore.rs b/crates/cli/src/base/restore.rs index abdf8ff..56d5c63 100644 --- a/crates/cli/src/base/restore.rs +++ b/crates/cli/src/base/restore.rs @@ -1,4 +1,4 @@ -use crate::base::output::print_json; +use crate::output::print_json; use crate::settings::Settings; use clap::Args; diff --git a/crates/cli/src/base/show.rs b/crates/cli/src/base/show.rs index 7fd6596..82895de 100644 --- a/crates/cli/src/base/show.rs +++ b/crates/cli/src/base/show.rs @@ -6,7 +6,7 @@ use lumis::themes; use std::io::{self, IsTerminal, Write}; use textbin_client::Paste; -use crate::base::output::print_json; +use crate::output::print_json; use crate::settings::Settings; #[derive(Args)] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 8ddf313..0e29ca5 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,5 +1,6 @@ mod auth; mod base; +mod output; mod settings; use std::path::PathBuf; @@ -138,4 +139,15 @@ mod tests { .is_ok() ); } + + #[test] + fn parses_auth_json_output() { + for args in [ + &["textbin", "auth", "whoami", "--json"][..], + &["textbin", "auth", "login", "--with-token", "--json"], + &["textbin", "auth", "logout", "--revoke", "--json"], + ] { + assert!(TextbinCli::try_parse_from(args).is_ok(), "{args:?}"); + } + } } diff --git a/crates/cli/src/base/output.rs b/crates/cli/src/output.rs similarity index 100% rename from crates/cli/src/base/output.rs rename to crates/cli/src/output.rs diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 21894a7..a4fd3e0 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -334,19 +334,19 @@ impl CreatedApiToken { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Identity { pub user: AuthenticatedUser, pub token: ApiTokenMetadata, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct AuthenticatedUser { pub id: String, pub email: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct ApiTokenMetadata { pub id: String, pub name: String,