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