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    normalize_romm_origin, persist_user_config, user_config_json_path, AuthConfig, Config,
16    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        };
144        persist_user_config(&config)?;
145        println!("Wrote {}", path.display());
146
147        if cmd.check {
148            let client = RommClient::new(&config, verbose)?;
149            println!("Checking connection to {}...", config.base_url);
150            client
151                .fetch_openapi_json()
152                .await
153                .context("failed to fetch OpenAPI JSON")?;
154            println!("Success: connected and fetched OpenAPI spec.");
155
156            println!("Verifying authentication...");
157            client
158                .call(&crate::endpoints::platforms::ListPlatforms)
159                .await
160                .context("failed to authenticate or fetch platforms")?;
161            println!("Success: authentication verified.");
162        }
163        return Ok(());
164    }
165
166    // ── Interactive setup ──────────────────────────────────────────────
167    if cmd.token.is_some() || cmd.token_file.is_some() {
168        return Err(anyhow!("--token and --token-file require --url"));
169    }
170
171    let base_input: String = Input::with_theme(&ColorfulTheme::default())
172        .with_prompt("RomM web URL (same as in your browser; do not add /api)")
173        .with_initial_text("https://")
174        .interact_text()?;
175
176    let base_input = base_input.trim();
177    if base_input.is_empty() {
178        return Err(anyhow!("Base URL cannot be empty"));
179    }
180
181    let had_api_path = base_input.trim_end_matches('/').ends_with("/api");
182    let base_url = normalize_romm_origin(base_input);
183    if had_api_path {
184        println!(
185            "Using `{base_url}` — `/api` was removed. Requests use `/api/...` under that origin automatically."
186        );
187    }
188
189    // ── ROMs directory ─────────────────────────────────────────────────
190    let default_dl_dir = dirs::download_dir()
191        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
192        .join("romm-cli");
193
194    let download_dir: String = Input::with_theme(&ColorfulTheme::default())
195        .with_prompt("ROMs directory")
196        .default(default_dl_dir.display().to_string())
197        .interact_text()?;
198
199    let download_dir = download_dir.trim().to_string();
200
201    // ── Authentication ─────────────────────────────────────────────────
202    let use_https = Confirm::with_theme(&ColorfulTheme::default())
203        .with_prompt("Connect over HTTPS?")
204        .default(true)
205        .interact()?;
206
207    let items = vec![
208        "No authentication",
209        "Basic (username + password)",
210        "API Token (Bearer)",
211        "API key in custom header",
212        "Pair with Web UI (8-character code)",
213    ];
214    let idx = Select::with_theme(&ColorfulTheme::default())
215        .with_prompt("Authentication")
216        .items(&items)
217        .default(0)
218        .interact()?;
219
220    let choice = match idx {
221        0 => AuthChoice::None,
222        1 => AuthChoice::Basic,
223        2 => AuthChoice::Bearer,
224        3 => AuthChoice::ApiKeyHeader,
225        4 => AuthChoice::PairingCode,
226        _ => AuthChoice::None,
227    };
228
229    let auth: Option<AuthConfig> = match choice {
230        AuthChoice::None => None,
231        AuthChoice::Basic => {
232            let username: String = Input::with_theme(&ColorfulTheme::default())
233                .with_prompt("Username")
234                .interact_text()?;
235            let password = Password::with_theme(&ColorfulTheme::default())
236                .with_prompt("Password")
237                .interact()?;
238            Some(AuthConfig::Basic {
239                username: username.trim().to_string(),
240                password,
241            })
242        }
243        AuthChoice::Bearer => {
244            let token = Password::with_theme(&ColorfulTheme::default())
245                .with_prompt("API Token")
246                .interact()?;
247            Some(AuthConfig::Bearer { token })
248        }
249        AuthChoice::ApiKeyHeader => {
250            let header: String = Input::with_theme(&ColorfulTheme::default())
251                .with_prompt("Header name (e.g. X-API-Key)")
252                .interact_text()?;
253            let key = Password::with_theme(&ColorfulTheme::default())
254                .with_prompt("API key value")
255                .interact()?;
256            Some(AuthConfig::ApiKey {
257                header: header.trim().to_string(),
258                key,
259            })
260        }
261        AuthChoice::PairingCode => {
262            let code: String = Input::with_theme(&ColorfulTheme::default())
263                .with_prompt("8-character pairing code")
264                .interact_text()?;
265
266            println!("Exchanging pairing code...");
267            let temp_config = Config {
268                base_url: base_url.clone(),
269                download_dir: download_dir.clone(),
270                use_https,
271                auth: None,
272                extras_defaults: ExtrasDefaults::default(),
273                save_sync: Default::default(),
274                roms_layout: Default::default(),
275            };
276            let client = RommClient::new(&temp_config, verbose)?;
277            let endpoint = crate::endpoints::client_tokens::ExchangeClientToken { code };
278
279            let response = client
280                .call(&endpoint)
281                .await
282                .context("failed to exchange pairing code")?;
283            println!("Successfully paired device as '{}'", response.name);
284
285            Some(AuthConfig::Bearer {
286                token: response.raw_token,
287            })
288        }
289    };
290
291    let mut platform_dirs = HashMap::new();
292    if auth.is_some() {
293        let map_custom = Confirm::with_theme(&ColorfulTheme::default())
294            .with_prompt("Map custom paths for consoles on other drives now?")
295            .default(false)
296            .interact()?;
297        if map_custom {
298            let temp_config = Config {
299                base_url: base_url.clone(),
300                download_dir: download_dir.clone(),
301                use_https,
302                auth: auth.clone(),
303                extras_defaults: ExtrasDefaults::default(),
304                save_sync: Default::default(),
305                roms_layout: Default::default(),
306            };
307            let client = RommClient::new(&temp_config, verbose)?;
308            let platforms = client
309                .call(&ListPlatforms)
310                .await
311                .context("failed to fetch platforms for custom path mapping")?;
312            prompt_custom_console_paths(&platforms, &mut platform_dirs)?;
313        }
314    }
315    let mut roms_layout = RomsLayoutConfig::default();
316    roms_layout.platform_dirs = platform_dirs;
317
318    let config = Config {
319        base_url,
320        download_dir,
321        use_https,
322        auth,
323        extras_defaults: ExtrasDefaults::default(),
324        save_sync: Default::default(),
325        roms_layout,
326    };
327    persist_user_config(&config)?;
328
329    println!("Wrote {}", path.display());
330    println!("Secrets are stored in the OS keyring when available (see file comments if plaintext fallback was used).");
331    println!("You can run `romm-cli tui` or `romm-tui` to start the TUI.");
332    Ok(())
333}
334
335fn prompt_custom_console_paths(
336    platforms: &[crate::types::Platform],
337    platform_dirs: &mut HashMap<u64, String>,
338) -> Result<()> {
339    if platforms.is_empty() {
340        println!("No platforms returned from the server; configure custom paths later in TUI Settings → ROMs.");
341        return Ok(());
342    }
343    loop {
344        let mut items: Vec<String> = platforms
345            .iter()
346            .map(|p| {
347                let mapped = platform_dirs
348                    .get(&p.id)
349                    .map(|s| s.as_str())
350                    .unwrap_or("(base default)");
351                format!("{} — {mapped}", p.name)
352            })
353            .collect();
354        items.push("Done mapping".to_string());
355        let idx = Select::with_theme(&ColorfulTheme::default())
356            .with_prompt("Choose a console to set a custom path (or finish)")
357            .items(&items)
358            .default(0)
359            .interact()?;
360        if idx == items.len() - 1 {
361            break;
362        }
363        let platform = &platforms[idx];
364        let path: String = Input::with_theme(&ColorfulTheme::default())
365            .with_prompt(format!(
366                "Custom path for {} (leave empty to clear)",
367                platform.name
368            ))
369            .allow_empty(true)
370            .interact_text()?;
371        let path = path.trim();
372        if path.is_empty() {
373            platform_dirs.remove(&platform.id);
374        } else {
375            platform_dirs.insert(platform.id, path.to_string());
376        }
377    }
378    Ok(())
379}