1use anyhow::{anyhow, Context, Result};
7use clap::Args;
8use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
9use std::collections::HashMap;
10use std::fs;
11use std::io::Read;
12
13use crate::client::RommClient;
14use crate::config::{
15 default_theme_id, normalize_romm_origin, persist_user_config, user_config_json_path,
16 AuthConfig, Config, ExtrasDefaults, RomsLayoutConfig,
17};
18use crate::endpoints::platforms::ListPlatforms;
19
20#[derive(Args, Debug, Clone)]
21pub struct InitCommand {
22 #[arg(long)]
24 pub force: bool,
25
26 #[arg(long)]
28 pub print_path: bool,
29
30 #[arg(long)]
32 pub url: Option<String>,
33
34 #[arg(long)]
36 pub token: Option<String>,
37
38 #[arg(long)]
40 pub token_file: Option<String>,
41
42 #[arg(long)]
44 pub download_dir: Option<String>,
45
46 #[arg(long)]
48 pub no_https: bool,
49
50 #[arg(long)]
52 pub check: bool,
53}
54
55enum AuthChoice {
56 None,
57 Basic,
58 Bearer,
59 ApiKeyHeader,
60 PairingCode,
61}
62
63pub async fn handle(cmd: InitCommand, verbose: bool) -> Result<()> {
64 let Some(path) = user_config_json_path() else {
65 return Err(anyhow!(
66 "Could not determine config directory (no HOME / APPDATA?)."
67 ));
68 };
69
70 if cmd.print_path {
71 println!("{}", path.display());
72 return Ok(());
73 }
74
75 let dir = path
76 .parent()
77 .ok_or_else(|| anyhow!("invalid config path"))?;
78
79 let is_non_interactive = cmd.url.is_some() || cmd.token.is_some() || cmd.token_file.is_some();
80
81 if path.exists() && !cmd.force {
82 if is_non_interactive {
83 return Err(anyhow!(
84 "Config file already exists at {}. Use --force to overwrite.",
85 path.display()
86 ));
87 }
88 let cont = Confirm::with_theme(&ColorfulTheme::default())
89 .with_prompt(format!("Overwrite existing config at {}?", path.display()))
90 .default(false)
91 .interact()?;
92 if !cont {
93 println!("Aborted.");
94 return Ok(());
95 }
96 }
97
98 fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
99
100 if let Some(url) = cmd.url {
102 let token = match (cmd.token, cmd.token_file) {
103 (Some(t), _) => Some(t),
104 (None, Some(f)) => {
105 let mut content = String::new();
106 if f == "-" {
107 std::io::stdin()
108 .read_to_string(&mut content)
109 .context("read token from stdin")?;
110 } else {
111 content =
112 fs::read_to_string(&f).with_context(|| format!("read token file {}", f))?;
113 }
114 Some(content.trim().to_string())
115 }
116 (None, None) => None,
117 };
118
119 if token.is_none() {
120 return Err(anyhow!("--url requires either --token or --token-file"));
121 }
122
123 let base_url = normalize_romm_origin(&url);
124 let default_dl_dir = dirs::download_dir()
125 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
126 .join("romm-cli");
127 let download_dir = cmd
128 .download_dir
129 .unwrap_or_else(|| default_dl_dir.display().to_string());
130 let use_https = !cmd.no_https;
131 let auth = Some(AuthConfig::Bearer {
132 token: token.unwrap(),
133 });
134
135 let config = Config {
136 base_url,
137 download_dir,
138 use_https,
139 auth,
140 extras_defaults: ExtrasDefaults::default(),
141 save_sync: Default::default(),
142 roms_layout: Default::default(),
143 theme: default_theme_id(),
144 };
145 persist_user_config(&config)?;
146 println!("Wrote {}", path.display());
147
148 if cmd.check {
149 let client = RommClient::new(&config, verbose)?;
150 println!("Checking connection to {}...", config.base_url);
151 client
152 .fetch_openapi_json()
153 .await
154 .context("failed to fetch OpenAPI JSON")?;
155 println!("Success: connected and fetched OpenAPI spec.");
156
157 println!("Verifying authentication...");
158 client
159 .call(&crate::endpoints::platforms::ListPlatforms)
160 .await
161 .context("failed to authenticate or fetch platforms")?;
162 println!("Success: authentication verified.");
163 }
164 return Ok(());
165 }
166
167 if cmd.token.is_some() || cmd.token_file.is_some() {
169 return Err(anyhow!("--token and --token-file require --url"));
170 }
171
172 let base_input: String = Input::with_theme(&ColorfulTheme::default())
173 .with_prompt("RomM web URL (same as in your browser; do not add /api)")
174 .with_initial_text("https://")
175 .interact_text()?;
176
177 let base_input = base_input.trim();
178 if base_input.is_empty() {
179 return Err(anyhow!("Base URL cannot be empty"));
180 }
181
182 let had_api_path = base_input.trim_end_matches('/').ends_with("/api");
183 let base_url = normalize_romm_origin(base_input);
184 if had_api_path {
185 println!(
186 "Using `{base_url}` — `/api` was removed. Requests use `/api/...` under that origin automatically."
187 );
188 }
189
190 let default_dl_dir = dirs::download_dir()
192 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
193 .join("romm-cli");
194
195 let download_dir: String = Input::with_theme(&ColorfulTheme::default())
196 .with_prompt("ROMs directory")
197 .default(default_dl_dir.display().to_string())
198 .interact_text()?;
199
200 let download_dir = download_dir.trim().to_string();
201
202 let use_https = Confirm::with_theme(&ColorfulTheme::default())
204 .with_prompt("Connect over HTTPS?")
205 .default(true)
206 .interact()?;
207
208 let items = vec![
209 "No authentication",
210 "Basic (username + password)",
211 "API Token (Bearer)",
212 "API key in custom header",
213 "Pair with Web UI (8-character code)",
214 ];
215 let idx = Select::with_theme(&ColorfulTheme::default())
216 .with_prompt("Authentication")
217 .items(&items)
218 .default(0)
219 .interact()?;
220
221 let choice = match idx {
222 0 => AuthChoice::None,
223 1 => AuthChoice::Basic,
224 2 => AuthChoice::Bearer,
225 3 => AuthChoice::ApiKeyHeader,
226 4 => AuthChoice::PairingCode,
227 _ => AuthChoice::None,
228 };
229
230 let auth: Option<AuthConfig> = match choice {
231 AuthChoice::None => None,
232 AuthChoice::Basic => {
233 let username: String = Input::with_theme(&ColorfulTheme::default())
234 .with_prompt("Username")
235 .interact_text()?;
236 let password = Password::with_theme(&ColorfulTheme::default())
237 .with_prompt("Password")
238 .interact()?;
239 Some(AuthConfig::Basic {
240 username: username.trim().to_string(),
241 password,
242 })
243 }
244 AuthChoice::Bearer => {
245 let token = Password::with_theme(&ColorfulTheme::default())
246 .with_prompt("API Token")
247 .interact()?;
248 Some(AuthConfig::Bearer { token })
249 }
250 AuthChoice::ApiKeyHeader => {
251 let header: String = Input::with_theme(&ColorfulTheme::default())
252 .with_prompt("Header name (e.g. X-API-Key)")
253 .interact_text()?;
254 let key = Password::with_theme(&ColorfulTheme::default())
255 .with_prompt("API key value")
256 .interact()?;
257 Some(AuthConfig::ApiKey {
258 header: header.trim().to_string(),
259 key,
260 })
261 }
262 AuthChoice::PairingCode => {
263 let code: String = Input::with_theme(&ColorfulTheme::default())
264 .with_prompt("8-character pairing code")
265 .interact_text()?;
266
267 println!("Exchanging pairing code...");
268 let temp_config = Config {
269 base_url: base_url.clone(),
270 download_dir: download_dir.clone(),
271 use_https,
272 auth: None,
273 extras_defaults: ExtrasDefaults::default(),
274 save_sync: Default::default(),
275 roms_layout: Default::default(),
276 theme: default_theme_id(),
277 };
278 let client = RommClient::new(&temp_config, verbose)?;
279 let endpoint = crate::endpoints::client_tokens::ExchangeClientToken { code };
280
281 let response = client
282 .call(&endpoint)
283 .await
284 .context("failed to exchange pairing code")?;
285 println!("Successfully paired device as '{}'", response.name);
286
287 Some(AuthConfig::Bearer {
288 token: response.raw_token,
289 })
290 }
291 };
292
293 let mut platform_dirs = HashMap::new();
294 if auth.is_some() {
295 let map_custom = Confirm::with_theme(&ColorfulTheme::default())
296 .with_prompt("Map custom paths for consoles on other drives now?")
297 .default(false)
298 .interact()?;
299 if map_custom {
300 let temp_config = Config {
301 base_url: base_url.clone(),
302 download_dir: download_dir.clone(),
303 use_https,
304 auth: auth.clone(),
305 extras_defaults: ExtrasDefaults::default(),
306 save_sync: Default::default(),
307 roms_layout: Default::default(),
308 theme: default_theme_id(),
309 };
310 let client = RommClient::new(&temp_config, verbose)?;
311 let platforms = client
312 .call(&ListPlatforms)
313 .await
314 .context("failed to fetch platforms for custom path mapping")?;
315 prompt_custom_console_paths(&platforms, &mut platform_dirs)?;
316 }
317 }
318 let mut roms_layout = RomsLayoutConfig::default();
319 roms_layout.platform_dirs = platform_dirs;
320
321 let config = Config {
322 base_url,
323 download_dir,
324 use_https,
325 auth,
326 extras_defaults: ExtrasDefaults::default(),
327 save_sync: Default::default(),
328 roms_layout,
329 theme: default_theme_id(),
330 };
331 persist_user_config(&config)?;
332
333 println!("Wrote {}", path.display());
334 println!("Secrets are stored in the OS keyring when available (see file comments if plaintext fallback was used).");
335 println!("You can run `romm-cli tui` or `romm-tui` to start the TUI.");
336 Ok(())
337}
338
339fn prompt_custom_console_paths(
340 platforms: &[crate::types::Platform],
341 platform_dirs: &mut HashMap<u64, String>,
342) -> Result<()> {
343 if platforms.is_empty() {
344 println!("No platforms returned from the server; configure custom paths later in TUI Settings → ROMs.");
345 return Ok(());
346 }
347 loop {
348 let mut items: Vec<String> = platforms
349 .iter()
350 .map(|p| {
351 let mapped = platform_dirs
352 .get(&p.id)
353 .map(|s| s.as_str())
354 .unwrap_or("(base default)");
355 format!("{} — {mapped}", p.name)
356 })
357 .collect();
358 items.push("Done mapping".to_string());
359 let idx = Select::with_theme(&ColorfulTheme::default())
360 .with_prompt("Choose a console to set a custom path (or finish)")
361 .items(&items)
362 .default(0)
363 .interact()?;
364 if idx == items.len() - 1 {
365 break;
366 }
367 let platform = &platforms[idx];
368 let path: String = Input::with_theme(&ColorfulTheme::default())
369 .with_prompt(format!(
370 "Custom path for {} (leave empty to clear)",
371 platform.name
372 ))
373 .allow_empty(true)
374 .interact_text()?;
375 let path = path.trim();
376 if path.is_empty() {
377 platform_dirs.remove(&platform.id);
378 } else {
379 platform_dirs.insert(platform.id, path.to_string());
380 }
381 }
382 Ok(())
383}