Skip to main content

romm_cli/commands/
init.rs

1//! Interactive `romm-cli init` — writes user-level `romm-cli/config.json`.
2//!
3//! Secrets (passwords, tokens, API keys) are stored in the OS keyring
4//! when available, keeping `config.json` free of plaintext credentials when keyring succeeds.
5
6use anyhow::{anyhow, Context, Result};
7use clap::Args;
8use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
9use std::collections::HashMap;
10use std::fs;
11use std::io::Read;
12
13use crate::client::RommClient;
14use crate::config::{
15    default_theme_id, normalize_romm_origin, persist_user_config, user_config_json_path,
16    AuthConfig, Config, ExtrasDefaults, RomsLayoutConfig,
17};
18use crate::endpoints::platforms::ListPlatforms;
19
20#[derive(Args, Debug, Clone)]
21pub struct InitCommand {
22    /// Overwrite existing user config `config.json` without asking
23    #[arg(long)]
24    pub force: bool,
25
26    /// Print the path to the user config `config.json` and exit
27    #[arg(long)]
28    pub print_path: bool,
29
30    /// RomM origin URL (e.g. <https://romm.example>). If provided with a token, skips interactive prompts.
31    #[arg(long)]
32    pub url: Option<String>,
33
34    /// Bearer token string (discouraged: visible in process list).
35    #[arg(long)]
36    pub token: Option<String>,
37
38    /// Read Bearer token from a UTF-8 file. Use '-' for stdin.
39    #[arg(long)]
40    pub token_file: Option<String>,
41
42    /// ROMs directory.
43    #[arg(long)]
44    pub download_dir: Option<String>,
45
46    /// Disable HTTPS (use HTTP instead).
47    #[arg(long)]
48    pub no_https: bool,
49
50    /// Verify URL and token by fetching OpenAPI after saving.
51    #[arg(long)]
52    pub check: bool,
53}
54
55enum AuthChoice {
56    None,
57    Basic,
58    Bearer,
59    ApiKeyHeader,
60    PairingCode,
61}
62
63pub async fn handle(cmd: InitCommand, verbose: bool) -> Result<()> {
64    let Some(path) = user_config_json_path() else {
65        return Err(anyhow!(
66            "Could not determine config directory (no HOME / APPDATA?)."
67        ));
68    };
69
70    if cmd.print_path {
71        println!("{}", path.display());
72        return Ok(());
73    }
74
75    let dir = path
76        .parent()
77        .ok_or_else(|| anyhow!("invalid config path"))?;
78
79    let is_non_interactive = cmd.url.is_some() || cmd.token.is_some() || cmd.token_file.is_some();
80
81    if path.exists() && !cmd.force {
82        if is_non_interactive {
83            return Err(anyhow!(
84                "Config file already exists at {}. Use --force to overwrite.",
85                path.display()
86            ));
87        }
88        let cont = Confirm::with_theme(&ColorfulTheme::default())
89            .with_prompt(format!("Overwrite existing config at {}?", path.display()))
90            .default(false)
91            .interact()?;
92        if !cont {
93            println!("Aborted.");
94            return Ok(());
95        }
96    }
97
98    fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
99
100    // ── Non-interactive quick setup ────────────────────────────────────
101    if let Some(url) = cmd.url {
102        let token = match (cmd.token, cmd.token_file) {
103            (Some(t), _) => Some(t),
104            (None, Some(f)) => {
105                let mut content = String::new();
106                if f == "-" {
107                    std::io::stdin()
108                        .read_to_string(&mut content)
109                        .context("read token from stdin")?;
110                } else {
111                    content =
112                        fs::read_to_string(&f).with_context(|| format!("read token file {}", f))?;
113                }
114                Some(content.trim().to_string())
115            }
116            (None, None) => None,
117        };
118
119        if token.is_none() {
120            return Err(anyhow!("--url requires either --token or --token-file"));
121        }
122
123        let base_url = normalize_romm_origin(&url);
124        let default_dl_dir = dirs::download_dir()
125            .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
126            .join("romm-cli");
127        let download_dir = cmd
128            .download_dir
129            .unwrap_or_else(|| default_dl_dir.display().to_string());
130        let use_https = !cmd.no_https;
131        let auth = Some(AuthConfig::Bearer {
132            token: token.unwrap(),
133        });
134
135        let config = Config {
136            base_url,
137            download_dir,
138            use_https,
139            auth,
140            extras_defaults: ExtrasDefaults::default(),
141            save_sync: Default::default(),
142            roms_layout: Default::default(),
143            theme: default_theme_id(),
144        };
145        persist_user_config(&config)?;
146        println!("Wrote {}", path.display());
147
148        if cmd.check {
149            let client = RommClient::new(&config, verbose)?;
150            println!("Checking connection to {}...", config.base_url);
151            client
152                .fetch_openapi_json()
153                .await
154                .context("failed to fetch OpenAPI JSON")?;
155            println!("Success: connected and fetched OpenAPI spec.");
156
157            println!("Verifying authentication...");
158            client
159                .call(&crate::endpoints::platforms::ListPlatforms)
160                .await
161                .context("failed to authenticate or fetch platforms")?;
162            println!("Success: authentication verified.");
163        }
164        return Ok(());
165    }
166
167    // ── Interactive setup ──────────────────────────────────────────────
168    if cmd.token.is_some() || cmd.token_file.is_some() {
169        return Err(anyhow!("--token and --token-file require --url"));
170    }
171
172    let base_input: String = Input::with_theme(&ColorfulTheme::default())
173        .with_prompt("RomM web URL (same as in your browser; do not add /api)")
174        .with_initial_text("https://")
175        .interact_text()?;
176
177    let base_input = base_input.trim();
178    if base_input.is_empty() {
179        return Err(anyhow!("Base URL cannot be empty"));
180    }
181
182    let had_api_path = base_input.trim_end_matches('/').ends_with("/api");
183    let base_url = normalize_romm_origin(base_input);
184    if had_api_path {
185        println!(
186            "Using `{base_url}` — `/api` was removed. Requests use `/api/...` under that origin automatically."
187        );
188    }
189
190    // ── ROMs directory ─────────────────────────────────────────────────
191    let default_dl_dir = dirs::download_dir()
192        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
193        .join("romm-cli");
194
195    let download_dir: String = Input::with_theme(&ColorfulTheme::default())
196        .with_prompt("ROMs directory")
197        .default(default_dl_dir.display().to_string())
198        .interact_text()?;
199
200    let download_dir = download_dir.trim().to_string();
201
202    // ── Authentication ─────────────────────────────────────────────────
203    let use_https = Confirm::with_theme(&ColorfulTheme::default())
204        .with_prompt("Connect over HTTPS?")
205        .default(true)
206        .interact()?;
207
208    let items = vec![
209        "No authentication",
210        "Basic (username + password)",
211        "API Token (Bearer)",
212        "API key in custom header",
213        "Pair with Web UI (8-character code)",
214    ];
215    let idx = Select::with_theme(&ColorfulTheme::default())
216        .with_prompt("Authentication")
217        .items(&items)
218        .default(0)
219        .interact()?;
220
221    let choice = match idx {
222        0 => AuthChoice::None,
223        1 => AuthChoice::Basic,
224        2 => AuthChoice::Bearer,
225        3 => AuthChoice::ApiKeyHeader,
226        4 => AuthChoice::PairingCode,
227        _ => AuthChoice::None,
228    };
229
230    let auth: Option<AuthConfig> = match choice {
231        AuthChoice::None => None,
232        AuthChoice::Basic => {
233            let username: String = Input::with_theme(&ColorfulTheme::default())
234                .with_prompt("Username")
235                .interact_text()?;
236            let password = Password::with_theme(&ColorfulTheme::default())
237                .with_prompt("Password")
238                .interact()?;
239            Some(AuthConfig::Basic {
240                username: username.trim().to_string(),
241                password,
242            })
243        }
244        AuthChoice::Bearer => {
245            let token = Password::with_theme(&ColorfulTheme::default())
246                .with_prompt("API Token")
247                .interact()?;
248            Some(AuthConfig::Bearer { token })
249        }
250        AuthChoice::ApiKeyHeader => {
251            let header: String = Input::with_theme(&ColorfulTheme::default())
252                .with_prompt("Header name (e.g. X-API-Key)")
253                .interact_text()?;
254            let key = Password::with_theme(&ColorfulTheme::default())
255                .with_prompt("API key value")
256                .interact()?;
257            Some(AuthConfig::ApiKey {
258                header: header.trim().to_string(),
259                key,
260            })
261        }
262        AuthChoice::PairingCode => {
263            let code: String = Input::with_theme(&ColorfulTheme::default())
264                .with_prompt("8-character pairing code")
265                .interact_text()?;
266
267            println!("Exchanging pairing code...");
268            let temp_config = Config {
269                base_url: base_url.clone(),
270                download_dir: download_dir.clone(),
271                use_https,
272                auth: None,
273                extras_defaults: ExtrasDefaults::default(),
274                save_sync: Default::default(),
275                roms_layout: Default::default(),
276                theme: default_theme_id(),
277            };
278            let client = RommClient::new(&temp_config, verbose)?;
279            let endpoint = crate::endpoints::client_tokens::ExchangeClientToken { code };
280
281            let response = client
282                .call(&endpoint)
283                .await
284                .context("failed to exchange pairing code")?;
285            println!("Successfully paired device as '{}'", response.name);
286
287            Some(AuthConfig::Bearer {
288                token: response.raw_token,
289            })
290        }
291    };
292
293    let mut platform_dirs = HashMap::new();
294    if auth.is_some() {
295        let map_custom = Confirm::with_theme(&ColorfulTheme::default())
296            .with_prompt("Map custom paths for consoles on other drives now?")
297            .default(false)
298            .interact()?;
299        if map_custom {
300            let temp_config = Config {
301                base_url: base_url.clone(),
302                download_dir: download_dir.clone(),
303                use_https,
304                auth: auth.clone(),
305                extras_defaults: ExtrasDefaults::default(),
306                save_sync: Default::default(),
307                roms_layout: Default::default(),
308                theme: default_theme_id(),
309            };
310            let client = RommClient::new(&temp_config, verbose)?;
311            let platforms = client
312                .call(&ListPlatforms)
313                .await
314                .context("failed to fetch platforms for custom path mapping")?;
315            prompt_custom_console_paths(&platforms, &mut platform_dirs)?;
316        }
317    }
318    let mut roms_layout = RomsLayoutConfig::default();
319    roms_layout.platform_dirs = platform_dirs;
320
321    let config = Config {
322        base_url,
323        download_dir,
324        use_https,
325        auth,
326        extras_defaults: ExtrasDefaults::default(),
327        save_sync: Default::default(),
328        roms_layout,
329        theme: default_theme_id(),
330    };
331    persist_user_config(&config)?;
332
333    println!("Wrote {}", path.display());
334    println!("Secrets are stored in the OS keyring when available (see file comments if plaintext fallback was used).");
335    println!("You can run `romm-cli tui` or `romm-tui` to start the TUI.");
336    Ok(())
337}
338
339fn prompt_custom_console_paths(
340    platforms: &[crate::types::Platform],
341    platform_dirs: &mut HashMap<u64, String>,
342) -> Result<()> {
343    if platforms.is_empty() {
344        println!("No platforms returned from the server; configure custom paths later in TUI Settings → ROMs.");
345        return Ok(());
346    }
347    loop {
348        let mut items: Vec<String> = platforms
349            .iter()
350            .map(|p| {
351                let mapped = platform_dirs
352                    .get(&p.id)
353                    .map(|s| s.as_str())
354                    .unwrap_or("(base default)");
355                format!("{} — {mapped}", p.name)
356            })
357            .collect();
358        items.push("Done mapping".to_string());
359        let idx = Select::with_theme(&ColorfulTheme::default())
360            .with_prompt("Choose a console to set a custom path (or finish)")
361            .items(&items)
362            .default(0)
363            .interact()?;
364        if idx == items.len() - 1 {
365            break;
366        }
367        let platform = &platforms[idx];
368        let path: String = Input::with_theme(&ColorfulTheme::default())
369            .with_prompt(format!(
370                "Custom path for {} (leave empty to clear)",
371                platform.name
372            ))
373            .allow_empty(true)
374            .interact_text()?;
375        let path = path.trim();
376        if path.is_empty() {
377            platform_dirs.remove(&platform.id);
378        } else {
379            platform_dirs.insert(platform.id, path.to_string());
380        }
381    }
382    Ok(())
383}