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