romm_cli/commands/
init.rs1use 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 #[arg(long)]
18 pub force: bool,
19
20 #[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 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 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 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 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 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 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}