use std::{io::Write, str::FromStr};
use mz_frontegg_auth::AppPassword;
use mz_frontegg_client::client::app_password::CreateAppPasswordRequest;
use mz_frontegg_client::client::{Client as AdminClient, Credentials};
use mz_frontegg_client::config::{
ClientBuilder as AdminClientBuilder, ClientConfig as AdminClientConfig,
};
use mz_cloud_api::config::DEFAULT_ENDPOINT;
use serde::{Deserialize, Serialize};
use tabled::Tabled;
use tokio::{select, sync::mpsc};
use url::Url;
use crate::ui::OptionalStr;
use crate::{
config_file::TomlProfile,
context::{Context, ProfileContext},
error::Error,
error::Error::ProfileNameAlreadyExistsError,
server::server,
};
fn strip_api_from_endpoint(endpoint: Url) -> Url {
if let Some(domain) = endpoint.domain() {
if let Some(corrected_domain) = domain.strip_prefix("api.") {
let mut new_endpoint = endpoint.clone();
let _ = new_endpoint.set_host(Some(corrected_domain));
return new_endpoint;
}
};
endpoint
}
pub async fn init_with_browser(cloud_endpoint: Option<Url>) -> Result<AppPassword, Error> {
let (tx, mut rx) = mpsc::unbounded_channel();
let (server, port) = server(tx).await;
let mut url =
strip_api_from_endpoint(cloud_endpoint.unwrap_or_else(|| DEFAULT_ENDPOINT.clone()));
url.path_segments_mut()
.expect("constructor validated URL can be a base")
.extend(&["account", "login"]);
let mut query_pairs = url.query_pairs_mut();
query_pairs.append_pair(
"redirectUrl",
&format!("/access/cli?redirectUri=http://localhost:{port}&tokenDescription=Materialize%20CLI%20%28mz%29"),
);
let open_url = &query_pairs.finish().as_str().replace("cloud", "console");
if let Err(_err) = open::that(open_url) {
println!(
"Error: Unable to launch a web browser. Access the login page using this link: '{:?}', or execute `mz profile init --no-browser` in your command line.",
open_url
)
}
select! {
_ = server => unreachable!("server should not shut down"),
result = rx.recv() => {
match result {
Some(app_password_result) => app_password_result,
None => { panic!("failed to login via browser") },
}
}
}
}
pub async fn init_without_browser(admin_endpoint: Option<Url>) -> Result<AppPassword, Error> {
let mut email = String::new();
print!("Email: ");
let _ = std::io::stdout().flush();
std::io::stdin().read_line(&mut email).unwrap();
if email.ends_with('\n') {
email.pop();
if email.ends_with('\r') {
email.pop();
}
}
print!("Password: ");
let _ = std::io::stdout().flush();
let password = rpassword::read_password().unwrap();
let mut admin_client_builder = AdminClientBuilder::default();
if let Some(admin_endpoint) = admin_endpoint {
admin_client_builder = admin_client_builder.endpoint(admin_endpoint);
}
let admin_client: AdminClient = admin_client_builder.build(AdminClientConfig {
authentication: mz_frontegg_client::client::Authentication::Credentials(Credentials {
email,
password,
}),
});
let app_password = admin_client
.create_app_password(CreateAppPasswordRequest {
description: "Materialize CLI (mz)",
})
.await?;
Ok(app_password)
}
pub async fn init(
scx: &Context,
no_browser: bool,
force: bool,
admin_endpoint: Option<Url>,
cloud_endpoint: Option<Url>,
) -> Result<(), Error> {
let config_file = scx.config_file();
let profile = scx
.get_global_profile()
.map_or(config_file.profile().to_string(), |n| n);
if let Some(profiles) = scx.config_file().profiles() {
if profiles.contains_key(&profile) && !force {
return Err(ProfileNameAlreadyExistsError(profile));
}
}
let app_password = match no_browser {
true => init_without_browser(admin_endpoint.clone()).await?,
false => init_with_browser(cloud_endpoint.clone()).await?,
};
let new_profile = TomlProfile {
app_password: Some(app_password.to_string()),
vault: None,
region: None,
admin_endpoint: admin_endpoint.map(|url| url.to_string()),
cloud_endpoint: cloud_endpoint.map(|url| url.to_string()),
};
config_file.add_profile(profile, new_profile).await?;
Ok(())
}
pub fn list(cx: &Context) -> Result<(), Error> {
if let Some(profiles) = cx.config_file().profiles() {
let output = cx.output_formatter();
#[derive(Clone, Serialize, Deserialize, Tabled)]
struct ProfileName<'a> {
#[tabled(rename = "Name")]
name: &'a str,
}
output.output_table(profiles.keys().map(|name| ProfileName { name }))?;
}
Ok(())
}
pub async fn remove(cx: &Context) -> Result<(), Error> {
cx.config_file()
.remove_profile(
&cx.get_global_profile()
.unwrap_or(cx.config_file().profile().to_string()),
)
.await
}
pub struct ConfigGetArgs<'a> {
pub name: &'a str,
}
#[derive(Clone, Debug)]
pub enum ConfigArg {
AdminAPI,
AppPassword,
CloudAPI,
Region,
Vault,
}
impl FromStr for ConfigArg {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"admin-api" => Ok(ConfigArg::AdminAPI),
"app-password" => Ok(ConfigArg::AppPassword),
"cloud-api" => Ok(ConfigArg::CloudAPI),
"region" => Ok(ConfigArg::Region),
"vault" => Ok(ConfigArg::Vault),
_ => Err("Invalid profile configuration parameter.".to_string()),
}
}
}
impl ToString for ConfigArg {
fn to_string(&self) -> String {
match self {
ConfigArg::AdminAPI => "admin-api".to_string(),
ConfigArg::AppPassword => "app-password".to_string(),
ConfigArg::CloudAPI => "cloud-api".to_string(),
ConfigArg::Region => "region".to_string(),
ConfigArg::Vault => "vault".to_string(),
}
}
}
pub fn config_get(
cx: &ProfileContext,
ConfigGetArgs { name }: ConfigGetArgs<'_>,
) -> Result<(), Error> {
let profile = cx.get_profile();
let value = cx.config_file().get_profile_param(name, &profile)?;
cx.output_formatter().output_scalar(value)?;
Ok(())
}
pub fn config_list(cx: &ProfileContext) -> Result<(), Error> {
let profile_params = cx.config_file().list_profile_params(&cx.get_profile())?;
let output = cx.output_formatter();
#[derive(Serialize, Deserialize, Tabled)]
struct ProfileParam<'a> {
#[tabled(rename = "Name")]
name: &'a str,
#[tabled(rename = "Value")]
value: OptionalStr<'a>,
}
output.output_table(profile_params.iter().map(|(name, value)| ProfileParam {
name,
value: OptionalStr(value.as_deref()),
}))?;
Ok(())
}
pub struct ConfigSetArgs<'a> {
pub name: &'a str,
pub value: &'a str,
}
pub async fn config_set(
cx: &ProfileContext,
ConfigSetArgs { name, value }: ConfigSetArgs<'_>,
) -> Result<(), Error> {
cx.config_file()
.set_profile_param(&cx.get_profile(), name, Some(value))
.await
}
pub struct ConfigRemoveArgs<'a> {
pub name: &'a str,
}
pub async fn config_remove(
cx: &ProfileContext,
ConfigRemoveArgs { name }: ConfigRemoveArgs<'_>,
) -> Result<(), Error> {
cx.config_file()
.set_profile_param(&cx.get_profile(), name, None)
.await
}