use crate::config::{ApiConfig, CliAuthState, SshConfig};
use chrono::{DateTime, Utc};
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use std::env;
use tokio::time::Duration;
const CLI_LOGIN_REQUIRED_HINT: &str =
"Run `xbp login` first so XBP can verify your session against the dashboard.";
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CliAuthSessionResponse {
pub(crate) user: CliAuthSessionUser,
pub(crate) token: CliAuthSessionToken,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct CliAuthSessionUser {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) email: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CliAuthSessionToken {
pub(crate) id: String,
pub(crate) label: Option<String>,
pub(crate) prefix: String,
pub(crate) created_at: Option<String>,
pub(crate) expires_at: Option<String>,
pub(crate) last_used_at: Option<String>,
}
#[derive(Debug)]
pub(crate) enum CliSessionError {
Unauthorized,
Other(String),
}
#[derive(Debug, Clone)]
enum CliTokenSource {
Config(String),
Env(String),
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct VersionActivityLinearInitiative {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) url: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CliVersionActivityPayload {
pub(crate) command_kind: String,
pub(crate) repository_owner: Option<String>,
pub(crate) repository_name: Option<String>,
pub(crate) scope_kind: String,
pub(crate) scope_label: String,
pub(crate) version: String,
pub(crate) tag_name: Option<String>,
pub(crate) title: Option<String>,
pub(crate) release_url: Option<String>,
pub(crate) message_markdown: Option<String>,
pub(crate) published_initiatives: Vec<VersionActivityLinearInitiative>,
}
pub(crate) fn cli_request_client() -> Result<Client, String> {
Client::builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))
}
pub(crate) async fn fetch_cli_session(
client: &Client,
api: &ApiConfig,
token: &str,
) -> Result<CliAuthSessionResponse, CliSessionError> {
let response = client
.get(api.cli_auth_session_endpoint())
.bearer_auth(token)
.send()
.await
.map_err(|e| CliSessionError::Other(format!("Failed to verify CLI login token: {}", e)))?;
if matches!(
response.status(),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
) {
return Err(CliSessionError::Unauthorized);
}
let response = response.error_for_status().map_err(|e| {
CliSessionError::Other(format!("CLI login token verification failed: {}", e))
})?;
response
.json::<CliAuthSessionResponse>()
.await
.map_err(|e| CliSessionError::Other(format!("Failed to parse CLI session response: {}", e)))
}
pub(crate) fn save_cli_login_state(
token: String,
session: &CliAuthSessionResponse,
) -> Result<(), String> {
let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
config.xbp_api_token = Some(token);
config.cli_auth = Some(cli_auth_state_from_session(session));
config.save()
}
pub(crate) fn clear_cli_login_state(clear_token: bool) -> Result<(), String> {
let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
if clear_token {
config.xbp_api_token = None;
}
config.cli_auth = None;
config.save()
}
pub(crate) fn resolve_cli_access_token() -> Result<String, String> {
resolve_cli_token_source()
.map(|source| source.token().to_string())
.ok_or_else(|| CLI_LOGIN_REQUIRED_HINT.to_string())
}
pub(crate) async fn require_authenticated_cli_session() -> Result<CliAuthSessionResponse, String> {
let token_source =
resolve_cli_token_source().ok_or_else(|| CLI_LOGIN_REQUIRED_HINT.to_string())?;
let api = ApiConfig::load();
let client = cli_request_client()?;
let token = token_source.token().to_string();
match fetch_cli_session(&client, &api, &token).await {
Ok(session) => {
if matches!(token_source, CliTokenSource::Config(_)) {
save_verified_state(&session)?;
}
Ok(session)
}
Err(CliSessionError::Unauthorized) => {
if matches!(token_source, CliTokenSource::Config(_)) {
let _ = clear_cli_login_state(true);
}
Err("Your stored CLI session is no longer valid. Run `xbp login` again.".to_string())
}
Err(CliSessionError::Other(error)) => Err(error),
}
}
pub(crate) async fn run_login_status() -> Result<(), String> {
let stored = SshConfig::load().ok().and_then(|cfg| cfg.cli_auth);
let token_source = resolve_cli_token_source();
let Some(token_source) = token_source else {
println!("CLI login status: not signed in.");
if let Some(stored) = stored {
if let Some(expires_at) = stored.token_expires_at {
println!("Last known token expiry: {}", expires_at.to_rfc3339());
}
}
println!("{}", CLI_LOGIN_REQUIRED_HINT);
return Ok(());
};
let api = ApiConfig::load();
let client = cli_request_client()?;
match fetch_cli_session(&client, &api, token_source.token()).await {
Ok(session) => {
if matches!(token_source, CliTokenSource::Config(_)) {
save_verified_state(&session)?;
}
println!("CLI login status: signed in.");
println!("User: {} <{}>", session.user.name, session.user.email);
println!("Token id: {}", session.token.id);
println!("Token prefix: {}", session.token.prefix);
if let Some(label) = session.token.label.as_deref() {
println!("Token label: {}", label);
}
if let Some(created_at) = session.token.created_at.as_deref() {
println!("Issued at: {}", created_at);
}
if let Some(expires_at) = session.token.expires_at.as_deref() {
println!("Expires at: {}", expires_at);
} else {
println!("Expires at: none");
}
if let Some(last_used_at) = session.token.last_used_at.as_deref() {
println!("Last used at: {}", last_used_at);
}
println!("Token source: {}", token_source.label());
Ok(())
}
Err(CliSessionError::Unauthorized) => {
if matches!(token_source, CliTokenSource::Config(_)) {
let _ = clear_cli_login_state(true);
}
Err("The current CLI token is no longer valid. Run `xbp login` again.".to_string())
}
Err(CliSessionError::Other(error)) => Err(error),
}
}
pub(crate) async fn run_logout() -> Result<(), String> {
let token_source = resolve_cli_token_source();
if token_source.is_none() {
let _ = clear_cli_login_state(true);
println!("CLI login status: already signed out.");
return Ok(());
}
let token_source = token_source.expect("checked above");
let api = ApiConfig::load();
let client = cli_request_client()?;
let response = client
.delete(api.cli_auth_session_endpoint())
.bearer_auth(token_source.token())
.send()
.await
.map_err(|e| format!("Failed to contact dashboard while signing out: {}", e))?;
if !matches!(
response.status(),
StatusCode::OK
| StatusCode::NO_CONTENT
| StatusCode::UNAUTHORIZED
| StatusCode::FORBIDDEN
| StatusCode::NOT_FOUND
) {
return Err(format!(
"Dashboard sign-out failed with status {} {}.",
response.status().as_u16(),
response.status().canonical_reason().unwrap_or("")
));
}
if matches!(token_source, CliTokenSource::Config(_)) {
clear_cli_login_state(true)?;
} else {
clear_cli_login_state(false)?;
}
println!("CLI login status: signed out.");
if matches!(token_source, CliTokenSource::Env(_)) {
println!("Environment token remains set in `XBP_API_TOKEN` until you unset it.");
}
Ok(())
}
pub(crate) async fn fetch_linear_api_key_from_dashboard() -> Result<Option<String>, String> {
let Some(token_source) = resolve_cli_token_source() else {
return Ok(None);
};
let api = ApiConfig::load();
let client = cli_request_client()?;
let response = client
.get(api.cli_linear_key_endpoint())
.bearer_auth(token_source.token())
.send()
.await
.map_err(|e| format!("Failed to fetch Linear key from dashboard: {}", e))?;
match response.status() {
StatusCode::OK => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinearKeyResponse {
api_key: String,
}
let body = response
.json::<LinearKeyResponse>()
.await
.map_err(|e| format!("Failed to parse dashboard Linear key response: {}", e))?;
Ok(Some(body.api_key))
}
StatusCode::NOT_FOUND => Ok(None),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(
"The current CLI session cannot read dashboard integrations. Run `xbp login` again."
.to_string(),
),
_ => Err(format!(
"Dashboard Linear key lookup failed with status {} {}.",
response.status().as_u16(),
response.status().canonical_reason().unwrap_or("")
)),
}
}
pub(crate) async fn post_version_activity(
payload: &CliVersionActivityPayload,
) -> Result<(), String> {
let Some(token_source) = resolve_cli_token_source() else {
return Err(CLI_LOGIN_REQUIRED_HINT.to_string());
};
let api = ApiConfig::load();
let client = cli_request_client()?;
let response = client
.post(api.cli_version_activity_endpoint())
.bearer_auth(token_source.token())
.json(payload)
.send()
.await
.map_err(|e| format!("Failed to sync version activity to dashboard: {}", e))?;
if response.status().is_success() {
return Ok(());
}
if should_skip_missing_version_activity_route(response.status()) {
return Ok(());
}
Err(format!(
"Dashboard version activity sync failed with status {} {}.",
response.status().as_u16(),
response.status().canonical_reason().unwrap_or("")
))
}
fn should_skip_missing_version_activity_route(status: StatusCode) -> bool {
status == StatusCode::NOT_FOUND
}
fn resolve_cli_token_source() -> Option<CliTokenSource> {
if let Ok(value) = env::var("XBP_API_TOKEN") {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(CliTokenSource::Env(trimmed.to_string()));
}
}
SshConfig::load()
.ok()
.and_then(|cfg| cfg.xbp_api_token)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(CliTokenSource::Config)
}
fn parse_optional_datetime(value: Option<&str>) -> Option<DateTime<Utc>> {
let raw = value?.trim();
if raw.is_empty() {
return None;
}
DateTime::parse_from_rfc3339(raw)
.ok()
.map(|value| value.with_timezone(&Utc))
}
#[cfg(test)]
mod tests {
use super::should_skip_missing_version_activity_route;
use reqwest::StatusCode;
#[test]
fn version_activity_sync_skips_missing_dashboard_route() {
assert!(should_skip_missing_version_activity_route(
StatusCode::NOT_FOUND
));
assert!(!should_skip_missing_version_activity_route(
StatusCode::INTERNAL_SERVER_ERROR
));
assert!(!should_skip_missing_version_activity_route(
StatusCode::UNAUTHORIZED
));
}
}
fn cli_auth_state_from_session(session: &CliAuthSessionResponse) -> CliAuthState {
CliAuthState {
user_id: Some(session.user.id.clone()),
user_name: Some(session.user.name.clone()),
user_email: Some(session.user.email.clone()),
token_label: session.token.label.clone(),
token_prefix: Some(session.token.prefix.clone()),
token_created_at: parse_optional_datetime(session.token.created_at.as_deref()),
token_expires_at: parse_optional_datetime(session.token.expires_at.as_deref()),
last_verified_at: Some(Utc::now()),
}
}
fn save_verified_state(session: &CliAuthSessionResponse) -> Result<(), String> {
let mut config = SshConfig::load().unwrap_or_else(|_| SshConfig::new());
if config.xbp_api_token.is_none() {
return Ok(());
}
config.cli_auth = Some(cli_auth_state_from_session(session));
config.save()
}
impl CliTokenSource {
fn token(&self) -> &str {
match self {
Self::Config(value) | Self::Env(value) => value.as_str(),
}
}
fn label(&self) -> &'static str {
match self {
Self::Config(_) => "config",
Self::Env(_) => "environment",
}
}
}