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::fs;
10use std::io::Read;
11
12use crate::client::RommClient;
13use crate::config::{
14    normalize_romm_origin, persist_user_config, user_config_json_path, AuthConfig, Config,
15};
16
17#[derive(Args, Debug, Clone)]
18pub struct InitCommand {
19    /// Overwrite existing user config `config.json` without asking
20    #[arg(long)]
21    pub force: bool,
22
23    /// Print the path to the user config `config.json` and exit
24    #[arg(long)]
25    pub print_path: bool,
26
27    /// RomM origin URL (e.g. https://romm.example). If provided with a token, skips interactive prompts.
28    #[arg(long)]
29    pub url: Option<String>,
30
31    /// Bearer token string (discouraged: visible in process list).
32    #[arg(long)]
33    pub token: Option<String>,
34
35    /// Read Bearer token from a UTF-8 file. Use '-' for stdin.
36    #[arg(long)]
37    pub token_file: Option<String>,
38
39    /// Download directory for ROMs.
40    #[arg(long)]
41    pub download_dir: Option<String>,
42
43    /// Disable HTTPS (use HTTP instead).
44    #[arg(long)]
45    pub no_https: bool,
46
47    /// Verify URL and token by fetching OpenAPI after saving.
48    #[arg(long)]
49    pub check: bool,
50}
51
52enum AuthChoice {
53    None,
54    Basic,
55    Bearer,
56    ApiKeyHeader,
57    PairingCode,
58}
59
60pub async fn handle(cmd: InitCommand, verbose: bool) -> Result<()> {
61    let Some(path) = user_config_json_path() else {
62        return Err(anyhow!(
63            "Could not determine config directory (no HOME / APPDATA?)."
64        ));
65    };
66
67    if cmd.print_path {
68        println!("{}", path.display());
69        return Ok(());
70    }
71
72    let dir = path
73        .parent()
74        .ok_or_else(|| anyhow!("invalid config path"))?;
75
76    let is_non_interactive = cmd.url.is_some() || cmd.token.is_some() || cmd.token_file.is_some();
77
78    if path.exists() && !cmd.force {
79        if is_non_interactive {
80            return Err(anyhow!(
81                "Config file already exists at {}. Use --force to overwrite.",
82                path.display()
83            ));
84        }
85        let cont = Confirm::with_theme(&ColorfulTheme::default())
86            .with_prompt(format!("Overwrite existing config at {}?", path.display()))
87            .default(false)
88            .interact()?;
89        if !cont {
90            println!("Aborted.");
91            return Ok(());
92        }
93    }
94
95    fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
96
97    // ── Non-interactive quick setup ────────────────────────────────────
98    if let Some(url) = cmd.url {
99        let token = match (cmd.token, cmd.token_file) {
100            (Some(t), _) => Some(t),
101            (None, Some(f)) => {
102                let mut content = String::new();
103                if f == "-" {
104                    std::io::stdin()
105                        .read_to_string(&mut content)
106                        .context("read token from stdin")?;
107                } else {
108                    content =
109                        fs::read_to_string(&f).with_context(|| format!("read token file {}", f))?;
110                }
111                Some(content.trim().to_string())
112            }
113            (None, None) => None,
114        };
115
116        if token.is_none() {
117            return Err(anyhow!("--url requires either --token or --token-file"));
118        }
119
120        let base_url = normalize_romm_origin(&url);
121        let default_dl_dir = dirs::download_dir()
122            .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
123            .join("romm-cli");
124        let download_dir = cmd
125            .download_dir
126            .unwrap_or_else(|| default_dl_dir.display().to_string());
127        let use_https = !cmd.no_https;
128        let auth = Some(AuthConfig::Bearer {
129            token: token.unwrap(),
130        });
131
132        persist_user_config(&base_url, &download_dir, use_https, auth.clone())?;
133        println!("Wrote {}", path.display());
134
135        if cmd.check {
136            let config = Config {
137                base_url,
138                download_dir,
139                use_https,
140                auth,
141            };
142            let client = RommClient::new(&config, verbose)?;
143            println!("Checking connection to {}...", config.base_url);
144            client
145                .fetch_openapi_json()
146                .await
147                .context("failed to fetch OpenAPI JSON")?;
148            println!("Success: connected and fetched OpenAPI spec.");
149
150            println!("Verifying authentication...");
151            client
152                .call(&crate::endpoints::platforms::ListPlatforms)
153                .await
154                .context("failed to authenticate or fetch platforms")?;
155            println!("Success: authentication verified.");
156        }
157        return Ok(());
158    }
159
160    // ── Interactive setup ──────────────────────────────────────────────
161    if cmd.token.is_some() || cmd.token_file.is_some() {
162        return Err(anyhow!("--token and --token-file require --url"));
163    }
164
165    let base_input: String = Input::with_theme(&ColorfulTheme::default())
166        .with_prompt("RomM web URL (same as in your browser; do not add /api)")
167        .with_initial_text("https://")
168        .interact_text()?;
169
170    let base_input = base_input.trim();
171    if base_input.is_empty() {
172        return Err(anyhow!("Base URL cannot be empty"));
173    }
174
175    let had_api_path = base_input.trim_end_matches('/').ends_with("/api");
176    let base_url = normalize_romm_origin(base_input);
177    if had_api_path {
178        println!(
179            "Using `{base_url}` — `/api` was removed. Requests use `/api/...` under that origin automatically."
180        );
181    }
182
183    // ── Download directory ──────────────────────────────────────────────
184    let default_dl_dir = dirs::download_dir()
185        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
186        .join("romm-cli");
187
188    let download_dir: String = Input::with_theme(&ColorfulTheme::default())
189        .with_prompt("Download directory for ROMs")
190        .default(default_dl_dir.display().to_string())
191        .interact_text()?;
192
193    let download_dir = download_dir.trim().to_string();
194
195    // ── Authentication ─────────────────────────────────────────────────
196    let use_https = Confirm::with_theme(&ColorfulTheme::default())
197        .with_prompt("Connect over HTTPS?")
198        .default(true)
199        .interact()?;
200
201    let items = vec![
202        "No authentication",
203        "Basic (username + password)",
204        "API Token (Bearer)",
205        "API key in custom header",
206        "Pair with Web UI (8-character code)",
207    ];
208    let idx = Select::with_theme(&ColorfulTheme::default())
209        .with_prompt("Authentication")
210        .items(&items)
211        .default(0)
212        .interact()?;
213
214    let choice = match idx {
215        0 => AuthChoice::None,
216        1 => AuthChoice::Basic,
217        2 => AuthChoice::Bearer,
218        3 => AuthChoice::ApiKeyHeader,
219        4 => AuthChoice::PairingCode,
220        _ => AuthChoice::None,
221    };
222
223    let auth: Option<AuthConfig> = match choice {
224        AuthChoice::None => None,
225        AuthChoice::Basic => {
226            let username: String = Input::with_theme(&ColorfulTheme::default())
227                .with_prompt("Username")
228                .interact_text()?;
229            let password = Password::with_theme(&ColorfulTheme::default())
230                .with_prompt("Password")
231                .interact()?;
232            Some(AuthConfig::Basic {
233                username: username.trim().to_string(),
234                password,
235            })
236        }
237        AuthChoice::Bearer => {
238            let token = Password::with_theme(&ColorfulTheme::default())
239                .with_prompt("API Token")
240                .interact()?;
241            Some(AuthConfig::Bearer { token })
242        }
243        AuthChoice::ApiKeyHeader => {
244            let header: String = Input::with_theme(&ColorfulTheme::default())
245                .with_prompt("Header name (e.g. X-API-Key)")
246                .interact_text()?;
247            let key = Password::with_theme(&ColorfulTheme::default())
248                .with_prompt("API key value")
249                .interact()?;
250            Some(AuthConfig::ApiKey {
251                header: header.trim().to_string(),
252                key,
253            })
254        }
255        AuthChoice::PairingCode => {
256            let code: String = Input::with_theme(&ColorfulTheme::default())
257                .with_prompt("8-character pairing code")
258                .interact_text()?;
259
260            println!("Exchanging pairing code...");
261            let temp_config = Config {
262                base_url: base_url.clone(),
263                download_dir: download_dir.clone(),
264                use_https,
265                auth: None,
266            };
267            let client = RommClient::new(&temp_config, verbose)?;
268            let endpoint = crate::endpoints::client_tokens::ExchangeClientToken { code };
269
270            let response = client
271                .call(&endpoint)
272                .await
273                .context("failed to exchange pairing code")?;
274            println!("Successfully paired device as '{}'", response.name);
275
276            Some(AuthConfig::Bearer {
277                token: response.raw_token,
278            })
279        }
280    };
281
282    persist_user_config(&base_url, &download_dir, use_https, auth)?;
283
284    println!("Wrote {}", path.display());
285    println!("Secrets are stored in the OS keyring when available (see file comments if plaintext fallback was used).");
286    println!("You can run `romm-cli tui` or `romm-tui` to start the TUI.");
287    Ok(())
288}