From af4fb0902056517f1cccd9d83ba0787ab5f2a697 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:16:37 +0000 Subject: [PATCH 1/2] refactor(cli): share JSON output across commands; serialize identities Move print_json from the paste commands' module to the crate root so the auth commands can use it too, and derive Serialize on the client's Identity, AuthenticatedUser and ApiTokenMetadata. ApiTokenMetadata holds only the token's id, name and creation time, never the secret. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- crates/cli/src/base/create.rs | 2 +- crates/cli/src/base/delete.rs | 2 +- crates/cli/src/base/deleted.rs | 2 +- crates/cli/src/base/mod.rs | 1 - crates/cli/src/base/restore.rs | 2 +- crates/cli/src/base/show.rs | 2 +- crates/cli/src/main.rs | 1 + crates/cli/src/{base => }/output.rs | 0 crates/client/src/lib.rs | 6 +++--- 9 files changed, 9 insertions(+), 9 deletions(-) rename crates/cli/src/{base => }/output.rs (100%) 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..8d05132 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; 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, From adf64562b64bbba9cf24cd5caec36d47fa5bbefe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:16:51 +0000 Subject: [PATCH 2/2] feat(cli): --json for auth login, logout and whoami Finish RFD 3's machine-readable output item: every CLI command now takes --json and prints one line of JSON on stdout. - whoami: {server, user, token} - login: {status, profile, server, user, token}, where status is logged_in, already_logged_in or environment (TEXTBIN_TOKEN). The API token secret is never printed. - logout: {profile, server, revoked, logged_out}. profile is null when only TEXTBIN_TOKEN was revoked; server is set only when a token was revoked. The email prompt now goes to stderr, like the password prompt, so stdout carries only the result. Human output is unchanged apart from that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019fnjMN7WpEjQJD2zveakV6 --- crates/cli/src/auth/login.rs | 108 +++++++++++++++++++++++++++++----- crates/cli/src/auth/logout.rs | 49 ++++++++++++++- crates/cli/src/auth/mod.rs | 4 +- crates/cli/src/auth/whoami.rs | 28 ++++++++- crates/cli/src/main.rs | 11 ++++ 5 files changed, 180 insertions(+), 20 deletions(-) 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/main.rs b/crates/cli/src/main.rs index 8d05132..0e29ca5 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -139,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:?}"); + } + } }