feat(cli): add token management commands

Co-authored-by: jnsgruk <668505+jnsgruk@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-25 09:54:16 +00:00 committed by Jon Seager
parent d96f2c27e0
commit 0b9cfefce5
No known key found for this signature in database
7 changed files with 195 additions and 1 deletions

31
Cargo.lock generated
View file

@ -341,6 +341,7 @@ dependencies = [
"once_cell", "once_cell",
"rand 0.8.5", "rand 0.8.5",
"reqwest", "reqwest",
"rpassword",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@ -1874,6 +1875,17 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rpassword"
version = "7.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.9" version = "0.9.9"
@ -1894,6 +1906,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rtoolbox"
version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.1"
@ -3053,6 +3075,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"

View file

@ -19,6 +19,7 @@ block-id = "0.2.1"
chrono = { version = "0.4", features = ["serde", "clock"] } chrono = { version = "0.4", features = ["serde", "clock"] }
clap = { version = "4.5", features = ["derive", "env"] } clap = { version = "4.5", features = ["derive", "env"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] } reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
rpassword = "7.3"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
once_cell = "1.19" once_cell = "1.19"

View file

@ -1,11 +1,13 @@
pub mod roasters; pub mod roasters;
pub mod roasts; pub mod roasts;
pub mod tokens;
use std::net::SocketAddr; use std::net::SocketAddr;
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use roasters::{AddRoasterCommand, DeleteRoasterCommand, GetRoasterCommand, UpdateRoasterCommand}; use roasters::{AddRoasterCommand, DeleteRoasterCommand, GetRoasterCommand, UpdateRoasterCommand};
use roasts::{AddRoastCommand, DeleteRoastCommand, GetRoastCommand, ListRoastsCommand}; use roasts::{AddRoastCommand, DeleteRoastCommand, GetRoastCommand, ListRoastsCommand};
use tokens::{CreateTokenCommand, RevokeTokenCommand};
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
#[command(author, version, about = "Track coffee roasts, brews, and cups", long_about = None)] #[command(author, version, about = "Track coffee roasts, brews, and cups", long_about = None)]
@ -27,6 +29,14 @@ pub enum Commands {
#[command(name = "serve")] #[command(name = "serve")]
Serve(ServeCommand), Serve(ServeCommand),
// Tokens
#[command(name = "create-token")]
CreateToken(CreateTokenCommand),
#[command(name = "list-tokens")]
ListTokens,
#[command(name = "revoke-token")]
RevokeToken(RevokeTokenCommand),
// Roasters // Roasters
#[command(name = "add-roaster")] #[command(name = "add-roaster")]
AddRoaster(AddRoasterCommand), AddRoaster(AddRoasterCommand),

60
src/cli/tokens.rs Normal file
View file

@ -0,0 +1,60 @@
use anyhow::{Context, Result};
use clap::Args;
use std::io::{self, Write};
use crate::cli::print_json;
use crate::client::BrewlogClient;
#[derive(Debug, Args)]
pub struct CreateTokenCommand {
/// A descriptive name for this token
#[arg(long)]
pub name: String,
}
#[derive(Debug, Args)]
pub struct RevokeTokenCommand {
/// The ID of the token to revoke
#[arg(long)]
pub id: String,
}
pub async fn create_token(client: &BrewlogClient, cmd: CreateTokenCommand) -> Result<()> {
// Prompt for username
print!("Username: ");
io::stdout().flush()?;
let mut username = String::new();
io::stdin().read_line(&mut username)?;
let username = username.trim();
// Prompt for password (without echo)
let password = rpassword::prompt_password("Password: ")
.context("failed to read password")?;
// Create the token
let token_response = client
.tokens()
.create(&username, &password, &cmd.name)
.await?;
println!("\nToken created successfully!");
println!("Token ID: {}", token_response.id);
println!("Token Name: {}", token_response.name);
println!("\n⚠️ Save this token securely - it will not be shown again:");
println!("\n{}", token_response.token);
println!("\nExport it in your environment:");
println!(" export BREWLOG_TOKEN={}", token_response.token);
Ok(())
}
pub async fn list_tokens(client: &BrewlogClient) -> Result<()> {
let tokens = client.tokens().list().await?;
print_json(&tokens)
}
pub async fn revoke_token(client: &BrewlogClient, cmd: RevokeTokenCommand) -> Result<()> {
let token = client.tokens().revoke(&cmd.id).await?;
println!("Token revoked successfully");
print_json(&token)
}

View file

@ -1,5 +1,6 @@
pub mod roasters; pub mod roasters;
pub mod roasts; pub mod roasts;
pub mod tokens;
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use reqwest::{Client, Url}; use reqwest::{Client, Url};
@ -9,6 +10,7 @@ use crate::server::errors::ErrorResponse;
pub struct BrewlogClient { pub struct BrewlogClient {
base_url: Url, base_url: Url,
http: Client, http: Client,
token: Option<String>,
} }
impl BrewlogClient { impl BrewlogClient {
@ -18,6 +20,8 @@ impl BrewlogClient {
normalized.set_path(&format!("{}/", normalized.path().trim_end_matches('/'))); normalized.set_path(&format!("{}/", normalized.path().trim_end_matches('/')));
} }
let token = std::env::var("BREWLOG_TOKEN").ok();
let http = Client::builder() let http = Client::builder()
.user_agent("brewlog-cli/0.1") .user_agent("brewlog-cli/0.1")
.build() .build()
@ -26,6 +30,7 @@ impl BrewlogClient {
Ok(Self { Ok(Self {
base_url: normalized, base_url: normalized,
http, http,
token,
}) })
} }
@ -42,6 +47,10 @@ impl BrewlogClient {
roasts::RoastsClient::new(self) roasts::RoastsClient::new(self)
} }
pub fn tokens(&self) -> tokens::TokensClient<'_> {
tokens::TokensClient::new(self)
}
pub(crate) fn endpoint(&self, path: &str) -> Result<Url> { pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
self.base_url self.base_url
.join(path) .join(path)
@ -52,6 +61,15 @@ impl BrewlogClient {
&self.http &self.http
} }
/// Build a request with authentication if token is available
pub(crate) fn request(&self, method: reqwest::Method, url: Url) -> reqwest::RequestBuilder {
let mut request = self.http.request(method, url);
if let Some(token) = &self.token {
request = request.bearer_auth(token);
}
request
}
pub(crate) async fn handle_response<T>(&self, response: reqwest::Response) -> Result<T> pub(crate) async fn handle_response<T>(&self, response: reqwest::Response) -> Result<T>
where where
T: serde::de::DeserializeOwned, T: serde::de::DeserializeOwned,

69
src/client/tokens.rs Normal file
View file

@ -0,0 +1,69 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::client::BrewlogClient;
use crate::domain::tokens::Token;
pub struct TokensClient<'a> {
client: &'a BrewlogClient,
}
impl<'a> TokensClient<'a> {
pub fn new(client: &'a BrewlogClient) -> Self {
Self { client }
}
pub async fn create(
&self,
username: &str,
password: &str,
name: &str,
) -> Result<TokenResponse> {
let url = self.client.endpoint("api/v1/tokens")?;
let body = CreateTokenRequest {
username: username.to_string(),
password: password.to_string(),
name: name.to_string(),
};
let response = self
.client
.http_client()
.post(url)
.json(&body)
.send()
.await?;
self.client.handle_response(response).await
}
pub async fn list(&self) -> Result<Vec<Token>> {
let url = self.client.endpoint("api/v1/tokens")?;
let response = self.client.http_client().get(url).send().await?;
self.client.handle_response(response).await
}
pub async fn revoke(&self, id: &str) -> Result<Token> {
let url = self.client.endpoint(&format!("api/v1/tokens/{}/revoke", id))?;
let response = self.client.http_client().post(url).send().await?;
self.client.handle_response(response).await
}
}
#[derive(Debug, Serialize)]
struct CreateTokenRequest {
username: String,
password: String,
name: String,
}
#[derive(Debug, Deserialize)]
pub struct TokenResponse {
pub id: String,
pub name: String,
pub token: String,
}

View file

@ -1,5 +1,5 @@
use anyhow::Result; use anyhow::Result;
use brewlog::cli::{Cli, Commands, ServeCommand, roasters, roasts}; use brewlog::cli::{Cli, Commands, ServeCommand, roasters, roasts, tokens};
use brewlog::client::BrewlogClient; use brewlog::client::BrewlogClient;
use brewlog::server::{ServerConfig, serve}; use brewlog::server::{ServerConfig, serve};
use clap::Parser; use clap::Parser;
@ -18,6 +18,11 @@ async fn main() -> Result<()> {
command => { command => {
let client = BrewlogClient::from_base_url(&cli.api_url)?; let client = BrewlogClient::from_base_url(&cli.api_url)?;
match command { match command {
// Tokens
Commands::CreateToken(cmd) => tokens::create_token(&client, cmd).await,
Commands::ListTokens => tokens::list_tokens(&client).await,
Commands::RevokeToken(cmd) => tokens::revoke_token(&client, cmd).await,
// Roasters // Roasters
Commands::AddRoaster(cmd) => roasters::add_roaster(&client, cmd).await, Commands::AddRoaster(cmd) => roasters::add_roaster(&client, cmd).await,
Commands::ListRoasters => roasters::list_roasters(&client).await, Commands::ListRoasters => roasters::list_roasters(&client).await,