1use 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
71pub 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 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 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); }
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 }
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}