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::Read;
11
12use crate::client::RommClient;
13use crate::config::{
14 normalize_romm_origin, persist_user_config, user_config_json_path, AuthConfig, Config,
15 ExtrasDefaults,
16};
17
18#[derive(Args, Debug, Clone)]
19pub struct InitCommand {
20 #[arg(long)]
22 pub force: bool,
23
24 #[arg(long)]
26 pub print_path: bool,
27
28 #[arg(long)]
30 pub url: Option<String>,
31
32 #[arg(long)]
34 pub token: Option<String>,
35
36 #[arg(long)]
38 pub token_file: Option<String>,
39
40 #[arg(long)]
42 pub download_dir: Option<String>,
43
44 #[arg(long)]
46 pub no_https: bool,
47
48 #[arg(long)]
50 pub check: bool,
51}
52
53enum AuthChoice {
54 None,
55 Basic,
56 Bearer,
57 ApiKeyHeader,
58 PairingCode,
59}
60
61pub async fn handle(cmd: InitCommand, verbose: bool) -> Result<()> {
62 let Some(path) = user_config_json_path() else {
63 return Err(anyhow!(
64 "Could not determine config directory (no HOME / APPDATA?)."
65 ));
66 };
67
68 if cmd.print_path {
69 println!("{}", path.display());
70 return Ok(());
71 }
72
73 let dir = path
74 .parent()
75 .ok_or_else(|| anyhow!("invalid config path"))?;
76
77 let is_non_interactive = cmd.url.is_some() || cmd.token.is_some() || cmd.token_file.is_some();
78
79 if path.exists() && !cmd.force {
80 if is_non_interactive {
81 return Err(anyhow!(
82 "Config file already exists at {}. Use --force to overwrite.",
83 path.display()
84 ));
85 }
86 let cont = Confirm::with_theme(&ColorfulTheme::default())
87 .with_prompt(format!("Overwrite existing config at {}?", path.display()))
88 .default(false)
89 .interact()?;
90 if !cont {
91 println!("Aborted.");
92 return Ok(());
93 }
94 }
95
96 fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
97
98 if let Some(url) = cmd.url {
100 let token = match (cmd.token, cmd.token_file) {
101 (Some(t), _) => Some(t),
102 (None, Some(f)) => {
103 let mut content = String::new();
104 if f == "-" {
105 std::io::stdin()
106 .read_to_string(&mut content)
107 .context("read token from stdin")?;
108 } else {
109 content =
110 fs::read_to_string(&f).with_context(|| format!("read token file {}", f))?;
111 }
112 Some(content.trim().to_string())
113 }
114 (None, None) => None,
115 };
116
117 if token.is_none() {
118 return Err(anyhow!("--url requires either --token or --token-file"));
119 }
120
121 let base_url = normalize_romm_origin(&url);
122 let default_dl_dir = dirs::download_dir()
123 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
124 .join("romm-cli");
125 let download_dir = cmd
126 .download_dir
127 .unwrap_or_else(|| default_dl_dir.display().to_string());
128 let use_https = !cmd.no_https;
129 let auth = Some(AuthConfig::Bearer {
130 token: token.unwrap(),
131 });
132
133 let config = Config {
134 base_url,
135 download_dir,
136 use_https,
137 auth,
138 extras_defaults: ExtrasDefaults::default(),
139 };
140 persist_user_config(&config)?;
141 println!("Wrote {}", path.display());
142
143 if cmd.check {
144 let client = RommClient::new(&config, verbose)?;
145 println!("Checking connection to {}...", config.base_url);
146 client
147 .fetch_openapi_json()
148 .await
149 .context("failed to fetch OpenAPI JSON")?;
150 println!("Success: connected and fetched OpenAPI spec.");
151
152 println!("Verifying authentication...");
153 client
154 .call(&crate::endpoints::platforms::ListPlatforms)
155 .await
156 .context("failed to authenticate or fetch platforms")?;
157 println!("Success: authentication verified.");
158 }
159 return Ok(());
160 }
161
162 if cmd.token.is_some() || cmd.token_file.is_some() {
164 return Err(anyhow!("--token and --token-file require --url"));
165 }
166
167 let base_input: String = Input::with_theme(&ColorfulTheme::default())
168 .with_prompt("RomM web URL (same as in your browser; do not add /api)")
169 .with_initial_text("https://")
170 .interact_text()?;
171
172 let base_input = base_input.trim();
173 if base_input.is_empty() {
174 return Err(anyhow!("Base URL cannot be empty"));
175 }
176
177 let had_api_path = base_input.trim_end_matches('/').ends_with("/api");
178 let base_url = normalize_romm_origin(base_input);
179 if had_api_path {
180 println!(
181 "Using `{base_url}` — `/api` was removed. Requests use `/api/...` under that origin automatically."
182 );
183 }
184
185 let default_dl_dir = dirs::download_dir()
187 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
188 .join("romm-cli");
189
190 let download_dir: String = Input::with_theme(&ColorfulTheme::default())
191 .with_prompt("ROMs directory")
192 .default(default_dl_dir.display().to_string())
193 .interact_text()?;
194
195 let download_dir = download_dir.trim().to_string();
196
197 let use_https = Confirm::with_theme(&ColorfulTheme::default())
199 .with_prompt("Connect over HTTPS?")
200 .default(true)
201 .interact()?;
202
203 let items = vec![
204 "No authentication",
205 "Basic (username + password)",
206 "API Token (Bearer)",
207 "API key in custom header",
208 "Pair with Web UI (8-character code)",
209 ];
210 let idx = Select::with_theme(&ColorfulTheme::default())
211 .with_prompt("Authentication")
212 .items(&items)
213 .default(0)
214 .interact()?;
215
216 let choice = match idx {
217 0 => AuthChoice::None,
218 1 => AuthChoice::Basic,
219 2 => AuthChoice::Bearer,
220 3 => AuthChoice::ApiKeyHeader,
221 4 => AuthChoice::PairingCode,
222 _ => AuthChoice::None,
223 };
224
225 let auth: Option<AuthConfig> = match choice {
226 AuthChoice::None => None,
227 AuthChoice::Basic => {
228 let username: String = Input::with_theme(&ColorfulTheme::default())
229 .with_prompt("Username")
230 .interact_text()?;
231 let password = Password::with_theme(&ColorfulTheme::default())
232 .with_prompt("Password")
233 .interact()?;
234 Some(AuthConfig::Basic {
235 username: username.trim().to_string(),
236 password,
237 })
238 }
239 AuthChoice::Bearer => {
240 let token = Password::with_theme(&ColorfulTheme::default())
241 .with_prompt("API Token")
242 .interact()?;
243 Some(AuthConfig::Bearer { token })
244 }
245 AuthChoice::ApiKeyHeader => {
246 let header: String = Input::with_theme(&ColorfulTheme::default())
247 .with_prompt("Header name (e.g. X-API-Key)")
248 .interact_text()?;
249 let key = Password::with_theme(&ColorfulTheme::default())
250 .with_prompt("API key value")
251 .interact()?;
252 Some(AuthConfig::ApiKey {
253 header: header.trim().to_string(),
254 key,
255 })
256 }
257 AuthChoice::PairingCode => {
258 let code: String = Input::with_theme(&ColorfulTheme::default())
259 .with_prompt("8-character pairing code")
260 .interact_text()?;
261
262 println!("Exchanging pairing code...");
263 let temp_config = Config {
264 base_url: base_url.clone(),
265 download_dir: download_dir.clone(),
266 use_https,
267 auth: None,
268 extras_defaults: ExtrasDefaults::default(),
269 };
270 let client = RommClient::new(&temp_config, verbose)?;
271 let endpoint = crate::endpoints::client_tokens::ExchangeClientToken { code };
272
273 let response = client
274 .call(&endpoint)
275 .await
276 .context("failed to exchange pairing code")?;
277 println!("Successfully paired device as '{}'", response.name);
278
279 Some(AuthConfig::Bearer {
280 token: response.raw_token,
281 })
282 }
283 };
284
285 let config = Config {
286 base_url,
287 download_dir,
288 use_https,
289 auth,
290 extras_defaults: ExtrasDefaults::default(),
291 };
292 persist_user_config(&config)?;
293
294 println!("Wrote {}", path.display());
295 println!("Secrets are stored in the OS keyring when available (see file comments if plaintext fallback was used).");
296 println!("You can run `romm-cli tui` or `romm-tui` to start the TUI.");
297 Ok(())
298}