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;
9use crate::output;
10
11const CORE_SCOPES: &[&str] = &[
12 "meeting:read:list_meetings:master",
13 "meeting:read:meeting:master",
14 "meeting:write:meeting:master",
15 "recording:read:list_user_recordings:master",
16 "user:read:user:master",
17 "user:read:list_users:master",
18];
19
20const OPTIONAL_SCOPES: &[&str] = &[
21 "meeting:write:meeting:admin",
22 "meeting:read:list_past_meeting_participants:admin",
23 "report:read:user:admin",
24 "recording:write:recording:master",
25];
26
27const OAUTH_URL: &str = "https://marketplace.zoom.us/develop/create";
28const SEP: &str = "──────────────────────────────────────";
29
30fn sym_q() -> String {
31 "?".green().bold().to_string()
32}
33
34fn sym_ok() -> String {
35 "✔".green().to_string()
36}
37
38fn sym_fail() -> String {
39 "✖".red().to_string()
40}
41
42fn sym_dim(s: &str) -> String {
43 s.dimmed().to_string()
44}
45
46fn prompt_optional<R: BufRead, W: Write>(
48 reader: &mut R,
49 writer: &mut W,
50 label: &str,
51 default: &str,
52) -> String {
53 let _ = write!(writer, "{} {} [{}]: ", sym_q(), label, sym_dim(default));
54 let _ = writer.flush();
55
56 let mut input = String::new();
57 reader.read_line(&mut input).unwrap_or(0);
58 let trimmed = input.trim().to_owned();
59 if trimmed.is_empty() {
60 default.to_owned()
61 } else {
62 trimmed
63 }
64}
65
66fn prompt_required<R: BufRead, W: Write>(
69 reader: &mut R,
70 writer: &mut W,
71 label: &str,
72 hint: &str,
73) -> Option<String> {
74 loop {
75 let _ = write!(
76 writer,
77 "{} {} {}: ",
78 sym_q(),
79 label,
80 sym_dim(&format!("[{hint}]"))
81 );
82 let _ = writer.flush();
83
84 let mut input = String::new();
85 match reader.read_line(&mut input) {
86 Ok(0) | Err(_) => return None,
87 Ok(_) => {}
88 }
89 let trimmed = input.trim().to_owned();
90 if !trimmed.is_empty() {
91 return Some(trimmed);
92 }
93 let _ = writeln!(writer, " {} {} is required.", sym_fail(), label);
94 }
95}
96
97fn prompt_credential_update<R: BufRead, W: Write>(
101 reader: &mut R,
102 writer: &mut W,
103 label: &str,
104 current: &str,
105) -> Option<String> {
106 let hint = format!("{} (Enter to keep)", output::mask_credential(current));
107 let _ = write!(writer, "{} {} {}: ", sym_q(), label, sym_dim(&hint));
108 let _ = writer.flush();
109
110 let mut input = String::new();
111 match reader.read_line(&mut input) {
112 Ok(0) | Err(_) => return None,
113 Ok(_) => {}
114 }
115 let trimmed = input.trim().to_owned();
116 Some(if trimmed.is_empty() {
117 current.to_owned()
118 } else {
119 trimmed
120 })
121}
122
123fn prompt_confirm<R: BufRead, W: Write>(
124 reader: &mut R,
125 writer: &mut W,
126 label: &str,
127 default_yes: bool,
128) -> bool {
129 let hint = if default_yes { "Y/n" } else { "y/N" };
130 let _ = write!(writer, "{} {} [{}]: ", sym_q(), label, sym_dim(hint));
131 let _ = writer.flush();
132
133 let mut input = String::new();
134 reader.read_line(&mut input).unwrap_or(0);
135 match input.trim().to_lowercase().as_str() {
136 "y" | "yes" => true,
137 "n" | "no" => false,
138 _ => default_yes,
139 }
140}
141
142fn print_json_schema(config_path: &Path) {
143 let path_str = config_path.to_string_lossy();
144 let schema = serde_json::json!({
145 "configPath": path_str,
146 "tokenInstructions": {
147 "steps": [
148 "Go to https://marketplace.zoom.us/develop/create",
149 "Click 'Build App', choose 'Server-to-Server OAuth'",
150 "Add the required scopes (see requiredScopes)",
151 "Activate the app",
152 "Copy Account ID, Client ID, and Client Secret from the app credentials page"
153 ]
154 },
155 "requiredCredentials": ["account_id", "client_id", "client_secret"],
156 "requiredScopes": CORE_SCOPES,
157 "optionalScopes": OPTIONAL_SCOPES,
158 "example": {
159 "configFile": path_str,
160 "format": "[default]\naccount_id = \"YOUR_ACCOUNT_ID\"\nclient_id = \"YOUR_CLIENT_ID\"\nclient_secret = \"YOUR_CLIENT_SECRET\""
161 }
162 });
163 println!(
164 "{}",
165 serde_json::to_string_pretty(&schema).expect("serialize")
166 );
167}
168
169fn load_existing_profile_names(config_path: &Path) -> Vec<String> {
170 let content = match std::fs::read_to_string(config_path) {
171 Ok(c) => c,
172 Err(_) => return Vec::new(),
173 };
174 let table: toml::Table = match toml::from_str(&content) {
175 Ok(t) => t,
176 Err(_) => return Vec::new(),
177 };
178 table.keys().cloned().collect()
179}
180
181pub async fn run_init<R, W, Fut>(
193 reader: &mut R,
194 writer: &mut W,
195 config_path: &Path,
196 profile_arg: Option<&str>,
197 validate: impl Fn(String, String, String) -> Fut,
198) -> Result<(), ApiError>
199where
200 R: BufRead,
201 W: Write,
202 Fut: Future<Output = Option<String>>,
203{
204 let _ = writeln!(writer, "\nzoom-cli");
205 let _ = writeln!(writer, "{SEP}\n");
206
207 let existing_profiles = load_existing_profile_names(config_path);
208 let is_first_setup = existing_profiles.is_empty();
209
210 let (profile_name, is_update) = if let Some(p) = profile_arg {
212 let is_update = existing_profiles.contains(&p.to_owned());
213 (p.to_owned(), is_update)
214 } else if is_first_setup {
215 ("default".to_owned(), false)
217 } else {
218 if existing_profiles.len() == 1 {
220 let p = &existing_profiles[0];
221 let acct = config::read_profile_credentials(config_path, p)
222 .map(|(a, _, _)| format!(" {}", output::mask_credential(&a)))
223 .unwrap_or_default();
224 let _ = writeln!(writer, " Profile: {}{}\n", p.bold(), sym_dim(&acct));
225 } else {
226 let _ = writeln!(writer, " Profiles:");
227 for p in &existing_profiles {
228 let acct = config::read_profile_credentials(config_path, p)
229 .map(|(a, _, _)| format!(" {}", output::mask_credential(&a)))
230 .unwrap_or_default();
231 let _ = writeln!(writer, " {}{}", p, sym_dim(&acct));
232 }
233 let _ = writeln!(writer);
234 }
235
236 let action = prompt_optional(reader, writer, "Action [update/add]", "update");
237 let _ = writeln!(writer);
238
239 if action.trim().eq_ignore_ascii_case("add") {
240 let Some(name) = prompt_required(reader, writer, "Profile name", "e.g. work") else {
241 let _ = writeln!(writer, "\nAborted.");
242 return Ok(());
243 };
244 (name, false)
245 } else {
246 if existing_profiles.len() == 1 {
248 (existing_profiles[0].clone(), true)
249 } else {
250 let options = existing_profiles.join("/");
251 let chosen = prompt_optional(
252 reader,
253 writer,
254 &format!("Profile [{}]", options),
255 &existing_profiles[0],
256 );
257 let profile = chosen.trim().to_owned();
258 if !existing_profiles.contains(&profile) {
259 let _ = writeln!(writer, "\n {} Unknown profile '{}'.", sym_fail(), profile);
260 return Ok(());
261 }
262 (profile, true)
263 }
264 }
265 };
266
267 if !is_update {
269 let _ = writeln!(
270 writer,
271 " {}",
272 sym_dim("Create a Server-to-Server OAuth app at:")
273 );
274 let _ = writeln!(writer, " {}\n", sym_dim(OAUTH_URL));
275 }
276
277 let (account_id, client_id, client_secret) = if is_update {
279 let (cur_acct, cur_cid, cur_csec) =
280 config::read_profile_credentials(config_path, &profile_name)
281 .expect("update mode requires existing credentials");
282 let Some(account_id) = prompt_credential_update(reader, writer, "Account ID", &cur_acct)
283 else {
284 let _ = writeln!(writer, "\nAborted.");
285 return Ok(());
286 };
287 let Some(client_id) = prompt_credential_update(reader, writer, "Client ID", &cur_cid)
288 else {
289 let _ = writeln!(writer, "\nAborted.");
290 return Ok(());
291 };
292 let Some(client_secret) =
293 prompt_credential_update(reader, writer, "Client Secret", &cur_csec)
294 else {
295 let _ = writeln!(writer, "\nAborted.");
296 return Ok(());
297 };
298 (account_id, client_id, client_secret)
299 } else {
300 let Some(account_id) =
301 prompt_required(reader, writer, "Account ID", "from app credentials")
302 else {
303 let _ = writeln!(writer, "\nAborted.");
304 return Ok(());
305 };
306 let Some(client_id) = prompt_required(reader, writer, "Client ID", "from app credentials")
307 else {
308 let _ = writeln!(writer, "\nAborted.");
309 return Ok(());
310 };
311 let Some(client_secret) =
312 prompt_required(reader, writer, "Client Secret", "from app credentials")
313 else {
314 let _ = writeln!(writer, "\nAborted.");
315 return Ok(());
316 };
317 (account_id, client_id, client_secret)
318 };
319
320 let _ = write!(writer, "\n Verifying credentials...");
322 let _ = writer.flush();
323 let validation = validate(account_id.clone(), client_id.clone(), client_secret.clone()).await;
324
325 let save = match validation {
326 Some(display_name) => {
327 let _ = writeln!(writer, " {} Connected as {}", sym_ok(), display_name.bold());
328 true
329 }
330 None => {
331 let _ = writeln!(writer, " {} Could not validate credentials.", sym_fail());
332 prompt_confirm(reader, writer, "Save anyway?", false)
333 }
334 };
335
336 if !save {
337 let _ = writeln!(writer, "\nAborted. Config not saved.");
338 let _ = writer.flush();
339 return Ok(());
340 }
341
342 config::write_profile(
343 config_path,
344 &profile_name,
345 &account_id,
346 &client_id,
347 &client_secret,
348 )?;
349
350 let run_cmd = if profile_name == "default" {
351 "zoom users me".to_owned()
352 } else {
353 format!("zoom --profile {} users me", profile_name)
354 };
355
356 let _ = writeln!(writer, "\n{SEP}");
357 let _ = writeln!(
358 writer,
359 " {} Config saved to {}",
360 sym_ok(),
361 sym_dim(&config_path.display().to_string()),
362 );
363 let _ = writeln!(writer, " Run: {}", run_cmd.bold());
364 let _ = writer.flush();
365
366 Ok(())
367}
368
369pub async fn init(profile_arg: Option<String>) -> Result<(), ApiError> {
371 let config_path = config::config_path();
372
373 if !std::io::stdout().is_terminal() {
374 print_json_schema(&config_path);
375 return Ok(());
376 }
377
378 let stdin = std::io::stdin();
379 let stdout = std::io::stdout();
380 let mut reader = std::io::BufReader::new(stdin.lock());
381 let mut writer = std::io::BufWriter::new(stdout.lock());
382
383 run_init(
384 &mut reader,
385 &mut writer,
386 &config_path,
387 profile_arg.as_deref(),
388 |account_id, client_id, client_secret| async move {
389 let mut client = crate::api::ZoomClient::new(account_id, client_id, client_secret);
390 match client.get_user("me").await {
391 Ok(user) => Some(user.display_name.unwrap_or(user.email)),
392 Err(_) => None,
393 }
394 },
395 )
396 .await
397}
398
399#[cfg(test)]
400mod tests {
401 use std::io::Cursor;
402
403 use tempfile::TempDir;
404
405 use super::*;
406
407 fn fake_path(dir: &TempDir) -> std::path::PathBuf {
408 dir.path().join("config.toml")
409 }
410
411 #[tokio::test]
412 async fn init_writes_config_on_valid_credentials() {
413 let dir = TempDir::new().unwrap();
414 let path = fake_path(&dir);
415
416 let input = b"test-account-id\ntest-client-id\ntest-client-secret\n";
418 let mut reader = Cursor::new(input.as_ref());
419 let mut writer = Vec::<u8>::new();
420
421 run_init(
422 &mut reader,
423 &mut writer,
424 &path,
425 None,
426 |a, b, c| async move {
427 let _ = (a, b, c);
428 Some("Alice Smith".into())
429 },
430 )
431 .await
432 .unwrap();
433
434 let saved = std::fs::read_to_string(&path).unwrap();
435 assert!(saved.contains("account_id"));
436 assert!(saved.contains("test-account-id"));
437 assert!(saved.contains("test-client-id"));
438 assert!(saved.contains("test-client-secret"));
439 }
440
441 #[tokio::test]
442 async fn init_uses_default_profile_name_when_empty() {
443 let dir = TempDir::new().unwrap();
444 let path = fake_path(&dir);
445
446 let input = b"test-acct\ntest-cid\ntest-csec\n";
448 let mut reader = Cursor::new(input.as_ref());
449 let mut writer = Vec::<u8>::new();
450
451 run_init(
452 &mut reader,
453 &mut writer,
454 &path,
455 None,
456 |a, b, c| async move {
457 let _ = (a, b, c);
458 Some("Test User".into())
459 },
460 )
461 .await
462 .unwrap();
463
464 let saved = std::fs::read_to_string(&path).unwrap();
465 assert!(
466 saved.contains("[default]"),
467 "should use 'default' profile name"
468 );
469 }
470
471 #[tokio::test]
472 async fn init_with_profile_arg_skips_profile_prompt() {
473 let dir = TempDir::new().unwrap();
474 let path = fake_path(&dir);
475
476 let input = b"test-acct\ntest-cid\ntest-csec\n";
478 let mut reader = Cursor::new(input.as_ref());
479 let mut writer = Vec::<u8>::new();
480
481 run_init(
482 &mut reader,
483 &mut writer,
484 &path,
485 Some("work"),
486 |a, b, c| async move {
487 let _ = (a, b, c);
488 Some("Alice".into())
489 },
490 )
491 .await
492 .unwrap();
493
494 let saved = std::fs::read_to_string(&path).unwrap();
495 assert!(saved.contains("[work]"));
496 }
497
498 #[tokio::test]
499 async fn init_aborts_when_validation_fails_and_user_declines_save() {
500 let dir = TempDir::new().unwrap();
501 let path = fake_path(&dir);
502
503 let input = b"test-acct\ntest-cid\ntest-csec\nn\n";
504 let mut reader = Cursor::new(input.as_ref());
505 let mut writer = Vec::<u8>::new();
506
507 run_init(
508 &mut reader,
509 &mut writer,
510 &path,
511 None,
512 |a, b, c| async move {
513 let _ = (a, b, c);
514 None
515 },
516 )
517 .await
518 .unwrap();
519
520 assert!(!path.exists(), "config should not be written after abort");
521 }
522
523 #[tokio::test]
524 async fn init_saves_when_validation_fails_but_user_forces_save() {
525 let dir = TempDir::new().unwrap();
526 let path = fake_path(&dir);
527
528 let input = b"test-acct\ntest-cid\ntest-csec\ny\n";
529 let mut reader = Cursor::new(input.as_ref());
530 let mut writer = Vec::<u8>::new();
531
532 run_init(
533 &mut reader,
534 &mut writer,
535 &path,
536 None,
537 |a, b, c| async move {
538 let _ = (a, b, c);
539 None
540 },
541 )
542 .await
543 .unwrap();
544
545 assert!(
546 path.exists(),
547 "config should be saved when user chooses to save anyway"
548 );
549 }
550
551 #[tokio::test]
552 async fn init_overwrites_existing_profile() {
553 let dir = TempDir::new().unwrap();
554 let path = fake_path(&dir);
555 std::fs::write(
556 &path,
557 "[default]\naccount_id = \"old\"\nclient_id = \"old\"\nclient_secret = \"old\"\n",
558 )
559 .unwrap();
560
561 let input = b"\nnew-account\nnew-client\nnew-secret\n";
564 let mut reader = Cursor::new(input.as_ref());
565 let mut writer = Vec::<u8>::new();
566
567 run_init(
568 &mut reader,
569 &mut writer,
570 &path,
571 None,
572 |a, b, c| async move {
573 let _ = (a, b, c);
574 Some("Alice".into())
575 },
576 )
577 .await
578 .unwrap();
579
580 let saved = std::fs::read_to_string(&path).unwrap();
581 assert!(saved.contains("new-account"));
582 assert!(
583 !saved.contains("\"old\""),
584 "old values should be overwritten"
585 );
586 }
587
588 #[tokio::test]
589 async fn init_update_keeps_fields_when_enter_pressed() {
590 let dir = TempDir::new().unwrap();
591 let path = fake_path(&dir);
592 std::fs::write(
593 &path,
594 "[default]\naccount_id = \"keep-acct\"\nclient_id = \"keep-cid\"\nclient_secret = \"keep-csec\"\n",
595 )
596 .unwrap();
597
598 let input = b"\n\n\n\n";
601 let mut reader = Cursor::new(input.as_ref());
602 let mut writer = Vec::<u8>::new();
603
604 run_init(
605 &mut reader,
606 &mut writer,
607 &path,
608 None,
609 |a, b, c| async move {
610 let _ = (a, b, c);
611 Some("Alice".into())
612 },
613 )
614 .await
615 .unwrap();
616
617 let saved = std::fs::read_to_string(&path).unwrap();
618 assert!(saved.contains("keep-acct"), "kept account_id");
619 assert!(saved.contains("keep-cid"), "kept client_id");
620 assert!(saved.contains("keep-csec"), "kept client_secret");
621 }
622
623 #[tokio::test]
624 async fn init_update_does_not_show_oauth_instructions() {
625 let dir = TempDir::new().unwrap();
626 let path = fake_path(&dir);
627 std::fs::write(
628 &path,
629 "[default]\naccount_id = \"acct\"\nclient_id = \"cid\"\nclient_secret = \"csec\"\n",
630 )
631 .unwrap();
632
633 let input = b"\nnew-acct\nnew-cid\nnew-csec\n";
634 let mut reader = Cursor::new(input.as_ref());
635 let mut writer = Vec::<u8>::new();
636
637 run_init(
638 &mut reader,
639 &mut writer,
640 &path,
641 None,
642 |a, b, c| async move {
643 let _ = (a, b, c);
644 Some("Alice".into())
645 },
646 )
647 .await
648 .unwrap();
649
650 let output = String::from_utf8_lossy(&writer);
651 assert!(
652 !output.contains("marketplace.zoom.us/develop/create"),
653 "update mode must not show OAuth setup instructions"
654 );
655 assert!(
656 output.contains("Profile:"),
657 "should list the existing profile"
658 );
659 assert!(output.contains("Action"), "should show the action prompt");
660 assert!(
661 output.contains("Enter to keep"),
662 "credential prompts should show keep hint"
663 );
664 }
665
666 #[tokio::test]
667 async fn init_adds_new_profile_to_existing_config() {
668 let dir = TempDir::new().unwrap();
669 let path = fake_path(&dir);
670 std::fs::write(
671 &path,
672 "[default]\naccount_id = \"def-acct\"\nclient_id = \"def-cid\"\nclient_secret = \"def-csec\"\n",
673 )
674 .unwrap();
675
676 let input = b"work-acct\nwork-cid\nwork-csec\n";
678 let mut reader = Cursor::new(input.as_ref());
679 let mut writer = Vec::<u8>::new();
680
681 run_init(
682 &mut reader,
683 &mut writer,
684 &path,
685 Some("work"),
686 |a, b, c| async move {
687 let _ = (a, b, c);
688 Some("Bob".into())
689 },
690 )
691 .await
692 .unwrap();
693
694 let saved = std::fs::read_to_string(&path).unwrap();
695 assert!(saved.contains("[default]"), "default profile preserved");
696 assert!(saved.contains("[work]"), "new profile added");
697 assert!(saved.contains("work-acct"));
698 assert!(saved.contains("def-acct"), "existing credentials untouched");
699
700 let output = String::from_utf8_lossy(&writer);
701 assert!(
702 !output.contains("Press Enter when your app is ready"),
703 "must not show the old wait gate"
704 );
705 }
706
707 #[tokio::test]
708 async fn init_aborts_gracefully_on_eof_during_required_prompt() {
709 let dir = TempDir::new().unwrap();
710 let path = fake_path(&dir);
711
712 let input = b"";
714 let mut reader = Cursor::new(input.as_ref());
715 let mut writer = Vec::<u8>::new();
716
717 run_init(
718 &mut reader,
719 &mut writer,
720 &path,
721 None,
722 |a, b, c| async move {
723 let _ = (a, b, c);
724 Some("Unreachable".into())
725 },
726 )
727 .await
728 .unwrap();
729
730 assert!(
731 !path.exists(),
732 "config must not be written on aborted input"
733 );
734 let output = String::from_utf8_lossy(&writer);
735 assert!(output.contains("Aborted"), "should print an abort message");
736 }
737
738 #[tokio::test]
739 async fn init_outro_includes_profile_flag_for_non_default_profiles() {
740 let dir = TempDir::new().unwrap();
741 let path = fake_path(&dir);
742
743 let input = b"work-acct\nwork-cid\nwork-csec\n";
744 let mut reader = Cursor::new(input.as_ref());
745 let mut writer = Vec::<u8>::new();
746
747 run_init(
748 &mut reader,
749 &mut writer,
750 &path,
751 Some("work"),
752 |a, b, c| async move {
753 let _ = (a, b, c);
754 Some("Bob".into())
755 },
756 )
757 .await
758 .unwrap();
759
760 let output = String::from_utf8_lossy(&writer);
761 assert!(
762 output.contains("--profile work"),
763 "outro should include --profile flag for non-default profiles"
764 );
765 }
766
767 #[tokio::test]
768 async fn init_action_add_prompts_for_new_profile_name() {
769 let dir = TempDir::new().unwrap();
770 let path = fake_path(&dir);
771 std::fs::write(
772 &path,
773 "[default]\naccount_id = \"def-acct\"\nclient_id = \"def-cid\"\nclient_secret = \"def-csec\"\n",
774 )
775 .unwrap();
776
777 let input = b"add\nstaging\nstg-acct\nstg-cid\nstg-csec\n";
779 let mut reader = Cursor::new(input.as_ref());
780 let mut writer = Vec::<u8>::new();
781
782 run_init(
783 &mut reader,
784 &mut writer,
785 &path,
786 None,
787 |a, b, c| async move {
788 let _ = (a, b, c);
789 Some("Carol".into())
790 },
791 )
792 .await
793 .unwrap();
794
795 let saved = std::fs::read_to_string(&path).unwrap();
796 assert!(saved.contains("[default]"), "default profile preserved");
797 assert!(saved.contains("[staging]"), "new profile added");
798 assert!(saved.contains("stg-acct"));
799
800 let output = String::from_utf8_lossy(&writer);
801 assert!(output.contains(OAUTH_URL), "add flow should show OAuth URL");
802 }
803}