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