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        };
140        persist_user_config(&config)?;
141        println!("Wrote {}", path.display());
142
143        if cmd.check {
144            let client = RommClient::new(&config, verbose)?;
145            println!("Checking connection to {}...", config.base_url);
146            client
147                .fetch_openapi_json()
148                .await
149                .context("failed to fetch OpenAPI JSON")?;
150            println!("Success: connected and fetched OpenAPI spec.");
151
152            println!("Verifying authentication...");
153            client
154                .call(&crate::endpoints::platforms::ListPlatforms)
155                .await
156                .context("failed to authenticate or fetch platforms")?;
157            println!("Success: authentication verified.");
158        }
159        return Ok(());
160    }
161
162    // ── Interactive setup ──────────────────────────────────────────────
163    if cmd.token.is_some() || cmd.token_file.is_some() {
164        return Err(anyhow!("--token and --token-file require --url"));
165    }
166
167    let base_input: String = Input::with_theme(&ColorfulTheme::default())
168        .with_prompt("RomM web URL (same as in your browser; do not add /api)")
169        .with_initial_text("https://")
170        .interact_text()?;
171
172    let base_input = base_input.trim();
173    if base_input.is_empty() {
174        return Err(anyhow!("Base URL cannot be empty"));
175    }
176
177    let had_api_path = base_input.trim_end_matches('/').ends_with("/api");
178    let base_url = normalize_romm_origin(base_input);
179    if had_api_path {
180        println!(
181            "Using `{base_url}` — `/api` was removed. Requests use `/api/...` under that origin automatically."
182        );
183    }
184
185    // ── ROMs directory ─────────────────────────────────────────────────
186    let default_dl_dir = dirs::download_dir()
187        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
188        .join("romm-cli");
189
190    let download_dir: String = Input::with_theme(&ColorfulTheme::default())
191        .with_prompt("ROMs directory")
192        .default(default_dl_dir.display().to_string())
193        .interact_text()?;
194
195    let download_dir = download_dir.trim().to_string();
196
197    // ── Authentication ─────────────────────────────────────────────────
198    let use_https = Confirm::with_theme(&ColorfulTheme::default())
199        .with_prompt("Connect over HTTPS?")
200        .default(true)
201        .interact()?;
202
203    let items = vec![
204        "No authentication",
205        "Basic (username + password)",
206        "API Token (Bearer)",
207        "API key in custom header",
208        "Pair with Web UI (8-character code)",
209    ];
210    let idx = Select::with_theme(&ColorfulTheme::default())
211        .with_prompt("Authentication")
212        .items(&items)
213        .default(0)
214        .interact()?;
215
216    let choice = match idx {
217        0 => AuthChoice::None,
218        1 => AuthChoice::Basic,
219        2 => AuthChoice::Bearer,
220        3 => AuthChoice::ApiKeyHeader,
221        4 => AuthChoice::PairingCode,
222        _ => AuthChoice::None,
223    };
224
225    let auth: Option<AuthConfig> = match choice {
226        AuthChoice::None => None,
227        AuthChoice::Basic => {
228            let username: String = Input::with_theme(&ColorfulTheme::default())
229                .with_prompt("Username")
230                .interact_text()?;
231            let password = Password::with_theme(&ColorfulTheme::default())
232                .with_prompt("Password")
233                .interact()?;
234            Some(AuthConfig::Basic {
235                username: username.trim().to_string(),
236                password,
237            })
238        }
239        AuthChoice::Bearer => {
240            let token = Password::with_theme(&ColorfulTheme::default())
241                .with_prompt("API Token")
242                .interact()?;
243            Some(AuthConfig::Bearer { token })
244        }
245        AuthChoice::ApiKeyHeader => {
246            let header: String = Input::with_theme(&ColorfulTheme::default())
247                .with_prompt("Header name (e.g. X-API-Key)")
248                .interact_text()?;
249            let key = Password::with_theme(&ColorfulTheme::default())
250                .with_prompt("API key value")
251                .interact()?;
252            Some(AuthConfig::ApiKey {
253                header: header.trim().to_string(),
254                key,
255            })
256        }
257        AuthChoice::PairingCode => {
258            let code: String = Input::with_theme(&ColorfulTheme::default())
259                .with_prompt("8-character pairing code")
260                .interact_text()?;
261
262            println!("Exchanging pairing code...");
263            let temp_config = Config {
264                base_url: base_url.clone(),
265                download_dir: download_dir.clone(),
266                use_https,
267                auth: None,
268                extras_defaults: ExtrasDefaults::default(),
269            };
270            let client = RommClient::new(&temp_config, verbose)?;
271            let endpoint = crate::endpoints::client_tokens::ExchangeClientToken { code };
272
273            let response = client
274                .call(&endpoint)
275                .await
276                .context("failed to exchange pairing code")?;
277            println!("Successfully paired device as '{}'", response.name);
278
279            Some(AuthConfig::Bearer {
280                token: response.raw_token,
281            })
282        }
283    };
284
285    let config = Config {
286        base_url,
287        download_dir,
288        use_https,
289        auth,
290        extras_defaults: ExtrasDefaults::default(),
291    };
292    persist_user_config(&config)?;
293
294    println!("Wrote {}", path.display());
295    println!("Secrets are stored in the OS keyring when available (see file comments if plaintext fallback was used).");
296    println!("You can run `romm-cli tui` or `romm-tui` to start the TUI.");
297    Ok(())
298}