Skip to main content

romm_cli/commands/
init.rs

1//! Interactive `romm-cli init` — writes user-level `romm-cli/.env`.
2//!
3//! Secrets (passwords, tokens, API keys) are stored in the OS keyring
4//! when available, keeping the `.env` file free of plaintext credentials.
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_env_path, AuthConfig, Config,
15};
16
17#[derive(Args, Debug, Clone)]
18pub struct InitCommand {
19    /// Overwrite existing user config `.env` without asking
20    #[arg(long)]
21    pub force: bool,
22
23    /// Print the path to the user config `.env` 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}
58
59pub async fn handle(cmd: InitCommand, verbose: bool) -> Result<()> {
60    let Some(path) = user_config_env_path() else {
61        return Err(anyhow!(
62            "Could not determine config directory (no HOME / APPDATA?)."
63        ));
64    };
65
66    if cmd.print_path {
67        println!("{}", path.display());
68        return Ok(());
69    }
70
71    let dir = path
72        .parent()
73        .ok_or_else(|| anyhow!("invalid config path"))?;
74
75    let is_non_interactive = cmd.url.is_some() || cmd.token.is_some() || cmd.token_file.is_some();
76
77    if path.exists() && !cmd.force {
78        if is_non_interactive {
79            return Err(anyhow!(
80                "Config file already exists at {}. Use --force to overwrite.",
81                path.display()
82            ));
83        }
84        let cont = Confirm::with_theme(&ColorfulTheme::default())
85            .with_prompt(format!("Overwrite existing config at {}?", path.display()))
86            .default(false)
87            .interact()?;
88        if !cont {
89            println!("Aborted.");
90            return Ok(());
91        }
92    }
93
94    fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
95
96    // ── Non-interactive quick setup ────────────────────────────────────
97    if let Some(url) = cmd.url {
98        let token = match (cmd.token, cmd.token_file) {
99            (Some(t), _) => Some(t),
100            (None, Some(f)) => {
101                let mut content = String::new();
102                if f == "-" {
103                    std::io::stdin()
104                        .read_to_string(&mut content)
105                        .context("read token from stdin")?;
106                } else {
107                    content =
108                        fs::read_to_string(&f).with_context(|| format!("read token file {}", f))?;
109                }
110                Some(content.trim().to_string())
111            }
112            (None, None) => None,
113        };
114
115        if token.is_none() {
116            return Err(anyhow!("--url requires either --token or --token-file"));
117        }
118
119        let base_url = normalize_romm_origin(&url);
120        let default_dl_dir = dirs::download_dir()
121            .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
122            .join("romm-cli");
123        let download_dir = cmd
124            .download_dir
125            .unwrap_or_else(|| default_dl_dir.display().to_string());
126        let use_https = !cmd.no_https;
127        let auth = Some(AuthConfig::Bearer {
128            token: token.unwrap(),
129        });
130
131        persist_user_config(&base_url, &download_dir, use_https, auth.clone())?;
132        println!("Wrote {}", path.display());
133
134        if cmd.check {
135            let config = Config {
136                base_url,
137                download_dir,
138                use_https,
139                auth,
140            };
141            let client = RommClient::new(&config, verbose)?;
142            println!("Checking connection to {}...", config.base_url);
143            client
144                .fetch_openapi_json()
145                .await
146                .context("failed to fetch OpenAPI JSON")?;
147            println!("Success: connected and fetched OpenAPI spec.");
148
149            println!("Verifying authentication...");
150            client
151                .call(&crate::endpoints::platforms::ListPlatforms)
152                .await
153                .context("failed to authenticate or fetch platforms")?;
154            println!("Success: authentication verified.");
155        }
156        return Ok(());
157    }
158
159    // ── Interactive setup ──────────────────────────────────────────────
160    if cmd.token.is_some() || cmd.token_file.is_some() {
161        return Err(anyhow!("--token and --token-file require --url"));
162    }
163
164    let base_input: String = Input::with_theme(&ColorfulTheme::default())
165        .with_prompt("RomM web URL (same as in your browser; do not add /api)")
166        .with_initial_text("https://")
167        .interact_text()?;
168
169    let base_input = base_input.trim();
170    if base_input.is_empty() {
171        return Err(anyhow!("Base URL cannot be empty"));
172    }
173
174    let had_api_path = base_input.trim_end_matches('/').ends_with("/api");
175    let base_url = normalize_romm_origin(base_input);
176    if had_api_path {
177        println!(
178            "Using `{base_url}` — `/api` was removed. Requests use `/api/...` under that origin automatically."
179        );
180    }
181
182    // ── Download directory ──────────────────────────────────────────────
183    let default_dl_dir = dirs::download_dir()
184        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
185        .join("romm-cli");
186
187    let download_dir: String = Input::with_theme(&ColorfulTheme::default())
188        .with_prompt("Download directory for ROMs")
189        .default(default_dl_dir.display().to_string())
190        .interact_text()?;
191
192    let download_dir = download_dir.trim().to_string();
193
194    // ── Authentication ─────────────────────────────────────────────────
195    let items = vec![
196        "No authentication",
197        "Basic (username + password)",
198        "API Token (Bearer)",
199        "API key in custom header",
200    ];
201    let idx = Select::with_theme(&ColorfulTheme::default())
202        .with_prompt("Authentication")
203        .items(&items)
204        .default(0)
205        .interact()?;
206
207    let choice = match idx {
208        0 => AuthChoice::None,
209        1 => AuthChoice::Basic,
210        2 => AuthChoice::Bearer,
211        3 => AuthChoice::ApiKeyHeader,
212        _ => AuthChoice::None,
213    };
214
215    let auth: Option<AuthConfig> = match choice {
216        AuthChoice::None => None,
217        AuthChoice::Basic => {
218            let username: String = Input::with_theme(&ColorfulTheme::default())
219                .with_prompt("Username")
220                .interact_text()?;
221            let password = Password::with_theme(&ColorfulTheme::default())
222                .with_prompt("Password")
223                .interact()?;
224            Some(AuthConfig::Basic {
225                username: username.trim().to_string(),
226                password,
227            })
228        }
229        AuthChoice::Bearer => {
230            let token = Password::with_theme(&ColorfulTheme::default())
231                .with_prompt("API Token")
232                .interact()?;
233            Some(AuthConfig::Bearer { token })
234        }
235        AuthChoice::ApiKeyHeader => {
236            let header: String = Input::with_theme(&ColorfulTheme::default())
237                .with_prompt("Header name (e.g. X-API-Key)")
238                .interact_text()?;
239            let key = Password::with_theme(&ColorfulTheme::default())
240                .with_prompt("API key value")
241                .interact()?;
242            Some(AuthConfig::ApiKey {
243                header: header.trim().to_string(),
244                key,
245            })
246        }
247    };
248
249    let use_https = Confirm::with_theme(&ColorfulTheme::default())
250        .with_prompt("Connect over HTTPS?")
251        .default(true)
252        .interact()?;
253
254    persist_user_config(&base_url, &download_dir, use_https, auth)?;
255
256    println!("Wrote {}", path.display());
257    println!("Secrets are stored in the OS keyring when available (see file comments if plaintext fallback was used).");
258    println!("You can run `romm-cli tui` or `romm-tui` to start the TUI.");
259    Ok(())
260}