flatland3 0.1.0

Flatland3 terminal play client and account CLI
use clap::Subcommand;

use crate::api::ControlPlaneClient;
use crate::config::{default_api_base, SessionConfig};

#[derive(Debug, Subcommand)]
#[command(
    about = "Control plane authentication and API tokens",
    long_about = "Manage human login (email/password sessions) and automation API tokens.\n\
                  Requires flatland-control-plane running (default http://127.0.0.1:7380)."
)]
pub enum AuthCommand {
    /// Create a new account (email + password)
    Register {
        /// Account email
        #[arg(long)]
        email: String,
        /// Account password (min 8 characters)
        #[arg(long)]
        password: String,
        /// Display name shown in-game
        #[arg(long)]
        name: String,
        /// Control plane base URL
        #[arg(long, default_value_t = default_api_base())]
        api: String,
        /// Skip automatic login after registration
        #[arg(long)]
        no_login: bool,
    },
    /// Log in with email and password; saves session to ~/.config/flatland/session.json
    Login {
        /// Account email
        #[arg(long)]
        email: String,
        /// Account password (min 8 characters)
        #[arg(long)]
        password: String,
        /// Control plane base URL
        #[arg(long, default_value_t = default_api_base())]
        api: String,
    },
    /// Create, list, or revoke API tokens for agents and CI
    #[command(subcommand)]
    Token(TokenCommand),
}

#[derive(Debug, Subcommand)]
pub enum TokenCommand {
    /// Create a token (plaintext shown once); requires login session
    Create {
        /// Human-readable label (e.g. ci-bot, my-agent)
        #[arg(long)]
        name: String,
        /// Scopes: play, read, write (comma-separated)
        #[arg(long, value_delimiter = ',')]
        scope: Vec<String>,
        /// Use flat_test_ prefix instead of flat_live_
        #[arg(long)]
        test: bool,
    },
    /// List token prefixes and metadata (secrets are never shown)
    List,
    /// Revoke a token by id (from list output)
    Revoke {
        /// Token UUID
        id: uuid::Uuid,
    },
}

pub async fn run(command: AuthCommand) -> anyhow::Result<()> {
    match command {
        AuthCommand::Register {
            email,
            password,
            name,
            api,
            no_login,
        } => register(&email, &password, &name, &api, !no_login).await,
        AuthCommand::Login { email, password, api } => login(&email, &password, &api).await,
        AuthCommand::Token(command) => run_token(command).await,
    }
}

async fn register(
    email: &str,
    password: &str,
    display_name: &str,
    api: &str,
    login_after: bool,
) -> anyhow::Result<()> {
    let client = ControlPlaneClient::new(api);
    #[derive(serde::Deserialize)]
    struct RegisterData {
        account: AccountSummary,
    }
    #[derive(serde::Deserialize)]
    struct AccountSummary {
        email: String,
        display_name: String,
    }

    let data: RegisterData = client
        .post(
            "/v1/auth/register",
            &serde_json::json!({
                "email": email,
                "password": password,
                "display_name": display_name,
            }),
            None,
        )
        .await?;

    println!(
        "Registered {} ({})",
        data.account.email, data.account.display_name
    );

    if login_after {
        login(email, password, api).await?;
    } else {
        println!("Run `flatland3 auth login --email {email} --password '…'` to sign in.");
    }
    Ok(())
}

async fn login(email: &str, password: &str, api: &str) -> anyhow::Result<()> {
    let client = ControlPlaneClient::new(api);
    #[derive(serde::Deserialize)]
    struct LoginData {
        session_token: String,
    }

    let data: LoginData = client
        .post(
            "/v1/auth/login",
            &serde_json::json!({ "email": email, "password": password }),
            None,
        )
        .await?;

    let config = SessionConfig {
        api_base: api.trim_end_matches('/').to_string(),
        session_token: data.session_token,
    };
    config.save()?;
    println!(
        "Logged in. Session saved to {}",
        SessionConfig::path()?.display()
    );
    Ok(())
}

async fn run_token(command: TokenCommand) -> anyhow::Result<()> {
    let session = SessionConfig::load()?;
    let client = ControlPlaneClient::new(&session.api_base);

    match command {
        TokenCommand::Create { name, scope, test } => {
            #[derive(serde::Deserialize)]
            struct CreateData {
                token: String,
                record: serde_json::Value,
            }

            let data: CreateData = client
                .post(
                    "/v1/me/tokens",
                    &serde_json::json!({
                        "label": name,
                        "scopes": scope,
                        "test": test,
                    }),
                    Some(&session.session_token),
                )
                .await?;

            println!("API token (save now — shown once):\n{}", data.token);
            println!("\nRecord: {}", serde_json::to_string_pretty(&data.record)?);
        }
        TokenCommand::List => {
            #[derive(serde::Deserialize)]
            struct ListData {
                tokens: Vec<serde_json::Value>,
            }

            let data: ListData = client
                .get("/v1/me/tokens", &session.session_token)
                .await?;
            println!("{}", serde_json::to_string_pretty(&data.tokens)?);
        }
        TokenCommand::Revoke { id } => {
            client
                .delete::<serde_json::Value>(
                    &format!("/v1/me/tokens/{id}"),
                    &session.session_token,
                )
                .await?;
            println!("Revoked token {id}");
        }
    }

    Ok(())
}