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!(
275 writer,
276 " {}",
277 sym_dim(&format!("{OAUTH_URL} → Server-to-Server OAuth"))
278 );
279 let _ = writeln!(writer);
280 }
281
282 let (account_id, client_id, client_secret) = if is_update {
284 let (cur_acct, cur_cid, cur_csec) =
285 config::read_profile_credentials(config_path, &profile_name)
286 .expect("update mode requires existing credentials");
287 let Some(account_id) = prompt_credential_update(reader, writer, "Account ID", &cur_acct)
288 else {
289 let _ = writeln!(writer, "\nAborted.");
290 return Ok(());
291 };
292 let Some(client_id) = prompt_credential_update(reader, writer, "Client ID", &cur_cid)
293 else {
294 let _ = writeln!(writer, "\nAborted.");
295 return Ok(());
296 };
297 let Some(client_secret) =
298 prompt_credential_update(reader, writer, "Client Secret", &cur_csec)
299 else {
300 let _ = writeln!(writer, "\nAborted.");
301 return Ok(());
302 };
303 (account_id, client_id, client_secret)
304 } else {
305 let Some(account_id) =
306 prompt_required(reader, writer, "Account ID", "from app credentials")
307 else {
308 let _ = writeln!(writer, "\nAborted.");
309 return Ok(());
310 };
311 let Some(client_id) = prompt_required(reader, writer, "Client ID", "from app credentials")
312 else {
313 let _ = writeln!(writer, "\nAborted.");
314 return Ok(());
315 };
316 let Some(client_secret) =
317 prompt_required(reader, writer, "Client Secret", "from app credentials")
318 else {
319 let _ = writeln!(writer, "\nAborted.");
320 return Ok(());
321 };
322 (account_id, client_id, client_secret)
323 };
324
325 let _ = write!(writer, "\n Verifying credentials...");
327 let _ = writer.flush();
328 let validation = validate(account_id.clone(), client_id.clone(), client_secret.clone()).await;
329
330 let save = match validation {
331 Some(display_name) => {
332 let _ = writeln!(writer, " {} Connected as {}", sym_ok(), display_name.bold());
333 true
334 }
335 None => {
336 let _ = writeln!(writer, " {} Could not validate credentials.", sym_fail());
337 prompt_confirm(reader, writer, "Save anyway?", false)
338 }
339 };
340
341 if !save {
342 let _ = writeln!(writer, "\nAborted. Config not saved.");
343 let _ = writer.flush();
344 return Ok(());
345 }
346
347 config::write_profile(
348 config_path,
349 &profile_name,
350 &account_id,
351 &client_id,
352 &client_secret,
353 )?;
354
355 let pfx = if profile_name == "default" {
356 "zoom".to_owned()
357 } else {
358 format!("zoom --profile {}", profile_name)
359 };
360
361 let _ = writeln!(writer);
362 let _ = writeln!(
363 writer,
364 " {} Configuration saved to {}",
365 sym_ok(),
366 sym_dim(&config_path.display().to_string()),
367 );
368 let _ = writeln!(writer);
369 let _ = writeln!(writer, " {}:", "Next steps".bold());
370 let _ = writeln!(
371 writer,
372 " {}",
373 sym_dim(&format!(
374 "{pfx} meetings list # list upcoming meetings"
375 ))
376 );
377 let _ = writeln!(
378 writer,
379 " {}",
380 sym_dim(&format!("{pfx} users list # list users"))
381 );
382 let _ = writeln!(
383 writer,
384 " {}",
385 sym_dim(&format!("{pfx} completions zsh # shell completions"))
386 );
387 let _ = writeln!(writer);
388 let _ = writer.flush();
389
390 Ok(())
391}
392
393pub async fn init(profile_arg: Option<String>) -> Result<(), ApiError> {
395 let config_path = config::config_path();
396
397 if !std::io::stdout().is_terminal() {
398 print_json_schema(&config_path);
399 return Ok(());
400 }
401
402 let stdin = std::io::stdin();
403 let stdout = std::io::stdout();
404 let mut reader = std::io::BufReader::new(stdin.lock());
405 let mut writer = std::io::BufWriter::new(stdout.lock());
406
407 run_init(
408 &mut reader,
409 &mut writer,
410 &config_path,
411 profile_arg.as_deref(),
412 |account_id, client_id, client_secret| async move {
413 let mut client = crate::api::ZoomClient::new(account_id, client_id, client_secret);
414 match client.get_user("me").await {
415 Ok(user) => Some(user.display_name.unwrap_or(user.email)),
416 Err(_) => None,
417 }
418 },
419 )
420 .await
421}
422
423#[cfg(test)]
424mod tests {
425 use std::io::Cursor;
426
427 use tempfile::TempDir;
428
429 use super::*;
430
431 fn fake_path(dir: &TempDir) -> std::path::PathBuf {
432 dir.path().join("config.toml")
433 }
434
435 #[tokio::test]
436 async fn init_writes_config_on_valid_credentials() {
437 let dir = TempDir::new().unwrap();
438 let path = fake_path(&dir);
439
440 let input = b"test-account-id\ntest-client-id\ntest-client-secret\n";
442 let mut reader = Cursor::new(input.as_ref());
443 let mut writer = Vec::<u8>::new();
444
445 run_init(
446 &mut reader,
447 &mut writer,
448 &path,
449 None,
450 |a, b, c| async move {
451 let _ = (a, b, c);
452 Some("Alice Smith".into())
453 },
454 )
455 .await
456 .unwrap();
457
458 let saved = std::fs::read_to_string(&path).unwrap();
459 assert!(saved.contains("account_id"));
460 assert!(saved.contains("test-account-id"));
461 assert!(saved.contains("test-client-id"));
462 assert!(saved.contains("test-client-secret"));
463 }
464
465 #[tokio::test]
466 async fn init_uses_default_profile_name_when_empty() {
467 let dir = TempDir::new().unwrap();
468 let path = fake_path(&dir);
469
470 let input = b"test-acct\ntest-cid\ntest-csec\n";
472 let mut reader = Cursor::new(input.as_ref());
473 let mut writer = Vec::<u8>::new();
474
475 run_init(
476 &mut reader,
477 &mut writer,
478 &path,
479 None,
480 |a, b, c| async move {
481 let _ = (a, b, c);
482 Some("Test User".into())
483 },
484 )
485 .await
486 .unwrap();
487
488 let saved = std::fs::read_to_string(&path).unwrap();
489 assert!(
490 saved.contains("[default]"),
491 "should use 'default' profile name"
492 );
493 }
494
495 #[tokio::test]
496 async fn init_with_profile_arg_skips_profile_prompt() {
497 let dir = TempDir::new().unwrap();
498 let path = fake_path(&dir);
499
500 let input = b"test-acct\ntest-cid\ntest-csec\n";
502 let mut reader = Cursor::new(input.as_ref());
503 let mut writer = Vec::<u8>::new();
504
505 run_init(
506 &mut reader,
507 &mut writer,
508 &path,
509 Some("work"),
510 |a, b, c| async move {
511 let _ = (a, b, c);
512 Some("Alice".into())
513 },
514 )
515 .await
516 .unwrap();
517
518 let saved = std::fs::read_to_string(&path).unwrap();
519 assert!(saved.contains("[work]"));
520 }
521
522 #[tokio::test]
523 async fn init_aborts_when_validation_fails_and_user_declines_save() {
524 let dir = TempDir::new().unwrap();
525 let path = fake_path(&dir);
526
527 let input = b"test-acct\ntest-cid\ntest-csec\nn\n";
528 let mut reader = Cursor::new(input.as_ref());
529 let mut writer = Vec::<u8>::new();
530
531 run_init(
532 &mut reader,
533 &mut writer,
534 &path,
535 None,
536 |a, b, c| async move {
537 let _ = (a, b, c);
538 None
539 },
540 )
541 .await
542 .unwrap();
543
544 assert!(!path.exists(), "config should not be written after abort");
545 }
546
547 #[tokio::test]
548 async fn init_saves_when_validation_fails_but_user_forces_save() {
549 let dir = TempDir::new().unwrap();
550 let path = fake_path(&dir);
551
552 let input = b"test-acct\ntest-cid\ntest-csec\ny\n";
553 let mut reader = Cursor::new(input.as_ref());
554 let mut writer = Vec::<u8>::new();
555
556 run_init(
557 &mut reader,
558 &mut writer,
559 &path,
560 None,
561 |a, b, c| async move {
562 let _ = (a, b, c);
563 None
564 },
565 )
566 .await
567 .unwrap();
568
569 assert!(
570 path.exists(),
571 "config should be saved when user chooses to save anyway"
572 );
573 }
574
575 #[tokio::test]
576 async fn init_overwrites_existing_profile() {
577 let dir = TempDir::new().unwrap();
578 let path = fake_path(&dir);
579 std::fs::write(
580 &path,
581 "[default]\naccount_id = \"old\"\nclient_id = \"old\"\nclient_secret = \"old\"\n",
582 )
583 .unwrap();
584
585 let input = b"\nnew-account\nnew-client\nnew-secret\n";
588 let mut reader = Cursor::new(input.as_ref());
589 let mut writer = Vec::<u8>::new();
590
591 run_init(
592 &mut reader,
593 &mut writer,
594 &path,
595 None,
596 |a, b, c| async move {
597 let _ = (a, b, c);
598 Some("Alice".into())
599 },
600 )
601 .await
602 .unwrap();
603
604 let saved = std::fs::read_to_string(&path).unwrap();
605 assert!(saved.contains("new-account"));
606 assert!(
607 !saved.contains("\"old\""),
608 "old values should be overwritten"
609 );
610 }
611
612 #[tokio::test]
613 async fn init_update_keeps_fields_when_enter_pressed() {
614 let dir = TempDir::new().unwrap();
615 let path = fake_path(&dir);
616 std::fs::write(
617 &path,
618 "[default]\naccount_id = \"keep-acct\"\nclient_id = \"keep-cid\"\nclient_secret = \"keep-csec\"\n",
619 )
620 .unwrap();
621
622 let input = b"\n\n\n\n";
625 let mut reader = Cursor::new(input.as_ref());
626 let mut writer = Vec::<u8>::new();
627
628 run_init(
629 &mut reader,
630 &mut writer,
631 &path,
632 None,
633 |a, b, c| async move {
634 let _ = (a, b, c);
635 Some("Alice".into())
636 },
637 )
638 .await
639 .unwrap();
640
641 let saved = std::fs::read_to_string(&path).unwrap();
642 assert!(saved.contains("keep-acct"), "kept account_id");
643 assert!(saved.contains("keep-cid"), "kept client_id");
644 assert!(saved.contains("keep-csec"), "kept client_secret");
645 }
646
647 #[tokio::test]
648 async fn init_update_does_not_show_oauth_instructions() {
649 let dir = TempDir::new().unwrap();
650 let path = fake_path(&dir);
651 std::fs::write(
652 &path,
653 "[default]\naccount_id = \"acct\"\nclient_id = \"cid\"\nclient_secret = \"csec\"\n",
654 )
655 .unwrap();
656
657 let input = b"\nnew-acct\nnew-cid\nnew-csec\n";
658 let mut reader = Cursor::new(input.as_ref());
659 let mut writer = Vec::<u8>::new();
660
661 run_init(
662 &mut reader,
663 &mut writer,
664 &path,
665 None,
666 |a, b, c| async move {
667 let _ = (a, b, c);
668 Some("Alice".into())
669 },
670 )
671 .await
672 .unwrap();
673
674 let output = String::from_utf8_lossy(&writer);
675 assert!(
676 !output.contains("marketplace.zoom.us/develop/create"),
677 "update mode must not show OAuth setup instructions"
678 );
679 assert!(
680 output.contains("Profile:"),
681 "should list the existing profile"
682 );
683 assert!(output.contains("Action"), "should show the action prompt");
684 assert!(
685 output.contains("Enter to keep"),
686 "credential prompts should show keep hint"
687 );
688 }
689
690 #[tokio::test]
691 async fn init_adds_new_profile_to_existing_config() {
692 let dir = TempDir::new().unwrap();
693 let path = fake_path(&dir);
694 std::fs::write(
695 &path,
696 "[default]\naccount_id = \"def-acct\"\nclient_id = \"def-cid\"\nclient_secret = \"def-csec\"\n",
697 )
698 .unwrap();
699
700 let input = b"work-acct\nwork-cid\nwork-csec\n";
702 let mut reader = Cursor::new(input.as_ref());
703 let mut writer = Vec::<u8>::new();
704
705 run_init(
706 &mut reader,
707 &mut writer,
708 &path,
709 Some("work"),
710 |a, b, c| async move {
711 let _ = (a, b, c);
712 Some("Bob".into())
713 },
714 )
715 .await
716 .unwrap();
717
718 let saved = std::fs::read_to_string(&path).unwrap();
719 assert!(saved.contains("[default]"), "default profile preserved");
720 assert!(saved.contains("[work]"), "new profile added");
721 assert!(saved.contains("work-acct"));
722 assert!(saved.contains("def-acct"), "existing credentials untouched");
723
724 let output = String::from_utf8_lossy(&writer);
725 assert!(
726 !output.contains("Press Enter when your app is ready"),
727 "must not show the old wait gate"
728 );
729 }
730
731 #[tokio::test]
732 async fn init_aborts_gracefully_on_eof_during_required_prompt() {
733 let dir = TempDir::new().unwrap();
734 let path = fake_path(&dir);
735
736 let input = b"";
738 let mut reader = Cursor::new(input.as_ref());
739 let mut writer = Vec::<u8>::new();
740
741 run_init(
742 &mut reader,
743 &mut writer,
744 &path,
745 None,
746 |a, b, c| async move {
747 let _ = (a, b, c);
748 Some("Unreachable".into())
749 },
750 )
751 .await
752 .unwrap();
753
754 assert!(
755 !path.exists(),
756 "config must not be written on aborted input"
757 );
758 let output = String::from_utf8_lossy(&writer);
759 assert!(output.contains("Aborted"), "should print an abort message");
760 }
761
762 #[tokio::test]
763 async fn init_outro_includes_profile_flag_for_non_default_profiles() {
764 let dir = TempDir::new().unwrap();
765 let path = fake_path(&dir);
766
767 let input = b"work-acct\nwork-cid\nwork-csec\n";
768 let mut reader = Cursor::new(input.as_ref());
769 let mut writer = Vec::<u8>::new();
770
771 run_init(
772 &mut reader,
773 &mut writer,
774 &path,
775 Some("work"),
776 |a, b, c| async move {
777 let _ = (a, b, c);
778 Some("Bob".into())
779 },
780 )
781 .await
782 .unwrap();
783
784 let output = String::from_utf8_lossy(&writer);
785 assert!(
786 output.contains("--profile work"),
787 "outro should include --profile flag for non-default profiles"
788 );
789 }
790
791 #[tokio::test]
792 async fn init_action_add_prompts_for_new_profile_name() {
793 let dir = TempDir::new().unwrap();
794 let path = fake_path(&dir);
795 std::fs::write(
796 &path,
797 "[default]\naccount_id = \"def-acct\"\nclient_id = \"def-cid\"\nclient_secret = \"def-csec\"\n",
798 )
799 .unwrap();
800
801 let input = b"add\nstaging\nstg-acct\nstg-cid\nstg-csec\n";
803 let mut reader = Cursor::new(input.as_ref());
804 let mut writer = Vec::<u8>::new();
805
806 run_init(
807 &mut reader,
808 &mut writer,
809 &path,
810 None,
811 |a, b, c| async move {
812 let _ = (a, b, c);
813 Some("Carol".into())
814 },
815 )
816 .await
817 .unwrap();
818
819 let saved = std::fs::read_to_string(&path).unwrap();
820 assert!(saved.contains("[default]"), "default profile preserved");
821 assert!(saved.contains("[staging]"), "new profile added");
822 assert!(saved.contains("stg-acct"));
823
824 let output = String::from_utf8_lossy(&writer);
825 assert!(output.contains(OAUTH_URL), "add flow should show OAuth URL");
826 }
827}