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 .map_or(config_file.profile().to_string(), |n| n);
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 let app_password = match no_browser {
174 true => init_without_browser(admin_endpoint.clone()).await?,
175 false => init_with_browser(cloud_endpoint.clone()).await?,
176 };
177
178 let new_profile = TomlProfile {
179 app_password: Some(app_password.to_string()),
180 vault: None,
181 region: None,
182 admin_endpoint: admin_endpoint.map(|url| url.to_string()),
183 cloud_endpoint: cloud_endpoint.map(|url| url.to_string()),
184 };
185
186 config_file.add_profile(profile, new_profile).await?;
187
188 Ok(())
189}
190
191pub fn list(cx: &Context) -> Result<(), Error> {
193 if let Some(profiles) = cx.config_file().profiles() {
194 let output = cx.output_formatter();
195
196 #[derive(Clone, Serialize, Deserialize, Tabled)]
198 struct ProfileName<'a> {
199 #[tabled(rename = "Name")]
200 name: &'a str,
201 }
202 output.output_table(profiles.keys().map(|name| ProfileName { name }))?;
203 }
204
205 Ok(())
206}
207
208pub async fn remove(cx: &Context) -> Result<(), Error> {
210 cx.config_file()
211 .remove_profile(
212 &cx.get_global_profile()
213 .unwrap_or_else(|| cx.config_file().profile().to_string()),
214 )
215 .await
216}
217
218pub struct ConfigGetArgs<'a> {
220 pub name: &'a str,
222}
223
224#[derive(Clone, Debug)]
226pub enum ConfigArg {
227 AdminAPI,
229 AppPassword,
231 CloudAPI,
233 Region,
235 Vault,
237}
238
239impl FromStr for ConfigArg {
240 type Err = String;
241
242 fn from_str(s: &str) -> Result<Self, Self::Err> {
243 match s.to_lowercase().as_str() {
244 "admin-api" => Ok(ConfigArg::AdminAPI),
245 "app-password" => Ok(ConfigArg::AppPassword),
246 "cloud-api" => Ok(ConfigArg::CloudAPI),
247 "region" => Ok(ConfigArg::Region),
248 "vault" => Ok(ConfigArg::Vault),
249 _ => Err("Invalid profile configuration parameter.".to_string()),
250 }
251 }
252}
253
254impl ToString for ConfigArg {
255 fn to_string(&self) -> String {
256 match self {
257 ConfigArg::AdminAPI => "admin-api".to_string(),
258 ConfigArg::AppPassword => "app-password".to_string(),
259 ConfigArg::CloudAPI => "cloud-api".to_string(),
260 ConfigArg::Region => "region".to_string(),
261 ConfigArg::Vault => "vault".to_string(),
262 }
263 }
264}
265
266pub fn config_get(
268 cx: &ProfileContext,
269 ConfigGetArgs { name }: ConfigGetArgs<'_>,
270) -> Result<(), Error> {
271 let profile = cx.get_profile();
272 let value = cx.config_file().get_profile_param(name, &profile)?;
273 cx.output_formatter().output_scalar(value)?;
274 Ok(())
275}
276
277pub fn config_list(cx: &ProfileContext) -> Result<(), Error> {
279 let profile_params = cx.config_file().list_profile_params(&cx.get_profile())?;
280 let output = cx.output_formatter();
281
282 #[derive(Serialize, Deserialize, Tabled)]
284 struct ProfileParam<'a> {
285 #[tabled(rename = "Name")]
286 name: &'a str,
287 #[tabled(rename = "Value")]
288 value: OptionalStr<'a>,
289 }
290
291 output.output_table(profile_params.iter().map(|(name, value)| ProfileParam {
292 name,
293 value: OptionalStr(value.as_deref()),
294 }))?;
295 Ok(())
296}
297
298pub struct ConfigSetArgs<'a> {
300 pub name: &'a str,
302 pub value: &'a str,
304}
305
306pub async fn config_set(
308 cx: &ProfileContext,
309 ConfigSetArgs { name, value }: ConfigSetArgs<'_>,
310) -> Result<(), Error> {
311 cx.config_file()
312 .set_profile_param(&cx.get_profile(), name, Some(value))
313 .await
314}
315
316pub struct ConfigRemoveArgs<'a> {
318 pub name: &'a str,
320}
321
322pub async fn config_remove(
324 cx: &ProfileContext,
325 ConfigRemoveArgs { name }: ConfigRemoveArgs<'_>,
326) -> Result<(), Error> {
327 cx.config_file()
328 .set_profile_param(&cx.get_profile(), name, None)
329 .await
330}