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