Skip to main content

romm_cli/tui/screens/
setup_wizard.rs

1//! First-run setup: server URL, download directory, authentication, test connection, persist config.
2
3use anyhow::{anyhow, Result};
4use crossterm::event::{
5    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind,
6};
7use crossterm::execute;
8use crossterm::terminal::{
9    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
10};
11use ratatui::backend::CrosstermBackend;
12use ratatui::layout::{Constraint, Direction, Layout};
13use ratatui::style::{Color, Modifier, Style};
14use ratatui::text::{Line, Span, Text};
15use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
16use ratatui::Terminal;
17use std::io::stdout;
18
19use crate::client::RommClient;
20use crate::config::{
21    is_keyring_placeholder, load_config, normalize_romm_origin, persist_user_config,
22    read_user_config_json_from_disk, AuthConfig, Config,
23};
24
25#[derive(Clone, Copy, PartialEq, Eq)]
26enum AuthKind {
27    None,
28    Basic,
29    Bearer,
30    ApiKey,
31}
32
33#[derive(Clone, Copy, PartialEq, Eq)]
34enum Step {
35    Url,
36    Https,
37    Download,
38    AuthMenu,
39    BasicUser,
40    BasicPass,
41    Bearer,
42    ApiHeader,
43    ApiKey,
44    Summary,
45}
46
47/// Interactive setup run before the main TUI when `API_BASE_URL` is missing.
48pub struct SetupWizard {
49    step: Step,
50    auth_kind: AuthKind,
51    auth_menu_selected: usize,
52    url: String,
53    url_cursor: usize,
54    download_dir: String,
55    dl_cursor: usize,
56    username: String,
57    user_cursor: usize,
58    password: String,
59    bearer_token: String,
60    bearer_cursor: usize,
61    api_header: String,
62    header_cursor: usize,
63    api_key: String,
64    api_key_cursor: usize,
65    /// Empty field + `true` means resolve secret from OS keyring on save (disk had `<stored-in-keyring>`).
66    reuse_keyring_password: bool,
67    reuse_keyring_bearer: bool,
68    reuse_keyring_api_key: bool,
69    pub testing: bool,
70    pub use_https: bool,
71    pub error: Option<String>,
72}
73
74impl SetupWizard {
75    pub fn new() -> Self {
76        let default_dl = dirs::download_dir()
77            .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
78            .join("romm-cli")
79            .display()
80            .to_string();
81        Self {
82            step: Step::Url,
83            auth_kind: AuthKind::None,
84            auth_menu_selected: 0,
85            url: "https://".to_string(),
86            url_cursor: "https://".len(),
87            download_dir: default_dl,
88            dl_cursor: 0,
89            username: String::new(),
90            user_cursor: 0,
91            password: String::new(),
92            bearer_token: String::new(),
93            bearer_cursor: 0,
94            api_header: String::new(),
95            header_cursor: 0,
96            api_key: String::new(),
97            api_key_cursor: 0,
98            reuse_keyring_password: false,
99            reuse_keyring_bearer: false,
100            reuse_keyring_api_key: false,
101            testing: false,
102            use_https: true,
103            error: None,
104        }
105    }
106
107    pub fn new_auth_only(config: &Config) -> Self {
108        let mut wizard = Self::new();
109        wizard.step = Step::AuthMenu;
110        wizard.url = config.base_url.clone();
111        wizard.download_dir = config.download_dir.clone();
112        wizard.use_https = config.use_https;
113
114        let disk = read_user_config_json_from_disk();
115
116        match &config.auth {
117            Some(AuthConfig::Basic { username, password }) => {
118                wizard.auth_kind = AuthKind::Basic;
119                wizard.auth_menu_selected = 1;
120                wizard.username = username.clone();
121                wizard.user_cursor = username.len();
122                let disk_pass = disk
123                    .as_ref()
124                    .and_then(|c| c.auth.as_ref())
125                    .and_then(|a| match a {
126                        AuthConfig::Basic { password, .. } => Some(password.as_str()),
127                        _ => None,
128                    });
129                if disk_pass.is_some_and(is_keyring_placeholder) {
130                    wizard.password = String::new();
131                    wizard.reuse_keyring_password = true;
132                } else {
133                    wizard.password = password.clone();
134                }
135            }
136            Some(AuthConfig::Bearer { token }) => {
137                wizard.auth_kind = AuthKind::Bearer;
138                wizard.auth_menu_selected = 2;
139                let disk_tok = disk
140                    .as_ref()
141                    .and_then(|c| c.auth.as_ref())
142                    .and_then(|a| match a {
143                        AuthConfig::Bearer { token } => Some(token.as_str()),
144                        _ => None,
145                    });
146                if disk_tok.is_some_and(is_keyring_placeholder) {
147                    wizard.bearer_token = String::new();
148                    wizard.bearer_cursor = 0;
149                    wizard.reuse_keyring_bearer = true;
150                } else {
151                    wizard.bearer_token = token.clone();
152                    wizard.bearer_cursor = token.len();
153                }
154            }
155            Some(AuthConfig::ApiKey { header, key }) => {
156                wizard.auth_kind = AuthKind::ApiKey;
157                wizard.auth_menu_selected = 3;
158                wizard.api_header = header.clone();
159                wizard.header_cursor = header.len();
160                let disk_key = disk
161                    .as_ref()
162                    .and_then(|c| c.auth.as_ref())
163                    .and_then(|a| match a {
164                        AuthConfig::ApiKey { key, .. } => Some(key.as_str()),
165                        _ => None,
166                    });
167                if disk_key.is_some_and(is_keyring_placeholder) {
168                    wizard.api_key = String::new();
169                    wizard.api_key_cursor = 0;
170                    wizard.reuse_keyring_api_key = true;
171                } else {
172                    wizard.api_key = key.clone();
173                    wizard.api_key_cursor = key.len();
174                }
175            }
176            None => {
177                wizard.auth_kind = AuthKind::None;
178                wizard.auth_menu_selected = 0;
179            }
180        }
181        wizard
182    }
183
184    fn auth_labels() -> [&'static str; 4] {
185        [
186            "No authentication",
187            "Basic (username + password)",
188            "API Token (Bearer)",
189            "API key in custom header",
190        ]
191    }
192
193    fn auth_kind_from_index(i: usize) -> AuthKind {
194        match i {
195            1 => AuthKind::Basic,
196            2 => AuthKind::Bearer,
197            3 => AuthKind::ApiKey,
198            _ => AuthKind::None,
199        }
200    }
201
202    fn build_config(&self) -> Result<Config> {
203        let base_url = normalize_romm_origin(self.url.trim());
204        if base_url.is_empty() {
205            return Err(anyhow!("Server URL cannot be empty"));
206        }
207        let auth: Option<AuthConfig> = match self.auth_kind {
208            AuthKind::None => None,
209            AuthKind::Basic => {
210                let u = self.username.trim();
211                if u.is_empty() {
212                    return Err(anyhow!("Username cannot be empty"));
213                }
214                let password = if self.password.is_empty() && self.reuse_keyring_password {
215                    crate::config::keyring_get("API_PASSWORD").ok_or_else(|| {
216                        anyhow!("Password not in OS keyring; enter a password or run romm-cli init")
217                    })?
218                } else if self.password.is_empty() {
219                    return Err(anyhow!("Password cannot be empty"));
220                } else {
221                    self.password.clone()
222                };
223                Some(AuthConfig::Basic {
224                    username: u.to_string(),
225                    password,
226                })
227            }
228            AuthKind::Bearer => {
229                let token = if self.bearer_token.trim().is_empty() && self.reuse_keyring_bearer {
230                    crate::config::keyring_get("API_TOKEN").ok_or_else(|| {
231                        anyhow!("API token not in OS keyring; enter a token or run romm-cli init")
232                    })?
233                } else if self.bearer_token.trim().is_empty() {
234                    return Err(anyhow!("Bearer token cannot be empty"));
235                } else {
236                    self.bearer_token.trim().to_string()
237                };
238                Some(AuthConfig::Bearer { token })
239            }
240            AuthKind::ApiKey => {
241                let h = self.api_header.trim();
242                if h.is_empty() {
243                    return Err(anyhow!("Header name cannot be empty"));
244                }
245                let key = if self.api_key.is_empty() && self.reuse_keyring_api_key {
246                    crate::config::keyring_get("API_KEY").ok_or_else(|| {
247                        anyhow!("API key not in OS keyring; enter a key or run romm-cli init")
248                    })?
249                } else if self.api_key.is_empty() {
250                    return Err(anyhow!("API key cannot be empty"));
251                } else {
252                    self.api_key.clone()
253                };
254                Some(AuthConfig::ApiKey {
255                    header: h.to_string(),
256                    key,
257                })
258            }
259        };
260        Ok(Config {
261            base_url,
262            download_dir: self.download_dir.trim().to_string(),
263            use_https: self.use_https,
264            auth,
265        })
266    }
267
268    pub fn render(&mut self, f: &mut ratatui::Frame, area: ratatui::layout::Rect) {
269        let title = match self.step {
270            Step::Url => "Step 1/5 — RomM server URL",
271            Step::Https => "Step 2/5 — Secure connection",
272            Step::Download => "Step 3/5 — Download directory",
273            Step::AuthMenu => "Step 4/5 — Authentication",
274            Step::BasicUser | Step::BasicPass => "Step 5/5 — Basic auth",
275            Step::Bearer => "Step 5/5 — API Token",
276            Step::ApiHeader | Step::ApiKey => "Step 5/5 — API key",
277            Step::Summary => "Review & connect",
278        };
279
280        let main = Layout::default()
281            .direction(Direction::Vertical)
282            .constraints([
283                Constraint::Length(3),
284                Constraint::Min(6),
285                Constraint::Length(4),
286            ])
287            .split(area);
288
289        let hint_top = "Same origin as in your browser (no trailing /api). Esc: quit";
290        let p = Paragraph::new(hint_top).style(Style::default().fg(Color::DarkGray));
291        f.render_widget(p, main[0]);
292
293        match self.step {
294            Step::Url => {
295                let line = format!(
296                    "{}▏",
297                    self.url.chars().take(self.url_cursor).collect::<String>()
298                );
299                let rest: String = self.url.chars().skip(self.url_cursor).collect();
300                let text = format!("{line}{rest}");
301                let block = Block::default().title(title).borders(Borders::ALL);
302                let p = Paragraph::new(text).block(block);
303                f.render_widget(p, main[1]);
304            }
305            Step::Https => {
306                let text = if self.use_https {
307                    "[X] Use HTTPS (Recommended)"
308                } else {
309                    "[ ] Use HTTPS (Insecure)"
310                };
311                let block = Block::default().title(title).borders(Borders::ALL);
312                let p = Paragraph::new(format!("\n  {}\n\n  Space: toggle   Enter: next", text))
313                    .block(block);
314                f.render_widget(p, main[1]);
315            }
316            Step::Download => {
317                let line = format!(
318                    "{}▏",
319                    self.download_dir
320                        .chars()
321                        .take(self.dl_cursor)
322                        .collect::<String>()
323                );
324                let rest: String = self.download_dir.chars().skip(self.dl_cursor).collect();
325                let text = format!("{line}{rest}");
326                let block = Block::default().title(title).borders(Borders::ALL);
327                let p = Paragraph::new(text).block(block);
328                f.render_widget(p, main[1]);
329            }
330            Step::AuthMenu => {
331                let items: Vec<ListItem> = Self::auth_labels()
332                    .iter()
333                    .map(|s| ListItem::new(*s))
334                    .collect();
335                let mut state = ListState::default();
336                state.select(Some(self.auth_menu_selected));
337                let list = List::new(items)
338                    .block(Block::default().title(title).borders(Borders::ALL))
339                    .highlight_style(
340                        Style::default()
341                            .fg(Color::Yellow)
342                            .add_modifier(Modifier::BOLD),
343                    )
344                    .highlight_symbol(">> ");
345                f.render_stateful_widget(list, main[1], &mut state);
346            }
347            Step::BasicUser | Step::BasicPass => {
348                let user_line = if self.step == Step::BasicUser {
349                    format!(
350                        "{}▏{}",
351                        self.username
352                            .chars()
353                            .take(self.user_cursor)
354                            .collect::<String>(),
355                        self.username
356                            .chars()
357                            .skip(self.user_cursor)
358                            .collect::<String>()
359                    )
360                } else {
361                    self.username.clone()
362                };
363                let pass_display: String = if self.step == Step::BasicPass {
364                    "•".repeat(self.password.len()) + "▏"
365                } else {
366                    "•".repeat(self.password.len())
367                };
368                let kr_hint = if self.step == Step::BasicPass
369                    && self.password.is_empty()
370                    && self.reuse_keyring_password
371                {
372                    "\n\n(stored in OS keyring — leave blank to keep, or type a new password)"
373                } else {
374                    ""
375                };
376                let block = Block::default().title(title).borders(Borders::ALL);
377                let body = format!(
378                    "Username\n{user_line}\n\nPassword (hidden)\n{pass_display}{kr_hint}\n\nTab: switch field"
379                );
380                let p = Paragraph::new(body).block(block);
381                f.render_widget(p, main[1]);
382            }
383            Step::Bearer => {
384                let line = format!(
385                    "{}▏{}",
386                    self.bearer_token
387                        .chars()
388                        .take(self.bearer_cursor)
389                        .collect::<String>(),
390                    self.bearer_token
391                        .chars()
392                        .skip(self.bearer_cursor)
393                        .collect::<String>()
394                );
395                let mut bearer_text = Text::from(vec![Line::from(line)]);
396                if self.bearer_token.is_empty() && self.reuse_keyring_bearer {
397                    bearer_text.push_line(Line::from(""));
398                    bearer_text.push_line(Line::from(Span::styled(
399                        "Token stored in OS keyring — leave blank to keep, or type a new token.",
400                        Style::default().fg(Color::DarkGray),
401                    )));
402                }
403                let block = Block::default().title(title).borders(Borders::ALL);
404                let p = Paragraph::new(bearer_text).block(block);
405                f.render_widget(p, main[1]);
406            }
407            Step::ApiHeader | Step::ApiKey => {
408                let header_line = if self.step == Step::ApiHeader {
409                    format!(
410                        "{}▏{}",
411                        self.api_header
412                            .chars()
413                            .take(self.header_cursor)
414                            .collect::<String>(),
415                        self.api_header
416                            .chars()
417                            .skip(self.header_cursor)
418                            .collect::<String>()
419                    )
420                } else {
421                    self.api_header.clone()
422                };
423                let key_line = if self.step == Step::ApiKey {
424                    "•".repeat(self.api_key.len()) + "▏"
425                } else {
426                    "•".repeat(self.api_key.len())
427                };
428                let kr_hint = if self.step == Step::ApiKey
429                    && self.api_key.is_empty()
430                    && self.reuse_keyring_api_key
431                {
432                    "\n\n(stored in OS keyring — leave blank to keep, or type a new key)"
433                } else {
434                    ""
435                };
436                let body = format!(
437                    "Header name\n{header_line}\n\nKey (hidden)\n{key_line}{kr_hint}\n\nTab: switch field"
438                );
439                let block = Block::default().title(title).borders(Borders::ALL);
440                let p = Paragraph::new(body).block(block);
441                f.render_widget(p, main[1]);
442            }
443            Step::Summary => {
444                let url_line = normalize_romm_origin(self.url.trim());
445                let auth_desc = match self.auth_kind {
446                    AuthKind::None => "None",
447                    AuthKind::Basic => "Basic",
448                    AuthKind::Bearer => "API Token",
449                    AuthKind::ApiKey => "API key header",
450                };
451                let mut lines = vec![
452                    format!("Server: {url_line}"),
453                    format!("Downloads: {}", self.download_dir.trim()),
454                    format!("Use HTTPS: {}", if self.use_https { "Yes" } else { "No" }),
455                    format!("Auth: {auth_desc}"),
456                    String::new(),
457                ];
458                if self.testing {
459                    lines.push("Connecting to server…".to_string());
460                } else if let Some(ref e) = self.error {
461                    lines.push(format!("Last error: {e}"));
462                } else {
463                    lines.push("Enter: test connection and save   Esc: quit".to_string());
464                }
465                let block = Block::default().title(title).borders(Borders::ALL);
466                let p = Paragraph::new(lines.join("\n")).block(block);
467                f.render_widget(p, main[1]);
468            }
469        }
470
471        let footer = match self.step {
472            Step::Url => "Enter: next   Backspace: delete   Esc: quit",
473            Step::Https => "Space: toggle   Enter: next   Esc: quit",
474            Step::Download => "Enter: next   Backspace: delete   Esc: quit",
475            Step::AuthMenu => "↑/↓: choose   Enter: next   Esc: quit",
476            Step::BasicUser | Step::BasicPass => {
477                "Type text   Tab: switch field   Enter: next step   Esc: quit"
478            }
479            Step::Bearer => "Enter: next step   Esc: quit",
480            Step::ApiHeader | Step::ApiKey => "Tab: switch field   Enter: next step   Esc: quit",
481            Step::Summary => {
482                if self.testing {
483                    "Please wait…"
484                } else {
485                    "Enter: connect & save"
486                }
487            }
488        };
489        let p = Paragraph::new(footer)
490            .style(Style::default().fg(Color::Cyan))
491            .block(Block::default().borders(Borders::ALL));
492        f.render_widget(p, main[2]);
493    }
494
495    pub fn cursor_pos(&self, area: ratatui::layout::Rect) -> Option<(u16, u16)> {
496        let main = Layout::default()
497            .direction(Direction::Vertical)
498            .constraints([
499                Constraint::Length(3),
500                Constraint::Min(6),
501                Constraint::Length(4),
502            ])
503            .split(area);
504        let inner = main[1];
505        match self.step {
506            Step::Url => {
507                let x = inner.x + 1 + self.url_cursor.min(self.url.len()) as u16;
508                Some((x, inner.y + 1))
509            }
510            Step::Download => {
511                let x = inner.x + 1 + self.dl_cursor.min(self.download_dir.len()) as u16;
512                Some((x, inner.y + 1))
513            }
514            Step::Bearer => {
515                let x = inner.x + 1 + self.bearer_cursor.min(self.bearer_token.len()) as u16;
516                Some((x, inner.y + 1))
517            }
518            Step::BasicUser => {
519                let x = inner.x + 1 + self.user_cursor.min(self.username.len()) as u16;
520                Some((x, inner.y + 2))
521            }
522            Step::BasicPass => {
523                let x = inner.x + 1 + "•".repeat(self.password.len()).len() as u16;
524                Some((x, inner.y + 6))
525            }
526            Step::ApiHeader => {
527                let x = inner.x + 1 + self.header_cursor.min(self.api_header.len()) as u16;
528                Some((x, inner.y + 2))
529            }
530            Step::ApiKey => {
531                let x = inner.x + 1 + self.api_key_cursor.min(self.api_key.len()) as u16;
532                Some((x, inner.y + 6))
533            }
534            Step::Https | Step::AuthMenu | Step::Summary => None,
535        }
536    }
537
538    fn add_char_url(&mut self, c: char) {
539        let pos = self.url_cursor.min(self.url.len());
540        self.url.insert(pos, c);
541        self.url_cursor = pos + 1;
542    }
543
544    fn del_char_url(&mut self) {
545        if self.url_cursor > 0 && self.url_cursor <= self.url.len() {
546            self.url.remove(self.url_cursor - 1);
547            self.url_cursor -= 1;
548        }
549    }
550
551    fn add_char_dl(&mut self, c: char) {
552        let pos = self.dl_cursor.min(self.download_dir.len());
553        self.download_dir.insert(pos, c);
554        self.dl_cursor = pos + 1;
555    }
556
557    fn del_char_dl(&mut self) {
558        if self.dl_cursor > 0 && self.dl_cursor <= self.download_dir.len() {
559            self.download_dir.remove(self.dl_cursor - 1);
560            self.dl_cursor -= 1;
561        }
562    }
563
564    fn advance_from_auth_menu(&mut self) {
565        self.auth_kind = Self::auth_kind_from_index(self.auth_menu_selected);
566        self.step = match self.auth_kind {
567            AuthKind::None => Step::Summary,
568            AuthKind::Basic => Step::BasicUser,
569            AuthKind::Bearer => Step::Bearer,
570            AuthKind::ApiKey => Step::ApiHeader,
571        };
572    }
573
574    fn advance_step(&mut self) -> Result<()> {
575        self.error = None;
576        match self.step {
577            Step::Url => {
578                if normalize_romm_origin(self.url.trim()).is_empty() {
579                    self.error = Some("Enter a valid server URL".to_string());
580                    return Ok(());
581                }
582                self.step = Step::Https;
583            }
584            Step::Https => {
585                self.step = Step::Download;
586                self.dl_cursor = self.download_dir.len();
587            }
588            Step::Download => {
589                if self.download_dir.trim().is_empty() {
590                    self.error = Some("Download path cannot be empty".to_string());
591                    return Ok(());
592                }
593                self.step = Step::AuthMenu;
594            }
595            Step::AuthMenu => self.advance_from_auth_menu(),
596            Step::BasicUser => self.step = Step::BasicPass,
597            Step::BasicPass => self.step = Step::Summary,
598            Step::Bearer => self.step = Step::Summary,
599            Step::ApiHeader => self.step = Step::ApiKey,
600            Step::ApiKey => self.step = Step::Summary,
601            Step::Summary => {}
602        }
603        Ok(())
604    }
605
606    pub async fn try_connect_and_persist(&mut self, verbose: bool) -> Result<Config> {
607        let cfg = self.build_config()?;
608        let client = RommClient::new(&cfg, verbose)?;
609        client.fetch_openapi_json().await?;
610        let base = cfg.base_url.clone();
611        let download = self.download_dir.trim().to_string();
612        persist_user_config(&base, &download, self.use_https, cfg.auth.clone())?;
613        load_config()
614    }
615
616    pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> Result<bool> {
617        if key.kind != KeyEventKind::Press {
618            return Ok(false);
619        }
620        if key.code == KeyCode::Esc {
621            return Ok(true); // Signal to caller that we should exit/cancel
622        }
623
624        if self.testing {
625            return Ok(false);
626        }
627
628        match self.step {
629            Step::Url => match key.code {
630                KeyCode::Enter => {
631                    let _ = self.advance_step();
632                }
633                KeyCode::Char(c) => self.add_char_url(c),
634                KeyCode::Backspace => self.del_char_url(),
635                KeyCode::Left => {
636                    if self.url_cursor > 0 {
637                        self.url_cursor -= 1;
638                    }
639                }
640                KeyCode::Right => {
641                    if self.url_cursor < self.url.len() {
642                        self.url_cursor += 1;
643                    }
644                }
645                _ => {}
646            },
647            Step::Https => match key.code {
648                KeyCode::Enter => {
649                    let _ = self.advance_step();
650                }
651                KeyCode::Char(' ') => self.use_https = !self.use_https,
652                _ => {}
653            },
654            Step::Download => match key.code {
655                KeyCode::Enter => {
656                    let _ = self.advance_step();
657                }
658                KeyCode::Char(c) => self.add_char_dl(c),
659                KeyCode::Backspace => self.del_char_dl(),
660                KeyCode::Left => {
661                    if self.dl_cursor > 0 {
662                        self.dl_cursor -= 1;
663                    }
664                }
665                KeyCode::Right => {
666                    if self.dl_cursor < self.download_dir.len() {
667                        self.dl_cursor += 1;
668                    }
669                }
670                _ => {}
671            },
672            Step::AuthMenu => match key.code {
673                KeyCode::Up | KeyCode::Char('k') => {
674                    if self.auth_menu_selected > 0 {
675                        self.auth_menu_selected -= 1;
676                    }
677                }
678                KeyCode::Down | KeyCode::Char('j') => {
679                    if self.auth_menu_selected < 3 {
680                        self.auth_menu_selected += 1;
681                    }
682                }
683                KeyCode::Enter => {
684                    let _ = self.advance_step();
685                }
686                _ => {}
687            },
688            Step::BasicUser => match key.code {
689                KeyCode::Tab => self.step = Step::BasicPass,
690                KeyCode::Enter => {
691                    let _ = self.advance_step();
692                }
693                KeyCode::Char(c) => {
694                    let pos = self.user_cursor.min(self.username.len());
695                    self.username.insert(pos, c);
696                    self.user_cursor = pos + 1;
697                }
698                KeyCode::Backspace => {
699                    if self.user_cursor > 0 && self.user_cursor <= self.username.len() {
700                        self.username.remove(self.user_cursor - 1);
701                        self.user_cursor -= 1;
702                    }
703                }
704                KeyCode::Left => {
705                    if self.user_cursor > 0 {
706                        self.user_cursor -= 1;
707                    }
708                }
709                KeyCode::Right => {
710                    if self.user_cursor < self.username.len() {
711                        self.user_cursor += 1;
712                    }
713                }
714                _ => {}
715            },
716            Step::BasicPass => match key.code {
717                KeyCode::Tab => self.step = Step::BasicUser,
718                KeyCode::Enter => {
719                    let _ = self.advance_step();
720                }
721                KeyCode::Char(c) => {
722                    self.reuse_keyring_password = false;
723                    self.password.push(c);
724                }
725                KeyCode::Backspace => {
726                    self.password.pop();
727                }
728                _ => {}
729            },
730            Step::Bearer => match key.code {
731                KeyCode::Enter => {
732                    let _ = self.advance_step();
733                }
734                KeyCode::Char(c) => {
735                    self.reuse_keyring_bearer = false;
736                    let pos = self.bearer_cursor.min(self.bearer_token.len());
737                    self.bearer_token.insert(pos, c);
738                    self.bearer_cursor = pos + 1;
739                }
740                KeyCode::Backspace => {
741                    if self.bearer_cursor > 0 && self.bearer_cursor <= self.bearer_token.len() {
742                        self.bearer_token.remove(self.bearer_cursor - 1);
743                        self.bearer_cursor -= 1;
744                    }
745                }
746                KeyCode::Left => {
747                    if self.bearer_cursor > 0 {
748                        self.bearer_cursor -= 1;
749                    }
750                }
751                KeyCode::Right => {
752                    if self.bearer_cursor < self.bearer_token.len() {
753                        self.bearer_cursor += 1;
754                    }
755                }
756                _ => {}
757            },
758            Step::ApiHeader => match key.code {
759                KeyCode::Tab => self.step = Step::ApiKey,
760                KeyCode::Enter => {
761                    let _ = self.advance_step();
762                }
763                KeyCode::Char(c) => {
764                    let pos = self.header_cursor.min(self.api_header.len());
765                    self.api_header.insert(pos, c);
766                    self.header_cursor = pos + 1;
767                }
768                KeyCode::Backspace => {
769                    if self.header_cursor > 0 && self.header_cursor <= self.api_header.len() {
770                        self.api_header.remove(self.header_cursor - 1);
771                        self.header_cursor -= 1;
772                    }
773                }
774                KeyCode::Left => {
775                    if self.header_cursor > 0 {
776                        self.header_cursor -= 1;
777                    }
778                }
779                KeyCode::Right => {
780                    if self.header_cursor < self.api_header.len() {
781                        self.header_cursor += 1;
782                    }
783                }
784                _ => {}
785            },
786            Step::ApiKey => match key.code {
787                KeyCode::Tab => self.step = Step::ApiHeader,
788                KeyCode::Enter => {
789                    let _ = self.advance_step();
790                }
791                KeyCode::Char(c) => {
792                    self.reuse_keyring_api_key = false;
793                    let pos = self.api_key_cursor.min(self.api_key.len());
794                    self.api_key.insert(pos, c);
795                    self.api_key_cursor = pos + 1;
796                }
797                KeyCode::Backspace => {
798                    if self.api_key_cursor > 0 && self.api_key_cursor <= self.api_key.len() {
799                        self.api_key.remove(self.api_key_cursor - 1);
800                        self.api_key_cursor -= 1;
801                    }
802                }
803                KeyCode::Left => {
804                    if self.api_key_cursor > 0 {
805                        self.api_key_cursor -= 1;
806                    }
807                }
808                KeyCode::Right => {
809                    if self.api_key_cursor < self.api_key.len() {
810                        self.api_key_cursor += 1;
811                    }
812                }
813                _ => {}
814            },
815            Step::Summary => {
816                if key.code == KeyCode::Enter {
817                    self.testing = true;
818                    self.error = None;
819                    // The caller handles the actual async try_connect_and_persist call
820                    // when they see testing = true.
821                }
822            }
823        }
824        Ok(false)
825    }
826
827    pub async fn run(mut self, verbose: bool) -> Result<Config> {
828        enable_raw_mode()?;
829        let mut stdout = stdout();
830        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
831        let backend = CrosstermBackend::new(stdout);
832        let mut terminal = Terminal::new(backend)?;
833
834        loop {
835            terminal.draw(|f| {
836                let area = f.size();
837                self.render(f, area);
838                if let Some((x, y)) = self.cursor_pos(area) {
839                    f.set_cursor(x, y);
840                }
841            })?;
842
843            if event::poll(std::time::Duration::from_millis(100))? {
844                if let Event::Key(key) = event::read()? {
845                    if self.handle_key(key)? {
846                        disable_raw_mode()?;
847                        execute!(
848                            terminal.backend_mut(),
849                            LeaveAlternateScreen,
850                            DisableMouseCapture
851                        )?;
852                        terminal.show_cursor()?;
853                        return Err(anyhow!("setup cancelled"));
854                    }
855
856                    if self.testing {
857                        terminal.draw(|f| {
858                            let area = f.size();
859                            self.render(f, area);
860                        })?;
861                        let result = self.try_connect_and_persist(verbose).await;
862                        self.testing = false;
863                        match result {
864                            Ok(cfg) => {
865                                disable_raw_mode()?;
866                                execute!(
867                                    terminal.backend_mut(),
868                                    LeaveAlternateScreen,
869                                    DisableMouseCapture
870                                )?;
871                                terminal.show_cursor()?;
872                                return Ok(cfg);
873                            }
874                            Err(e) => {
875                                self.error = Some(format!("{e:#}"));
876                            }
877                        }
878                    }
879                }
880            }
881        }
882    }
883}
884
885impl Default for SetupWizard {
886    fn default() -> Self {
887        Self::new()
888    }
889}