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 romm_api::client::RommClient;
14use romm_api::config::{
15 default_theme_id, normalize_romm_origin, persist_user_config, user_config_json_path,
16 AuthConfig, Config, ExtrasDefaults, RomsLayoutConfig,
17};
18use romm_api::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 tui_layout: Default::default(),
145 };
146 persist_user_config(&config)?;
147 println!("Wrote {}", path.display());
148
149 if cmd.check {
150 let client = RommClient::new(&config, verbose)?;
151 println!("Checking connection to {}...", config.base_url);
152 client
153 .fetch_openapi_json()
154 .await
155 .context("failed to fetch OpenAPI JSON")?;
156 println!("Success: connected and fetched OpenAPI spec.");
157
158 println!("Verifying authentication...");
159 client
160 .call(&romm_api::endpoints::platforms::ListPlatforms)
161 .await
162 .context("failed to authenticate or fetch platforms")?;
163 println!("Success: authentication verified.");
164 }
165 return Ok(());
166 }
167
168 if cmd.token.is_some() || cmd.token_file.is_some() {
170 return Err(anyhow!("--token and --token-file require --url"));
171 }
172
173 let base_input: String = Input::with_theme(&ColorfulTheme::default())
174 .with_prompt("RomM web URL (same as in your browser; do not add /api)")
175 .with_initial_text("https://")
176 .interact_text()?;
177
178 let base_input = base_input.trim();
179 if base_input.is_empty() {
180 return Err(anyhow!("Base URL cannot be empty"));
181 }
182
183 let had_api_path = base_input.trim_end_matches('/').ends_with("/api");
184 let base_url = normalize_romm_origin(base_input);
185 if had_api_path {
186 println!(
187 "Using `{base_url}` — `/api` was removed. Requests use `/api/...` under that origin automatically."
188 );
189 }
190
191 let default_dl_dir = dirs::download_dir()
193 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
194 .join("romm-cli");
195
196 let download_dir: String = Input::with_theme(&ColorfulTheme::default())
197 .with_prompt("ROMs directory")
198 .default(default_dl_dir.display().to_string())
199 .interact_text()?;
200
201 let download_dir = download_dir.trim().to_string();
202
203 let use_https = Confirm::with_theme(&ColorfulTheme::default())
205 .with_prompt("Connect over HTTPS?")
206 .default(true)
207 .interact()?;
208
209 let items = vec![
210 "No authentication",
211 "Basic (username + password)",
212 "API Token (Bearer)",
213 "API key in custom header",
214 "Pair with Web UI (8-character code)",
215 ];
216 let idx = Select::with_theme(&ColorfulTheme::default())
217 .with_prompt("Authentication")
218 .items(&items)
219 .default(0)
220 .interact()?;
221
222 let choice = match idx {
223 0 => AuthChoice::None,
224 1 => AuthChoice::Basic,
225 2 => AuthChoice::Bearer,
226 3 => AuthChoice::ApiKeyHeader,
227 4 => AuthChoice::PairingCode,
228 _ => AuthChoice::None,
229 };
230
231 let auth: Option<AuthConfig> = match choice {
232 AuthChoice::None => None,
233 AuthChoice::Basic => {
234 let username: String = Input::with_theme(&ColorfulTheme::default())
235 .with_prompt("Username")
236 .interact_text()?;
237 let password = Password::with_theme(&ColorfulTheme::default())
238 .with_prompt("Password")
239 .interact()?;
240 Some(AuthConfig::Basic {
241 username: username.trim().to_string(),
242 password,
243 })
244 }
245 AuthChoice::Bearer => {
246 let token = Password::with_theme(&ColorfulTheme::default())
247 .with_prompt("API Token")
248 .interact()?;
249 Some(AuthConfig::Bearer { token })
250 }
251 AuthChoice::ApiKeyHeader => {
252 let header: String = Input::with_theme(&ColorfulTheme::default())
253 .with_prompt("Header name (e.g. X-API-Key)")
254 .interact_text()?;
255 let key = Password::with_theme(&ColorfulTheme::default())
256 .with_prompt("API key value")
257 .interact()?;
258 Some(AuthConfig::ApiKey {
259 header: header.trim().to_string(),
260 key,
261 })
262 }
263 AuthChoice::PairingCode => {
264 let code: String = Input::with_theme(&ColorfulTheme::default())
265 .with_prompt("8-character pairing code")
266 .interact_text()?;
267
268 println!("Exchanging pairing code...");
269 let temp_config = Config {
270 base_url: base_url.clone(),
271 download_dir: download_dir.clone(),
272 use_https,
273 auth: None,
274 extras_defaults: ExtrasDefaults::default(),
275 save_sync: Default::default(),
276 roms_layout: Default::default(),
277 theme: default_theme_id(),
278 tui_layout: Default::default(),
279 };
280 let client = RommClient::new(&temp_config, verbose)?;
281 let endpoint = romm_api::endpoints::client_tokens::ExchangeClientToken { code };
282
283 let response = client
284 .call(&endpoint)
285 .await
286 .context("failed to exchange pairing code")?;
287 println!("Successfully paired device as '{}'", response.name);
288
289 Some(AuthConfig::Bearer {
290 token: response.raw_token,
291 })
292 }
293 };
294
295 let mut platform_dirs = HashMap::new();
296 if auth.is_some() {
297 let map_custom = Confirm::with_theme(&ColorfulTheme::default())
298 .with_prompt("Map custom paths for consoles on other drives now?")
299 .default(false)
300 .interact()?;
301 if map_custom {
302 let temp_config = Config {
303 base_url: base_url.clone(),
304 download_dir: download_dir.clone(),
305 use_https,
306 auth: auth.clone(),
307 extras_defaults: ExtrasDefaults::default(),
308 save_sync: Default::default(),
309 roms_layout: Default::default(),
310 theme: default_theme_id(),
311 tui_layout: Default::default(),
312 };
313 let client = RommClient::new(&temp_config, verbose)?;
314 let platforms = client
315 .call(&ListPlatforms)
316 .await
317 .context("failed to fetch platforms for custom path mapping")?;
318 prompt_custom_console_paths(&platforms, &mut platform_dirs)?;
319 }
320 }
321 let mut roms_layout = RomsLayoutConfig::default();
322 roms_layout.platform_dirs = platform_dirs;
323
324 let config = Config {
325 base_url,
326 download_dir,
327 use_https,
328 auth,
329 extras_defaults: ExtrasDefaults::default(),
330 save_sync: Default::default(),
331 roms_layout,
332 theme: default_theme_id(),
333 tui_layout: Default::default(),
334 };
335 persist_user_config(&config)?;
336
337 println!("Wrote {}", path.display());
338 println!("Secrets are stored in the OS keyring when available (see file comments if plaintext fallback was used).");
339 println!("Install the `romm-tui` crate and run `romm-tui` to start the TUI.");
340 Ok(())
341}
342
343fn prompt_custom_console_paths(
344 platforms: &[romm_api::types::Platform],
345 platform_dirs: &mut HashMap<u64, String>,
346) -> Result<()> {
347 if platforms.is_empty() {
348 println!("No platforms returned from the server; configure custom paths later in TUI Settings → ROMs.");
349 return Ok(());
350 }
351 loop {
352 let mut items: Vec<String> = platforms
353 .iter()
354 .map(|p| {
355 let mapped = platform_dirs
356 .get(&p.id)
357 .map(|s| s.as_str())
358 .unwrap_or("(base default)");
359 format!("{} — {mapped}", p.name)
360 })
361 .collect();
362 items.push("Done mapping".to_string());
363 let idx = Select::with_theme(&ColorfulTheme::default())
364 .with_prompt("Choose a console to set a custom path (or finish)")
365 .items(&items)
366 .default(0)
367 .interact()?;
368 if idx == items.len() - 1 {
369 break;
370 }
371 let platform = &platforms[idx];
372 let path: String = Input::with_theme(&ColorfulTheme::default())
373 .with_prompt(format!(
374 "Custom path for {} (leave empty to clear)",
375 platform.name
376 ))
377 .allow_empty(true)
378 .interact_text()?;
379 let path = path.trim();
380 if path.is_empty() {
381 platform_dirs.remove(&platform.id);
382 } else {
383 platform_dirs.insert(platform.id, path.to_string());
384 }
385 }
386 Ok(())
387}