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::Write;
11
12use crate::config::{keyring_store, user_config_env_path};
13
14#[derive(Args, Debug, Clone)]
15pub struct InitCommand {
16    /// Overwrite existing user config `.env` without asking
17    #[arg(long)]
18    pub force: bool,
19
20    /// Print the path to the user config `.env` and exit
21    #[arg(long)]
22    pub print_path: bool,
23}
24
25enum AuthChoice {
26    None,
27    Basic,
28    Bearer,
29    ApiKeyHeader,
30}
31
32pub fn handle(cmd: InitCommand) -> Result<()> {
33    let Some(path) = user_config_env_path() else {
34        return Err(anyhow!(
35            "Could not determine config directory (no HOME / APPDATA?)."
36        ));
37    };
38
39    if cmd.print_path {
40        println!("{}", path.display());
41        return Ok(());
42    }
43
44    let dir = path
45        .parent()
46        .ok_or_else(|| anyhow!("invalid config path"))?;
47
48    if path.exists() && !cmd.force {
49        let cont = Confirm::with_theme(&ColorfulTheme::default())
50            .with_prompt(format!("Overwrite existing config at {}?", path.display()))
51            .default(false)
52            .interact()?;
53        if !cont {
54            println!("Aborted.");
55            return Ok(());
56        }
57    }
58
59    fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
60
61    let base_url: String = Input::with_theme(&ColorfulTheme::default())
62        .with_prompt("ROMM API base URL")
63        .with_initial_text("http://")
64        .interact_text()?;
65
66    let base_url = base_url.trim().to_string();
67    if base_url.is_empty() {
68        return Err(anyhow!("Base URL cannot be empty"));
69    }
70
71    // ── Download directory ──────────────────────────────────────────────
72    let default_dl_dir = dirs::download_dir()
73        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
74        .join("romm-cli");
75
76    let download_dir: String = Input::with_theme(&ColorfulTheme::default())
77        .with_prompt("Download directory for ROMs")
78        .default(default_dl_dir.display().to_string())
79        .interact_text()?;
80
81    let download_dir = download_dir.trim().to_string();
82
83    // ── Authentication ─────────────────────────────────────────────────
84    let items = vec![
85        "No authentication",
86        "Basic (username + password)",
87        "Bearer token",
88        "API key in custom header",
89    ];
90    let idx = Select::with_theme(&ColorfulTheme::default())
91        .with_prompt("Authentication")
92        .items(&items)
93        .default(0)
94        .interact()?;
95
96    let choice = match idx {
97        0 => AuthChoice::None,
98        1 => AuthChoice::Basic,
99        2 => AuthChoice::Bearer,
100        3 => AuthChoice::ApiKeyHeader,
101        _ => AuthChoice::None,
102    };
103
104    // ── Build .env lines (secrets go to keyring, not here) ─────────────
105    let mut lines: Vec<String> = vec![
106        "# romm-cli user configuration".to_string(),
107        "# Secrets are stored in the OS keyring when available.".to_string(),
108        "# Applied after project .env: only fills variables not already set.".to_string(),
109        String::new(),
110        format!("API_BASE_URL={}", escape_env_value(&base_url)),
111        format!("ROMM_DOWNLOAD_DIR={}", escape_env_value(&download_dir)),
112        String::new(),
113    ];
114
115    // Track what we stored in the keyring vs .env file.
116    let mut keyring_success = true;
117
118    match choice {
119        AuthChoice::None => {
120            lines.push("# No auth variables set.".to_string());
121        }
122        AuthChoice::Basic => {
123            let username: String = Input::with_theme(&ColorfulTheme::default())
124                .with_prompt("Username")
125                .interact_text()?;
126            let password = Password::with_theme(&ColorfulTheme::default())
127                .with_prompt("Password")
128                .interact()?;
129
130            // Username is not secret, always in .env
131            lines.push("# Basic auth (password stored in OS keyring)".to_string());
132            lines.push(format!(
133                "API_USERNAME={}",
134                escape_env_value(username.trim())
135            ));
136
137            // Try keyring for password; fall back to .env
138            if let Err(e) = keyring_store("API_PASSWORD", &password) {
139                eprintln!(
140                    "warning: could not store password in OS keyring: {e}\n\
141                     Falling back to plaintext .env storage."
142                );
143                lines.push(format!("API_PASSWORD={}", escape_env_value(&password)));
144                keyring_success = false;
145            }
146        }
147        AuthChoice::Bearer => {
148            let token = Password::with_theme(&ColorfulTheme::default())
149                .with_prompt("Bearer token")
150                .interact()?;
151
152            lines.push("# Bearer token (stored in OS keyring)".to_string());
153
154            if let Err(e) = keyring_store("API_TOKEN", &token) {
155                eprintln!(
156                    "warning: could not store token in OS keyring: {e}\n\
157                     Falling back to plaintext .env storage."
158                );
159                lines.push(format!("API_TOKEN={}", escape_env_value(&token)));
160                keyring_success = false;
161            }
162        }
163        AuthChoice::ApiKeyHeader => {
164            let header: String = Input::with_theme(&ColorfulTheme::default())
165                .with_prompt("Header name (e.g. X-API-Key)")
166                .interact_text()?;
167            let key = Password::with_theme(&ColorfulTheme::default())
168                .with_prompt("API key value")
169                .interact()?;
170
171            lines.push("# Custom header API key (key stored in OS keyring)".to_string());
172            lines.push(format!(
173                "API_KEY_HEADER={}",
174                escape_env_value(header.trim())
175            ));
176
177            if let Err(e) = keyring_store("API_KEY", &key) {
178                eprintln!(
179                    "warning: could not store API key in OS keyring: {e}\n\
180                     Falling back to plaintext .env storage."
181                );
182                lines.push(format!("API_KEY={}", escape_env_value(&key)));
183                keyring_success = false;
184            }
185        }
186    }
187
188    let content = lines.join("\n") + "\n";
189    {
190        let mut f = fs::File::create(&path).with_context(|| format!("write {}", path.display()))?;
191        f.write_all(content.as_bytes())?;
192    }
193
194    #[cfg(unix)]
195    {
196        use std::os::unix::fs::PermissionsExt;
197        let mut perms = fs::metadata(&path)?.permissions();
198        perms.set_mode(0o600);
199        fs::set_permissions(&path, perms)?;
200    }
201
202    println!("Wrote {}", path.display());
203    if keyring_success {
204        println!("Credentials stored securely in the OS keyring.");
205    }
206    println!("You can run `romm-cli tui` or `romm-tui` to start the TUI.");
207    Ok(())
208}
209
210fn escape_env_value(s: &str) -> String {
211    let needs_quote = s.is_empty()
212        || s.chars()
213            .any(|c| c.is_whitespace() || c == '#' || c == '"' || c == '\'');
214    if needs_quote {
215        let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
216        format!("\"{}\"", escaped)
217    } else {
218        s.to_string()
219    }
220}