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