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;
10
11use crate::config::{normalize_romm_origin, persist_user_config, user_config_env_path, AuthConfig};
12
13#[derive(Args, Debug, Clone)]
14pub struct InitCommand {
15    /// Overwrite existing user config `.env` without asking
16    #[arg(long)]
17    pub force: bool,
18
19    /// Print the path to the user config `.env` and exit
20    #[arg(long)]
21    pub print_path: bool,
22}
23
24enum AuthChoice {
25    None,
26    Basic,
27    Bearer,
28    ApiKeyHeader,
29}
30
31pub fn handle(cmd: InitCommand) -> Result<()> {
32    let Some(path) = user_config_env_path() else {
33        return Err(anyhow!(
34            "Could not determine config directory (no HOME / APPDATA?)."
35        ));
36    };
37
38    if cmd.print_path {
39        println!("{}", path.display());
40        return Ok(());
41    }
42
43    let dir = path
44        .parent()
45        .ok_or_else(|| anyhow!("invalid config path"))?;
46
47    if path.exists() && !cmd.force {
48        let cont = Confirm::with_theme(&ColorfulTheme::default())
49            .with_prompt(format!("Overwrite existing config at {}?", path.display()))
50            .default(false)
51            .interact()?;
52        if !cont {
53            println!("Aborted.");
54            return Ok(());
55        }
56    }
57
58    fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
59
60    let base_input: String = Input::with_theme(&ColorfulTheme::default())
61        .with_prompt("RomM web URL (same as in your browser; do not add /api)")
62        .with_initial_text("https://")
63        .interact_text()?;
64
65    let base_input = base_input.trim();
66    if base_input.is_empty() {
67        return Err(anyhow!("Base URL cannot be empty"));
68    }
69
70    let had_api_path = base_input.trim_end_matches('/').ends_with("/api");
71    let base_url = normalize_romm_origin(base_input);
72    if had_api_path {
73        println!(
74            "Using `{base_url}` — `/api` was removed. Requests use `/api/...` under that origin automatically."
75        );
76    }
77
78    // ── Download directory ──────────────────────────────────────────────
79    let default_dl_dir = dirs::download_dir()
80        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
81        .join("romm-cli");
82
83    let download_dir: String = Input::with_theme(&ColorfulTheme::default())
84        .with_prompt("Download directory for ROMs")
85        .default(default_dl_dir.display().to_string())
86        .interact_text()?;
87
88    let download_dir = download_dir.trim().to_string();
89
90    // ── Authentication ─────────────────────────────────────────────────
91    let items = vec![
92        "No authentication",
93        "Basic (username + password)",
94        "Bearer token",
95        "API key in custom header",
96    ];
97    let idx = Select::with_theme(&ColorfulTheme::default())
98        .with_prompt("Authentication")
99        .items(&items)
100        .default(0)
101        .interact()?;
102
103    let choice = match idx {
104        0 => AuthChoice::None,
105        1 => AuthChoice::Basic,
106        2 => AuthChoice::Bearer,
107        3 => AuthChoice::ApiKeyHeader,
108        _ => AuthChoice::None,
109    };
110
111    let auth: Option<AuthConfig> = match choice {
112        AuthChoice::None => None,
113        AuthChoice::Basic => {
114            let username: String = Input::with_theme(&ColorfulTheme::default())
115                .with_prompt("Username")
116                .interact_text()?;
117            let password = Password::with_theme(&ColorfulTheme::default())
118                .with_prompt("Password")
119                .interact()?;
120            Some(AuthConfig::Basic {
121                username: username.trim().to_string(),
122                password,
123            })
124        }
125        AuthChoice::Bearer => {
126            let token = Password::with_theme(&ColorfulTheme::default())
127                .with_prompt("Bearer token")
128                .interact()?;
129            Some(AuthConfig::Bearer { token })
130        }
131        AuthChoice::ApiKeyHeader => {
132            let header: String = Input::with_theme(&ColorfulTheme::default())
133                .with_prompt("Header name (e.g. X-API-Key)")
134                .interact_text()?;
135            let key = Password::with_theme(&ColorfulTheme::default())
136                .with_prompt("API key value")
137                .interact()?;
138            Some(AuthConfig::ApiKey {
139                header: header.trim().to_string(),
140                key,
141            })
142        }
143    };
144
145    let use_https = Confirm::with_theme(&ColorfulTheme::default())
146        .with_prompt("Connect over HTTPS?")
147        .default(true)
148        .interact()?;
149
150    persist_user_config(&base_url, &download_dir, use_https, auth)?;
151
152    println!("Wrote {}", path.display());
153    println!("Secrets are stored in the OS keyring when available (see file comments if plaintext fallback was used).");
154    println!("You can run `romm-cli tui` or `romm-tui` to start the TUI.");
155    Ok(())
156}