1use std::future::Future;
2use std::io::{BufRead, IsTerminal, Write};
3use std::path::Path;
4
5use owo_colors::OwoColorize;
6
7use crate::api::ApiError;
8use crate::config;
9
10const CORE_SCOPES: &[&str] = &[
11 "meeting:read:list_meetings:master",
12 "meeting:read:meeting:master",
13 "meeting:write:meeting:master",
14 "recording:read:list_user_recordings:master",
15 "user:read:user:master",
16 "user:read:list_users:master",
17];
18
19const OPTIONAL_SCOPES: &[&str] = &[
20 "meeting:write:meeting:admin",
21 "meeting:read:list_past_meeting_participants:admin",
22 "report:read:user:admin",
23 "recording:write:recording:master",
24];
25
26fn sym_q() -> String {
27 "?".green().to_string()
28}
29
30fn sym_ok() -> String {
31 "✔".green().to_string()
32}
33
34fn sym_fail() -> String {
35 "✖".red().to_string()
36}
37
38fn mask_credential(s: &str) -> String {
39 if s.len() <= 10 {
40 return "•".repeat(s.len());
41 }
42 format!("{}…{}", &s[..6], &s[s.len() - 4..])
43}
44
45fn prompt_optional<R: BufRead, W: Write>(
46 reader: &mut R,
47 writer: &mut W,
48 label: &str,
49 default: &str,
50) -> String {
51 write!(writer, "{} {} ({}): ", sym_q(), label, default.dimmed()).unwrap();
52 writer.flush().unwrap();
53
54 let mut input = String::new();
55 reader.read_line(&mut input).unwrap_or(0);
56 let trimmed = input.trim().to_owned();
57 if trimmed.is_empty() { default.to_owned() } else { trimmed }
58}
59
60fn prompt_required<R: BufRead, W: Write>(
61 reader: &mut R,
62 writer: &mut W,
63 label: &str,
64 hint: &str,
65) -> String {
66 loop {
67 write!(writer, "{} {} [{}]: ", sym_q(), label, hint.dimmed()).unwrap();
68 writer.flush().unwrap();
69
70 let mut input = String::new();
71 reader.read_line(&mut input).unwrap_or(0);
72 let trimmed = input.trim().to_owned();
73 if !trimmed.is_empty() {
74 return trimmed;
75 }
76 writeln!(writer, " {} {} is required.", sym_fail(), label).unwrap();
77 }
78}
79
80fn prompt_confirm<R: BufRead, W: Write>(
81 reader: &mut R,
82 writer: &mut W,
83 label: &str,
84 default_yes: bool,
85) -> bool {
86 let hint = if default_yes { "Y/n" } else { "y/N" };
87 write!(writer, "{} {} [{}]: ", sym_q(), label, hint.dimmed()).unwrap();
88 writer.flush().unwrap();
89
90 let mut input = String::new();
91 reader.read_line(&mut input).unwrap_or(0);
92 match input.trim().to_lowercase().as_str() {
93 "y" | "yes" => true,
94 "n" | "no" => false,
95 _ => default_yes,
96 }
97}
98
99fn print_json_schema(config_path: &Path) {
100 let path_str = config_path.to_string_lossy();
101 let schema = serde_json::json!({
102 "configPath": path_str,
103 "tokenInstructions": {
104 "steps": [
105 "Go to https://marketplace.zoom.us/develop/create",
106 "Click 'Build App', choose 'Server-to-Server OAuth'",
107 "Add the required scopes (see requiredScopes)",
108 "Activate the app",
109 "Copy Account ID, Client ID, and Client Secret from the app credentials page"
110 ]
111 },
112 "requiredCredentials": ["account_id", "client_id", "client_secret"],
113 "requiredScopes": CORE_SCOPES,
114 "optionalScopes": OPTIONAL_SCOPES,
115 "example": {
116 "configFile": path_str,
117 "format": "[default]\naccount_id = \"YOUR_ACCOUNT_ID\"\nclient_id = \"YOUR_CLIENT_ID\"\nclient_secret = \"YOUR_CLIENT_SECRET\""
118 }
119 });
120 println!("{}", serde_json::to_string_pretty(&schema).expect("serialize"));
121}
122
123fn load_existing_profile_names(config_path: &Path) -> Vec<String> {
124 let content = match std::fs::read_to_string(config_path) {
125 Ok(c) => c,
126 Err(_) => return Vec::new(),
127 };
128 let table: toml::Table = match toml::from_str(&content) {
129 Ok(t) => t,
130 Err(_) => return Vec::new(),
131 };
132 table.keys().cloned().collect()
133}
134
135pub async fn run_init<R, W, Fut>(
140 reader: &mut R,
141 writer: &mut W,
142 config_path: &Path,
143 profile_arg: Option<&str>,
144 validate: impl Fn(String, String, String) -> Fut,
145) -> Result<(), ApiError>
146where
147 R: BufRead,
148 W: Write,
149 Fut: Future<Output = Option<String>>,
150{
151 let existing = load_existing_profile_names(config_path);
152
153 if existing.is_empty() {
154 writeln!(writer, "\nWelcome to zoom-cli!\n").unwrap();
155 writeln!(
156 writer,
157 "This tool authenticates via a Zoom Server-to-Server OAuth app."
158 )
159 .unwrap();
160 writeln!(writer, "You'll need to create one at https://marketplace.zoom.us\n").unwrap();
161 } else {
162 writeln!(
163 writer,
164 "\nUpdating zoom-cli config — existing profiles: {}\n",
165 existing.join(", ")
166 )
167 .unwrap();
168 }
169
170 writeln!(writer, "Set up your Zoom Server-to-Server OAuth app:").unwrap();
171 writeln!(writer, " 1. https://marketplace.zoom.us/develop/create").unwrap();
172 writeln!(writer, " 2. Build App → Server-to-Server OAuth").unwrap();
173 writeln!(writer, " 3. Add scopes:\n").unwrap();
174 writeln!(writer, " Core (required):").unwrap();
175 for scope in CORE_SCOPES {
176 writeln!(writer, " • {scope}").unwrap();
177 }
178 writeln!(writer, "\n Optional (end/participants/reports/recording-control):").unwrap();
179 for scope in OPTIONAL_SCOPES {
180 writeln!(writer, " • {scope}").unwrap();
181 }
182 writeln!(writer, "\n 4. Activate the app\n").unwrap();
183
184 write!(writer, "Press Enter when your app is ready (Ctrl+C to abort)... ").unwrap();
185 writer.flush().unwrap();
186 let mut _buf = String::new();
187 reader.read_line(&mut _buf).unwrap_or(0);
188 writeln!(writer).unwrap();
189
190 let profile_name = if let Some(p) = profile_arg {
191 p.to_owned()
192 } else {
193 prompt_optional(reader, writer, "Profile name", "default")
194 };
195
196 let account_id = prompt_required(reader, writer, "Account ID", "from app credentials");
197 let client_id = prompt_required(reader, writer, "Client ID", "from app credentials");
198 let client_secret = prompt_required(reader, writer, "Client Secret", "from app credentials");
199
200 writeln!(writer).unwrap();
201 writeln!(writer, " Profile: {}", profile_name.bold()).unwrap();
202 writeln!(writer, " Account ID: {}", mask_credential(&account_id)).unwrap();
203 writeln!(writer, " Client ID: {}", mask_credential(&client_id)).unwrap();
204 writeln!(writer, " Client Secret: {}\n", mask_credential(&client_secret)).unwrap();
205
206 write!(writer, "{} Validating credentials... ", sym_q()).unwrap();
207 writer.flush().unwrap();
208 let validation = validate(account_id.clone(), client_id.clone(), client_secret.clone()).await;
209
210 let save = match validation {
211 Some(display_name) => {
212 writeln!(writer, "{} Connected as {}", sym_ok(), display_name.bold()).unwrap();
213 true
214 }
215 None => {
216 writeln!(writer, "{} Could not validate credentials.", sym_fail()).unwrap();
217 prompt_confirm(reader, writer, "Save anyway?", false)
218 }
219 };
220
221 writeln!(writer).unwrap();
222
223 if !save {
224 writeln!(writer, "Aborted. Config not saved.").unwrap();
225 writer.flush().unwrap();
226 return Ok(());
227 }
228
229 config::write_profile(
230 config_path,
231 &profile_name,
232 &account_id,
233 &client_id,
234 &client_secret,
235 )?;
236
237 writeln!(
238 writer,
239 "{} Config saved to {}\n",
240 sym_ok(),
241 config_path.display().to_string().bold()
242 )
243 .unwrap();
244 writeln!(writer, "Run: {}", "zoom users me".bold()).unwrap();
245 writer.flush().unwrap();
246
247 Ok(())
248}
249
250pub async fn init(profile_arg: Option<String>) -> Result<(), ApiError> {
252 let config_path = config::config_path();
253
254 if !std::io::stdout().is_terminal() {
255 print_json_schema(&config_path);
256 return Ok(());
257 }
258
259 let stdin = std::io::stdin();
260 let stdout = std::io::stdout();
261 let mut reader = std::io::BufReader::new(stdin.lock());
262 let mut writer = std::io::BufWriter::new(stdout.lock());
263
264 run_init(
265 &mut reader,
266 &mut writer,
267 &config_path,
268 profile_arg.as_deref(),
269 |account_id, client_id, client_secret| async move {
270 let mut client =
271 crate::api::ZoomClient::new(account_id, client_id, client_secret);
272 match client.get_user("me").await {
273 Ok(user) => Some(user.display_name.unwrap_or(user.email)),
274 Err(_) => None,
275 }
276 },
277 )
278 .await
279}
280
281#[cfg(test)]
282mod tests {
283 use std::io::Cursor;
284
285 use tempfile::TempDir;
286
287 use super::*;
288
289 fn fake_path(dir: &TempDir) -> std::path::PathBuf {
290 dir.path().join("config.toml")
291 }
292
293 #[tokio::test]
294 async fn init_writes_config_on_valid_credentials() {
295 let dir = TempDir::new().unwrap();
296 let path = fake_path(&dir);
297
298 let input = b"\n\ntest-account-id\ntest-client-id\ntest-client-secret\n";
300 let mut reader = Cursor::new(input.as_ref());
301 let mut writer = Vec::<u8>::new();
302
303 run_init(
304 &mut reader,
305 &mut writer,
306 &path,
307 None,
308 |a, b, c| async move {
309 let _ = (a, b, c);
310 Some("Alice Smith".into())
311 },
312 )
313 .await
314 .unwrap();
315
316 let saved = std::fs::read_to_string(&path).unwrap();
317 assert!(saved.contains("account_id"));
318 assert!(saved.contains("test-account-id"));
319 assert!(saved.contains("test-client-id"));
320 assert!(saved.contains("test-client-secret"));
321 }
322
323 #[tokio::test]
324 async fn init_uses_default_profile_name_when_empty() {
325 let dir = TempDir::new().unwrap();
326 let path = fake_path(&dir);
327
328 let input = b"\n\ntest-acct\ntest-cid\ntest-csec\n";
329 let mut reader = Cursor::new(input.as_ref());
330 let mut writer = Vec::<u8>::new();
331
332 run_init(
333 &mut reader,
334 &mut writer,
335 &path,
336 None,
337 |a, b, c| async move {
338 let _ = (a, b, c);
339 Some("Test User".into())
340 },
341 )
342 .await
343 .unwrap();
344
345 let saved = std::fs::read_to_string(&path).unwrap();
346 assert!(saved.contains("[default]"), "should use 'default' profile name");
347 }
348
349 #[tokio::test]
350 async fn init_with_profile_arg_skips_profile_prompt() {
351 let dir = TempDir::new().unwrap();
352 let path = fake_path(&dir);
353
354 let input = b"\ntest-acct\ntest-cid\ntest-csec\n";
356 let mut reader = Cursor::new(input.as_ref());
357 let mut writer = Vec::<u8>::new();
358
359 run_init(
360 &mut reader,
361 &mut writer,
362 &path,
363 Some("work"),
364 |a, b, c| async move {
365 let _ = (a, b, c);
366 Some("Alice".into())
367 },
368 )
369 .await
370 .unwrap();
371
372 let saved = std::fs::read_to_string(&path).unwrap();
373 assert!(saved.contains("[work]"));
374 }
375
376 #[tokio::test]
377 async fn init_aborts_when_validation_fails_and_user_declines_save() {
378 let dir = TempDir::new().unwrap();
379 let path = fake_path(&dir);
380
381 let input = b"\n\ntest-acct\ntest-cid\ntest-csec\nn\n";
382 let mut reader = Cursor::new(input.as_ref());
383 let mut writer = Vec::<u8>::new();
384
385 run_init(
386 &mut reader,
387 &mut writer,
388 &path,
389 None,
390 |a, b, c| async move {
391 let _ = (a, b, c);
392 None
393 },
394 )
395 .await
396 .unwrap();
397
398 assert!(!path.exists(), "config should not be written after abort");
399 }
400
401 #[tokio::test]
402 async fn init_saves_when_validation_fails_but_user_forces_save() {
403 let dir = TempDir::new().unwrap();
404 let path = fake_path(&dir);
405
406 let input = b"\n\ntest-acct\ntest-cid\ntest-csec\ny\n";
407 let mut reader = Cursor::new(input.as_ref());
408 let mut writer = Vec::<u8>::new();
409
410 run_init(
411 &mut reader,
412 &mut writer,
413 &path,
414 None,
415 |a, b, c| async move {
416 let _ = (a, b, c);
417 None
418 },
419 )
420 .await
421 .unwrap();
422
423 assert!(path.exists(), "config should be saved when user chooses to save anyway");
424 }
425
426 #[tokio::test]
427 async fn init_overwrites_existing_profile() {
428 let dir = TempDir::new().unwrap();
429 let path = fake_path(&dir);
430 std::fs::write(
431 &path,
432 "[default]\naccount_id = \"old\"\nclient_id = \"old\"\nclient_secret = \"old\"\n",
433 )
434 .unwrap();
435
436 let input = b"\n\nnew-account\nnew-client\nnew-secret\n";
437 let mut reader = Cursor::new(input.as_ref());
438 let mut writer = Vec::<u8>::new();
439
440 run_init(
441 &mut reader,
442 &mut writer,
443 &path,
444 None,
445 |a, b, c| async move {
446 let _ = (a, b, c);
447 Some("Alice".into())
448 },
449 )
450 .await
451 .unwrap();
452
453 let saved = std::fs::read_to_string(&path).unwrap();
454 assert!(saved.contains("new-account"));
455 assert!(!saved.contains("\"old\""), "old values should be overwritten");
456 }
457
458 #[test]
459 fn mask_credential_masks_long_values() {
460 assert_eq!(mask_credential("abcdefghijklmnop"), "abcdef…mnop");
461 }
462
463 #[test]
464 fn mask_credential_dots_short_values() {
465 assert_eq!(mask_credential("short"), "•••••");
466 assert_eq!(mask_credential(""), "");
467 }
468}