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