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 save_sync: Default::default(),
140 };
141 persist_user_config(&config)?;
142 println!("Wrote {}", path.display());
143
144 if cmd.check {
145 let client = RommClient::new(&config, verbose)?;
146 println!("Checking connection to {}...", config.base_url);
147 client
148 .fetch_openapi_json()
149 .await
150 .context("failed to fetch OpenAPI JSON")?;
151 println!("Success: connected and fetched OpenAPI spec.");
152
153 println!("Verifying authentication...");
154 client
155 .call(&crate::endpoints::platforms::ListPlatforms)
156 .await
157 .context("failed to authenticate or fetch platforms")?;
158 println!("Success: authentication verified.");
159 }
160 return Ok(());
161 }
162
163 if cmd.token.is_some() || cmd.token_file.is_some() {
165 return Err(anyhow!("--token and --token-file require --url"));
166 }
167
168 let base_input: String = Input::with_theme(&ColorfulTheme::default())
169 .with_prompt("RomM web URL (same as in your browser; do not add /api)")
170 .with_initial_text("https://")
171 .interact_text()?;
172
173 let base_input = base_input.trim();
174 if base_input.is_empty() {
175 return Err(anyhow!("Base URL cannot be empty"));
176 }
177
178 let had_api_path = base_input.trim_end_matches('/').ends_with("/api");
179 let base_url = normalize_romm_origin(base_input);
180 if had_api_path {
181 println!(
182 "Using `{base_url}` — `/api` was removed. Requests use `/api/...` under that origin automatically."
183 );
184 }
185
186 let default_dl_dir = dirs::download_dir()
188 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
189 .join("romm-cli");
190
191 let download_dir: String = Input::with_theme(&ColorfulTheme::default())
192 .with_prompt("ROMs directory")
193 .default(default_dl_dir.display().to_string())
194 .interact_text()?;
195
196 let download_dir = download_dir.trim().to_string();
197
198 let use_https = Confirm::with_theme(&ColorfulTheme::default())
200 .with_prompt("Connect over HTTPS?")
201 .default(true)
202 .interact()?;
203
204 let items = vec![
205 "No authentication",
206 "Basic (username + password)",
207 "API Token (Bearer)",
208 "API key in custom header",
209 "Pair with Web UI (8-character code)",
210 ];
211 let idx = Select::with_theme(&ColorfulTheme::default())
212 .with_prompt("Authentication")
213 .items(&items)
214 .default(0)
215 .interact()?;
216
217 let choice = match idx {
218 0 => AuthChoice::None,
219 1 => AuthChoice::Basic,
220 2 => AuthChoice::Bearer,
221 3 => AuthChoice::ApiKeyHeader,
222 4 => AuthChoice::PairingCode,
223 _ => AuthChoice::None,
224 };
225
226 let auth: Option<AuthConfig> = match choice {
227 AuthChoice::None => None,
228 AuthChoice::Basic => {
229 let username: String = Input::with_theme(&ColorfulTheme::default())
230 .with_prompt("Username")
231 .interact_text()?;
232 let password = Password::with_theme(&ColorfulTheme::default())
233 .with_prompt("Password")
234 .interact()?;
235 Some(AuthConfig::Basic {
236 username: username.trim().to_string(),
237 password,
238 })
239 }
240 AuthChoice::Bearer => {
241 let token = Password::with_theme(&ColorfulTheme::default())
242 .with_prompt("API Token")
243 .interact()?;
244 Some(AuthConfig::Bearer { token })
245 }
246 AuthChoice::ApiKeyHeader => {
247 let header: String = Input::with_theme(&ColorfulTheme::default())
248 .with_prompt("Header name (e.g. X-API-Key)")
249 .interact_text()?;
250 let key = Password::with_theme(&ColorfulTheme::default())
251 .with_prompt("API key value")
252 .interact()?;
253 Some(AuthConfig::ApiKey {
254 header: header.trim().to_string(),
255 key,
256 })
257 }
258 AuthChoice::PairingCode => {
259 let code: String = Input::with_theme(&ColorfulTheme::default())
260 .with_prompt("8-character pairing code")
261 .interact_text()?;
262
263 println!("Exchanging pairing code...");
264 let temp_config = Config {
265 base_url: base_url.clone(),
266 download_dir: download_dir.clone(),
267 use_https,
268 auth: None,
269 extras_defaults: ExtrasDefaults::default(),
270 save_sync: Default::default(),
271 };
272 let client = RommClient::new(&temp_config, verbose)?;
273 let endpoint = crate::endpoints::client_tokens::ExchangeClientToken { code };
274
275 let response = client
276 .call(&endpoint)
277 .await
278 .context("failed to exchange pairing code")?;
279 println!("Successfully paired device as '{}'", response.name);
280
281 Some(AuthConfig::Bearer {
282 token: response.raw_token,
283 })
284 }
285 };
286
287 let config = Config {
288 base_url,
289 download_dir,
290 use_https,
291 auth,
292 extras_defaults: ExtrasDefaults::default(),
293 save_sync: Default::default(),
294 };
295 persist_user_config(&config)?;
296
297 println!("Wrote {}", path.display());
298 println!("Secrets are stored in the OS keyring when available (see file comments if plaintext fallback was used).");
299 println!("You can run `romm-cli tui` or `romm-tui` to start the TUI.");
300 Ok(())
301}