Skip to main content

zoom_cli/commands/
init.rs

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
135/// Interactive init flow with injectable IO and validator for testing.
136///
137/// `validate` receives (account_id, client_id, client_secret) and returns
138/// `Some(display_name)` on success or `None` on auth failure.
139pub 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
250/// Entry point from main — uses real stdin/stdout and live API validation.
251pub 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        // Enter: ready, default profile, account, client_id, secret
299        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        // One fewer line (no profile name prompt)
355        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}