1use std::{io::Write, str::FromStr};
21
22use mz_frontegg_auth::AppPassword;
23use mz_frontegg_client::client::app_password::CreateAppPasswordRequest;
24use mz_frontegg_client::client::{Client as AdminClient, Credentials};
25use mz_frontegg_client::config::{
26 ClientBuilder as AdminClientBuilder, ClientConfig as AdminClientConfig,
27};
28
29use mz_cloud_api::config::DEFAULT_ENDPOINT;
30use serde::{Deserialize, Serialize};
31use tabled::Tabled;
32use tokio::{select, sync::mpsc};
33use url::Url;
34
35use crate::ui::OptionalStr;
36use crate::{
37 config_file::TomlProfile,
38 context::{Context, ProfileContext},
39 error::Error,
40 error::Error::ProfileNameAlreadyExistsError,
41 server::server,
42};
43
44fn strip_api_from_endpoint(endpoint: Url) -> Url {
48 if let Some(domain) = endpoint.domain() {
49 if let Some(corrected_domain) = domain.strip_prefix("api.") {
50 let mut new_endpoint = endpoint.clone();
51 let _ = new_endpoint.set_host(Some(corrected_domain));
52
53 return new_endpoint;
54 }
55 };
56
57 endpoint
58}
59
60pub async fn init_with_browser(cloud_endpoint: Option<Url>) -> Result<AppPassword, Error> {
63 let (tx, mut rx) = mpsc::unbounded_channel();
65 let (server, port) = server(tx).await;
66
67 let mut url =
69 strip_api_from_endpoint(cloud_endpoint.unwrap_or_else(|| DEFAULT_ENDPOINT.clone()));
70
71 url.path_segments_mut()
72 .expect("constructor validated URL can be a base")
73 .extend(&["account", "login"]);
74
75 let mut query_pairs = url.query_pairs_mut();
76 query_pairs.append_pair(
77 "redirectUrl",
78 &format!("/access/cli?redirectUri=http://localhost:{port}&tokenDescription=Materialize%20CLI%20%28mz%29"),
79 );
80 let open_url = &query_pairs.finish().as_str().replace("cloud", "console");
83
84 if let Err(_err) = open::that(open_url) {
86 println!(
87 "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.",
88 open_url
89 )
90 }
91
92 select! {
94 _ = server => unreachable!("server should not shut down"),
95 result = rx.recv() => {
96 match result {
97 Some(app_password_result) => app_password_result,
98 None => { panic!("failed to login via browser") },
99 }
100 }
101 }
102}
103
104pub async fn init_without_browser(admin_endpoint: Option<Url>) -> Result<AppPassword, Error> {
108 let mut email = String::new();
110
111 print!("Email: ");
112 let _ = std::io::stdout().flush();
113 std::io::stdin().read_line(&mut email).unwrap();
114
115 if email.ends_with('\n') {
117 email.pop();
118 if email.ends_with('\r') {
119 email.pop();
120 }
121 }
122
123 print!("Password: ");
124 let _ = std::io::stdout().flush();
125 let password = rpassword::read_password().unwrap();
126
127 let mut admin_client_builder = AdminClientBuilder::default();
129
130 if let Some(admin_endpoint) = admin_endpoint {
131 admin_client_builder = admin_client_builder.endpoint(admin_endpoint);
132 }
133
134 let admin_client: AdminClient = admin_client_builder.build(AdminClientConfig {
135 authentication: mz_frontegg_client::client::Authentication::Credentials(Credentials {
136 email,
137 password,
138 }),
139 });
140
141 let app_password = admin_client
142 .create_app_password(CreateAppPasswordRequest {
143 description: "Materialize CLI (mz)",
144 })
145 .await?;
146
147 Ok(app_password)
148}
149
150pub async fn init(
156 scx: &Context,
157 no_browser: bool,
158 force: bool,
159 admin_endpoint: Option<Url>,
160 cloud_endpoint: Option<Url>,
161) -> Result<(), Error> {
162 let config_file = scx.config_file();
163 let profile = scx
164 .get_global_profile()
165 .unwrap_or_else(|| config_file.profile().to_string());
166
167 if let Some(profiles) = scx.config_file().profiles() {
168 if profiles.contains_key(&profile) && !force {
169 return Err(ProfileNameAlreadyExistsError(profile));
170 }
171 }
172
173 config_file.ensure_writable().await?;
176
177 let app_password = match no_browser {
178 true => init_without_browser(admin_endpoint.clone()).await?,
179 false => init_with_browser(cloud_endpoint.clone()).await?,
180 };
181
182 let new_profile = TomlProfile {
183 app_password: Some(app_password.to_string()),
184 vault: None,
185 region: None,
186 admin_endpoint: admin_endpoint.map(|url| url.to_string()),
187 cloud_endpoint: cloud_endpoint.map(|url| url.to_string()),
188 };
189
190 config_file.add_profile(profile, new_profile).await?;
191
192 Ok(())
193}
194
195pub fn list(cx: &Context) -> Result<(), Error> {
197 if let Some(profiles) = cx.config_file().profiles() {
198 let output = cx.output_formatter();
199
200 #[derive(Clone, Serialize, Deserialize, Tabled)]
202 struct ProfileName<'a> {
203 #[tabled(rename = "Name")]
204 name: &'a str,
205 }
206 output.output_table(profiles.keys().map(|name| ProfileName { name }))?;
207 }
208
209 Ok(())
210}
211
212pub async fn remove(cx: &Context) -> Result<(), Error> {
214 cx.config_file()
215 .remove_profile(
216 &cx.get_global_profile()
217 .unwrap_or_else(|| cx.config_file().profile().to_string()),
218 )
219 .await
220}
221
222pub struct ConfigGetArgs<'a> {
224 pub name: &'a str,
226}
227
228#[derive(Clone, Debug)]
230pub enum ConfigArg {
231 AdminAPI,
233 AppPassword,
235 CloudAPI,
237 Region,
239 Vault,
241}
242
243impl FromStr for ConfigArg {
244 type Err = String;
245
246 fn from_str(s: &str) -> Result<Self, Self::Err> {
247 match s.to_lowercase().as_str() {
248 "admin-api" => Ok(ConfigArg::AdminAPI),
249 "app-password" => Ok(ConfigArg::AppPassword),
250 "cloud-api" => Ok(ConfigArg::CloudAPI),
251 "region" => Ok(ConfigArg::Region),
252 "vault" => Ok(ConfigArg::Vault),
253 _ => Err("Invalid profile configuration parameter.".to_string()),
254 }
255 }
256}
257
258impl ToString for ConfigArg {
259 fn to_string(&self) -> String {
260 match self {
261 ConfigArg::AdminAPI => "admin-api".to_string(),
262 ConfigArg::AppPassword => "app-password".to_string(),
263 ConfigArg::CloudAPI => "cloud-api".to_string(),
264 ConfigArg::Region => "region".to_string(),
265 ConfigArg::Vault => "vault".to_string(),
266 }
267 }
268}
269
270pub fn config_get(
272 cx: &ProfileContext,
273 ConfigGetArgs { name }: ConfigGetArgs<'_>,
274) -> Result<(), Error> {
275 let profile = cx.get_profile();
276 let value = cx.config_file().get_profile_param(name, &profile)?;
277 cx.output_formatter().output_scalar(value)?;
278 Ok(())
279}
280
281pub fn config_list(cx: &ProfileContext) -> Result<(), Error> {
283 let profile_params = cx.config_file().list_profile_params(&cx.get_profile())?;
284 let output = cx.output_formatter();
285
286 #[derive(Serialize, Deserialize, Tabled)]
288 struct ProfileParam<'a> {
289 #[tabled(rename = "Name")]
290 name: &'a str,
291 #[tabled(rename = "Value")]
292 value: OptionalStr<'a>,
293 }
294
295 output.output_table(profile_params.iter().map(|(name, value)| ProfileParam {
296 name,
297 value: OptionalStr(value.as_deref()),
298 }))?;
299 Ok(())
300}
301
302pub struct ConfigSetArgs<'a> {
304 pub name: &'a str,
306 pub value: &'a str,
308}
309
310pub async fn config_set(
312 cx: &ProfileContext,
313 ConfigSetArgs { name, value }: ConfigSetArgs<'_>,
314) -> Result<(), Error> {
315 cx.config_file()
316 .set_profile_param(&cx.get_profile(), name, Some(value))
317 .await
318}
319
320pub struct ConfigRemoveArgs<'a> {
322 pub name: &'a str,
324}
325
326pub async fn config_remove(
328 cx: &ProfileContext,
329 ConfigRemoveArgs { name }: ConfigRemoveArgs<'_>,
330) -> Result<(), Error> {
331 cx.config_file()
332 .set_profile_param(&cx.get_profile(), name, None)
333 .await
334}