sanity-cli 0.1.10

CLI for interacting with sanitycloud.io
use std::{path::PathBuf, process::Command as StdCommand};

use anyhow::Context;
use dialoguer::*;
use email_address_parser::EmailAddress;
use sanity_api::*;
use structopt::StructOpt;
use url::Url;
#[derive(StructOpt)]
struct CliOptions {
    /// Base URL for API calls.
    #[structopt(
        long,
        parse(try_from_str),
        default_value = "https://web-tb546tjmbq-uc.a.run.app/api/v0/"
    )]
    base_api_url: Url,

    /// Host to push images to.
    #[structopt(
        long,
        parse(try_from_str),
        default_value = "docker-registry-tb546tjmbq-uc.a.run.app"
    )]
    push_host: String,

    /// Command to run.
    #[structopt(subcommand)]
    command: Command,
}

#[derive(StructOpt)]
enum Command {
    Login {
        /// Token to login with.
        #[structopt(long)]
        token: Option<String>,
    },

    CreateProject,

    Push {
        /// Docker image ID or tag.
        docker_image: String,

        /// Project ID to push to.
        project_id: String,

        /// Task to push to.
        task_id: String,
    },
}

fn main() -> anyhow::Result<()> {
    stderrlog::new().verbosity(log::Level::Info).init()?;

    let options = CliOptions::from_args();
    let api = HttpApi::new(&options.base_api_url)?;

    if let Command::Login { token } = &options.command {
        let token = if let Some(token) = token {
            token.clone()
        } else {
            do_login(&api)?
        };
        let token_path = token_path()?;
        std::fs::create_dir_all(token_path.parent().expect("did join"))?;
        std::fs::write(&token_path, token)?;
        log::info!("Wrote token to {}", token_path.display());
        return Ok(());
    }

    let token = read_token().context("Reading token")?;

    match options.command {
        Command::Login { .. } => unreachable!(),

        Command::CreateProject => create_project(&api, token)?,
        Command::Push {
            docker_image,
            project_id,
            task_id,
        } => {
            let full_tag = format!(
                "{}/{project_id}/{task_id}",
                options.push_host.trim_end_matches("/")
            );

            log::info!("Tagging image as {full_tag:?}");
            let tag_status = StdCommand::new("docker")
                .args(["tag", &docker_image, &full_tag])
                .status()?;
            anyhow::ensure!(tag_status.success(), "docker tag failed");

            let push_status = StdCommand::new("docker")
                .args(["push", &full_tag])
                .status()?;
            anyhow::ensure!(push_status.success(), "docker push failed");

            let response = async_std::task::block_on(api.register_task_version(
                CreateTaskVersionRequest {
                    project_id,
                    task_id,
                },
                token,
            ))?;

            log::info!("Created task version: {response:#?}");
        }
    }

    Ok(())
}

fn sanity_dir() -> anyhow::Result<PathBuf> {
    Ok(dirs::home_dir()
        .ok_or_else(|| anyhow::anyhow!("Could not determine HOME directory"))?
        .join(".sanity"))
}

fn token_path() -> anyhow::Result<PathBuf> {
    Ok(sanity_dir()?.join("token.txt"))
}

fn read_token() -> anyhow::Result<String> {
    let path = token_path().context("Determining token path")?;
    Ok(std::fs::read_to_string(&path)
        .context(format!("Reading {}", path.display()))?
        .trim()
        .to_string())
}

fn do_login(api: &dyn Api) -> anyhow::Result<String> {
    let email: String = Input::new().with_prompt("Email").interact_text()?;
    let email =
        EmailAddress::parse(&email, None).ok_or_else(|| anyhow::anyhow!("Invalid email"))?;

    let password = Password::new().with_prompt("Password").interact()?;

    let login_response = async_std::task::block_on(api.login(LoginRequest {
        email: email.to_string(),
        password,
    }))?;

    let token = match login_response {
        LoginResponse::CreatedAccount { email, token } => {
            log::info!("Created an account with email {email}");
            token
        }
        LoginResponse::LoggedIn { email, token } => {
            log::info!("Logged in as {email}");
            token
        }
    };

    println!("{token}");
    Ok(token)
}

fn create_project(api: &dyn Api, token: String) -> anyhow::Result<()> {
    let nickname: String = Input::<String>::new()
        .with_prompt("Project nickname (optional)")
        .allow_empty(true)
        .interact_text()?
        .trim()
        .to_string();
    let nickname = if nickname.is_empty() {
        None
    } else {
        Some(nickname)
    };

    let create_response =
        async_std::task::block_on(api.create_project(CreateProjectRequest { nickname }, token))?;

    println!("{}", create_response.id);

    Ok(())
}