tenseleyflow/rcal / b688449

Browse files

Add Microsoft provider setup flow

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b6884493ef21817d9af4653572c8779281b0deee
Parents
38753e4
Tree
cbb2db3

4 changed files

StatusFile+-
M README.md 25 35
M src/cli.rs 351 11
M src/config.rs 232 12
M src/providers.rs 12 0
README.mdmodified
@@ -25,7 +25,9 @@ rcal [--config PATH|--no-config] [--date YYYY-MM-DD] [--events-file PATH] [--hol
2525
 rcal config init [--path PATH] [--force]
2626
 rcal providers microsoft auth login --account ID [--browser]
2727
 rcal providers microsoft auth logout --account ID
28
+rcal providers microsoft auth inspect --account ID
2829
 rcal providers microsoft calendars list --account ID
30
+rcal providers microsoft setup --account ID [--browser] [--calendar ID]
2931
 rcal providers microsoft sync [--account ID]
3032
 rcal providers microsoft status
3133
 rcal reminders run [--events-file PATH] [--state-file PATH] [--once]
@@ -110,42 +112,35 @@ Microsoft Graph is the first remote provider. It is cache-first: the TUI reads
110112
 the local Microsoft cache instantly, and you refresh remote data explicitly:
111113
 
112114
 ```sh
113
-rcal providers microsoft auth login --account work
114
-rcal providers microsoft calendars list --account work
115
-rcal providers microsoft sync --account work
115
+rcal providers microsoft setup --account work --browser
116
+rcal
116117
 ```
117118
 
118
-For the current development release, users provide their own Microsoft Entra
119
-app registration `client_id` in `config.toml`. A future production release can
120
-ship an rcal-owned public/native client ID so end users do not need to create an
121
-Azure app. No client secret is used or stored; rcal stores user tokens in the OS
122
-keychain.
119
+The setup command uses rcal's official public/native Microsoft client ID, opens
120
+the Microsoft login flow, selects your default editable calendar, writes
121
+`~/.config/rcal/config.toml`, performs the first sync, and sets new event
122
+creation to Microsoft by default. No client secret is used or stored; rcal
123
+stores user tokens in the OS keychain.
123124
 
124
-### Microsoft Account Setup
125
-
126
-Create a config file:
125
+Use `--calendar CALENDAR_ID` if you already know which editable calendar to use.
126
+You can list calendars later with:
127127
 
128128
 ```sh
129
-rcal config init
129
+rcal providers microsoft calendars list --account work
130130
 ```
131131
 
132
-Register a temporary local test app in the Microsoft Entra admin center:
132
+### Microsoft Account Setup
133
+
134
+The normal setup flow is:
133135
 
134
-- Name: `rcal local test` or similar.
135
-- Supported account type:
136
-  - Personal Outlook/Hotmail/Live accounts: **Personal Microsoft accounts
137
-    only**.
138
-  - Work or school Microsoft 365 accounts: **Accounts in any organizational
139
-    directory**.
140
-- Redirect URI: platform **Mobile and desktop applications**, value
141
-  `http://localhost:8765/callback`.
142
-- API permissions: Microsoft Graph delegated `User.Read` and
143
-  `Calendars.ReadWrite`.
144
-- If Azure refuses the account type change with
145
-  `api.requestedAccessTokenVersion`, set the app manifest's
146
-  `requestedAccessTokenVersion` or `accessTokenAcceptedVersion` to `2`.
136
+```sh
137
+rcal providers microsoft setup --account work --browser
138
+rcal providers microsoft status
139
+rcal
140
+```
147141
 
148
-Then edit `~/.config/rcal/config.toml`:
142
+For advanced testing, you may still override the Microsoft app registration in
143
+`config.toml`. Omit `client_id` and `tenant` to use rcal's official public app:
149144
 
150145
 ```toml
151146
 [providers]
@@ -160,21 +155,16 @@ sync_future_days = 365
160155
 
161156
 [[providers.microsoft.accounts]]
162157
 id = "work"
163
-client_id = "AZURE_APP_CLIENT_ID"
164
-tenant = "consumers"      # personal accounts
165
-# tenant = "organizations" # work/school accounts
158
+# client_id = "AZURE_APP_CLIENT_ID"
159
+# tenant = "common"
166160
 redirect_port = 8765
167161
 calendars = ["CALENDAR_ID"]
168162
 ```
169163
 
170
-Authenticate, list calendars, then copy the editable calendar ID into both
171
-`default_calendar` and `calendars`:
164
+Manual sync remains available:
172165
 
173166
 ```sh
174
-rcal providers microsoft auth login --account work --browser
175
-rcal providers microsoft calendars list --account work
176167
 rcal providers microsoft sync --account work
177
-rcal
178168
 ```
179169
 
180170
 Use `rcal providers microsoft auth inspect --account work` to inspect safe
src/cli.rsmodified
@@ -23,13 +23,15 @@ use crate::{
2323
     },
2424
     calendar::CalendarDate,
2525
     config::{
26
-        ConfigError, ConfigHolidaySource, UserConfig, init_config_file, load_discovered_config,
27
-        load_explicit_config,
26
+        ConfigError, ConfigHolidaySource, MicrosoftSetupConfig, UserConfig, default_config_file,
27
+        init_config_file, load_discovered_config, load_explicit_config,
28
+        write_microsoft_setup_config,
2829
     },
2930
     providers::{
30
-        KeyringMicrosoftTokenStore, MicrosoftProviderConfig, MicrosoftProviderRuntime,
31
-        ProviderConfig, ProviderError, ReqwestMicrosoftHttpClient, inspect_token, list_calendars,
32
-        login_device_code_or_browser, logout,
31
+        KeyringMicrosoftTokenStore, MicrosoftAccountConfig, MicrosoftCalendarInfo,
32
+        MicrosoftProviderConfig, MicrosoftProviderRuntime, ProviderConfig, ProviderError,
33
+        ReqwestMicrosoftHttpClient, inspect_token, list_calendars, login_device_code_or_browser,
34
+        logout,
3335
     },
3436
     reminders::{
3537
         ReminderDaemonConfig, ReminderError, SystemNotifier, default_state_file,
@@ -56,6 +58,7 @@ const HELP: &str = concat!(
5658
     "  rcal providers microsoft auth logout --account ID\n",
5759
     "  rcal providers microsoft auth inspect --account ID\n",
5860
     "  rcal providers microsoft calendars list --account ID\n",
61
+    "  rcal providers microsoft setup --account ID [--browser] [--calendar ID]\n",
5962
     "  rcal providers microsoft sync [--account ID]\n",
6063
     "  rcal providers microsoft status\n\n",
6164
     "  rcal reminders run [--events-file PATH] [--state-file PATH] [--once]\n",
@@ -147,6 +150,13 @@ pub enum ProviderCliAction {
147150
 
148151
 #[derive(Debug, Clone, PartialEq, Eq)]
149152
 pub enum MicrosoftCliAction {
153
+    Setup {
154
+        account: String,
155
+        browser: bool,
156
+        calendar: Option<String>,
157
+        config_path: PathBuf,
158
+        config: MicrosoftProviderConfig,
159
+    },
150160
     AuthLogin {
151161
         account: String,
152162
         browser: bool,
@@ -219,6 +229,8 @@ pub enum CliError {
219229
     UnknownProviderCommand(String),
220230
     MissingProviderAccount,
221231
     DuplicateProviderAccount,
232
+    MissingProviderCalendar,
233
+    DuplicateProviderCalendar,
222234
     MissingReminderCommand,
223235
     UnknownReminderCommand(String),
224236
     DuplicateStateFile,
@@ -278,6 +290,10 @@ impl fmt::Display for CliError {
278290
             }
279291
             Self::MissingProviderAccount => write!(f, "--account requires a Microsoft account id"),
280292
             Self::DuplicateProviderAccount => write!(f, "--account may only be provided once"),
293
+            Self::MissingProviderCalendar => {
294
+                write!(f, "--calendar requires a Microsoft calendar id")
295
+            }
296
+            Self::DuplicateProviderCalendar => write!(f, "--calendar may only be provided once"),
281297
             Self::MissingReminderCommand => write!(
282298
                 f,
283299
                 "reminders requires one of: run, install, uninstall, status, test"
@@ -427,10 +443,18 @@ where
427443
         return parse_config_args(args.into_iter().skip(1), config_selection.path);
428444
     }
429445
 
446
+    let is_setup = is_microsoft_setup_command(&args);
430447
     let config = match &config_selection {
431448
         ConfigSelection {
432449
             no_config: true, ..
433450
         } => UserConfig::empty(),
451
+        ConfigSelection {
452
+            path: Some(path), ..
453
+        } if is_setup => match load_explicit_config(path.clone()) {
454
+            Ok(config) => config,
455
+            Err(ConfigError::Missing { .. }) => UserConfig::empty(),
456
+            Err(err) => return Err(err.into()),
457
+        },
434458
         ConfigSelection {
435459
             path: Some(path), ..
436460
         } => load_explicit_config(path.clone())?,
@@ -440,6 +464,14 @@ where
440464
     parse_args_with_config(args, today, config, config_selection.path)
441465
 }
442466
 
467
+fn is_microsoft_setup_command(args: &[OsString]) -> bool {
468
+    matches!(
469
+        (args.first(), args.get(1), args.get(2)),
470
+        (Some(first), Some(second), Some(third))
471
+            if first == "providers" && second == "microsoft" && third == "setup"
472
+    )
473
+}
474
+
443475
 fn parse_args_with_config<I>(
444476
     args: I,
445477
     today: Date,
@@ -465,7 +497,10 @@ where
465497
     if let Some(first) = args.first()
466498
         && first == "providers"
467499
     {
468
-        return parse_provider_args(args.into_iter().skip(1), &user_config);
500
+        let config_path = explicit_config_path
501
+            .or_else(|| user_config.path.clone())
502
+            .unwrap_or_else(default_config_file);
503
+        return parse_provider_args(args.into_iter().skip(1), &user_config, config_path);
469504
     }
470505
 
471506
     parse_calendar_args(args, today, user_config)
@@ -768,7 +803,11 @@ where
768803
     Ok(CliAction::Config(ConfigCliAction::Init { path, force }))
769804
 }
770805
 
771
-fn parse_provider_args<I>(args: I, user_config: &UserConfig) -> Result<CliAction, CliError>
806
+fn parse_provider_args<I>(
807
+    args: I,
808
+    user_config: &UserConfig,
809
+    config_path: PathBuf,
810
+) -> Result<CliAction, CliError>
772811
 where
773812
     I: IntoIterator<Item = OsString>,
774813
 {
@@ -779,7 +818,9 @@ where
779818
     };
780819
 
781820
     match command {
782
-        "microsoft" => parse_microsoft_provider_args(args, &user_config.providers.microsoft),
821
+        "microsoft" => {
822
+            parse_microsoft_provider_args(args, &user_config.providers.microsoft, config_path)
823
+        }
783824
         "--help" | "-h" => Ok(CliAction::Help),
784825
         _ => Err(CliError::UnknownProviderCommand(command.to_string())),
785826
     }
@@ -788,6 +829,7 @@ where
788829
 fn parse_microsoft_provider_args<I>(
789830
     args: I,
790831
     config: &MicrosoftProviderConfig,
832
+    config_path: PathBuf,
791833
 ) -> Result<CliAction, CliError>
792834
 where
793835
     I: IntoIterator<Item = OsString>,
@@ -801,6 +843,7 @@ where
801843
     match command {
802844
         "auth" => parse_microsoft_auth_args(args, config),
803845
         "calendars" => parse_microsoft_calendars_args(args, config),
846
+        "setup" => parse_microsoft_setup_args(args, config, config_path),
804847
         "sync" => parse_microsoft_sync_args(args, config),
805848
         "status" => no_extra_provider_args(
806849
             args,
@@ -857,6 +900,56 @@ where
857900
     }
858901
 }
859902
 
903
+fn parse_microsoft_setup_args<I>(
904
+    args: I,
905
+    config: &MicrosoftProviderConfig,
906
+    config_path: PathBuf,
907
+) -> Result<CliAction, CliError>
908
+where
909
+    I: IntoIterator<Item = OsString>,
910
+{
911
+    let mut browser = false;
912
+    let mut account = None;
913
+    let mut calendar = None;
914
+    let mut args = args.into_iter();
915
+    while let Some(arg) = args.next() {
916
+        if arg == "--browser" {
917
+            browser = true;
918
+            continue;
919
+        }
920
+        if arg == "--calendar" {
921
+            if calendar.is_some() {
922
+                return Err(CliError::DuplicateProviderCalendar);
923
+            }
924
+            calendar = Some(display_arg(
925
+                &args.next().ok_or(CliError::MissingProviderCalendar)?,
926
+            ));
927
+            continue;
928
+        }
929
+        if let Some(value) = arg
930
+            .to_str()
931
+            .and_then(|value| value.strip_prefix("--calendar="))
932
+        {
933
+            if calendar.is_some() {
934
+                return Err(CliError::DuplicateProviderCalendar);
935
+            }
936
+            calendar = Some(value.to_string());
937
+            continue;
938
+        }
939
+        parse_account_arg(arg, &mut args, &mut account)?;
940
+    }
941
+
942
+    Ok(CliAction::Providers(ProviderCliAction::Microsoft(
943
+        MicrosoftCliAction::Setup {
944
+            account: account.ok_or(CliError::MissingProviderAccount)?,
945
+            browser,
946
+            calendar,
947
+            config_path,
948
+            config: config.clone(),
949
+        },
950
+    )))
951
+}
952
+
860953
 fn parse_microsoft_calendars_args<I>(
861954
     args: I,
862955
     config: &MicrosoftProviderConfig,
@@ -1236,6 +1329,48 @@ fn run_microsoft_action(
12361329
     let http = ReqwestMicrosoftHttpClient;
12371330
     let token_store = KeyringMicrosoftTokenStore;
12381331
     let result = match action {
1332
+        MicrosoftCliAction::Setup {
1333
+            account,
1334
+            browser,
1335
+            calendar,
1336
+            config_path,
1337
+            config,
1338
+        } => (|| {
1339
+            let account_config = MicrosoftAccountConfig::new_official(account.clone());
1340
+            login_device_code_or_browser(&account_config, &http, &token_store, stdout, browser)?;
1341
+            let calendars = list_calendars(&account_config, &http, &token_store)?;
1342
+            let calendar = choose_setup_calendar(&calendars, calendar.as_deref())?;
1343
+            let setup = MicrosoftSetupConfig {
1344
+                account_id: account.clone(),
1345
+                calendar_id: calendar.id.clone(),
1346
+                calendar_name: calendar.name.clone(),
1347
+                sync_past_days: config.sync_past_days,
1348
+                sync_future_days: config.sync_future_days,
1349
+                redirect_port: account_config.redirect_port,
1350
+            };
1351
+            let written = write_microsoft_setup_config(Some(config_path), &setup)
1352
+                .map_err(|err| ProviderError::Config(err.to_string()))?;
1353
+            let _ = writeln!(
1354
+                stdout,
1355
+                "selected Microsoft calendar '{}' ({})",
1356
+                calendar.name, calendar.id
1357
+            );
1358
+            let _ = writeln!(stdout, "wrote config {}", written.display());
1359
+            let setup_config = microsoft_setup_provider_config(config, account_config, calendar);
1360
+            let mut runtime = MicrosoftProviderRuntime::load(setup_config)?;
1361
+            let summary = runtime.sync(
1362
+                Some(&account),
1363
+                &http,
1364
+                &token_store,
1365
+                CalendarDate::from(default_start_date()),
1366
+            )?;
1367
+            let _ = writeln!(
1368
+                stdout,
1369
+                "synced accounts={} calendars={} events={}",
1370
+                summary.accounts, summary.calendars, summary.events
1371
+            );
1372
+            Ok(())
1373
+        })(),
12391374
         MicrosoftCliAction::AuthLogin {
12401375
             account,
12411376
             browser,
@@ -1391,6 +1526,62 @@ fn run_microsoft_action(
13911526
     }
13921527
 }
13931528
 
1529
+fn choose_setup_calendar<'a>(
1530
+    calendars: &'a [MicrosoftCalendarInfo],
1531
+    requested: Option<&str>,
1532
+) -> Result<&'a MicrosoftCalendarInfo, ProviderError> {
1533
+    if let Some(requested) = requested {
1534
+        let calendar = calendars
1535
+            .iter()
1536
+            .find(|calendar| calendar.id == requested)
1537
+            .ok_or_else(|| {
1538
+                ProviderError::Config(format!(
1539
+                    "Microsoft calendar '{requested}' was not found for this account"
1540
+                ))
1541
+            })?;
1542
+        if !calendar.can_edit {
1543
+            return Err(ProviderError::Config(format!(
1544
+                "Microsoft calendar '{}' is read-only; choose an editable calendar",
1545
+                calendar.name
1546
+            )));
1547
+        }
1548
+        return Ok(calendar);
1549
+    }
1550
+
1551
+    calendars
1552
+        .iter()
1553
+        .find(|calendar| calendar.can_edit && calendar.is_default)
1554
+        .or_else(|| calendars.iter().find(|calendar| calendar.can_edit))
1555
+        .ok_or_else(|| {
1556
+            ProviderError::Config(
1557
+                "no editable Microsoft calendars were found for this account".to_string(),
1558
+            )
1559
+        })
1560
+}
1561
+
1562
+fn microsoft_setup_provider_config(
1563
+    mut config: MicrosoftProviderConfig,
1564
+    mut account: MicrosoftAccountConfig,
1565
+    calendar: &MicrosoftCalendarInfo,
1566
+) -> MicrosoftProviderConfig {
1567
+    config.enabled = true;
1568
+    config.default_account = Some(account.id.clone());
1569
+    config.default_calendar = Some(calendar.id.clone());
1570
+    account.calendars = vec![calendar.id.clone()];
1571
+
1572
+    if let Some(existing) = config
1573
+        .accounts
1574
+        .iter_mut()
1575
+        .find(|existing| existing.id == account.id)
1576
+    {
1577
+        *existing = account;
1578
+    } else {
1579
+        config.accounts.push(account);
1580
+    }
1581
+
1582
+    config
1583
+}
1584
+
13941585
 fn run_reminder_action(
13951586
     action: ReminderCliAction,
13961587
     stdout: &mut impl Write,
@@ -1815,11 +2006,14 @@ fn provider_error_exit(stderr: &mut impl Write, err: ProviderError) -> std::proc
18152006
 #[cfg(test)]
18162007
 mod tests {
18172008
     use super::*;
1818
-    use std::{env, fs};
2009
+    use std::{
2010
+        env, fs,
2011
+        sync::atomic::{AtomicUsize, Ordering},
2012
+    };
18192013
 
18202014
     use crate::app::{KeyBindingOverrides, KeyCommand};
18212015
     use crate::calendar::CalendarMonth;
1822
-    use crate::providers::{MicrosoftAccountConfig, ProviderCreateTarget};
2016
+    use crate::providers::{MicrosoftAccountConfig, MicrosoftCalendarInfo, ProviderCreateTarget};
18232017
     use time::Month;
18242018
 
18252019
     fn date(year: i32, month: Month, day: u8) -> CalendarDate {
@@ -1830,9 +2024,12 @@ mod tests {
18302024
         OsString::from(value)
18312025
     }
18322026
 
2027
+    static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
2028
+
18332029
     fn temp_path(name: &str) -> PathBuf {
2030
+        let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
18342031
         env::temp_dir()
1835
-            .join(format!("rcal-cli-test-{}", std::process::id()))
2032
+            .join(format!("rcal-cli-test-{}-{counter}", std::process::id()))
18362033
             .join(name)
18372034
     }
18382035
 
@@ -2375,6 +2572,149 @@ create_event = ["n"]
23752572
         );
23762573
     }
23772574
 
2575
+    #[test]
2576
+    fn microsoft_setup_command_parses_with_calendar_and_config_path() {
2577
+        let today = date(2026, Month::April, 23);
2578
+        let user_config = config_with_microsoft_provider();
2579
+        let microsoft = user_config.providers.microsoft.clone();
2580
+        let config_path = PathBuf::from("/tmp/rcal/setup-config.toml");
2581
+
2582
+        let action = parse_args_with_config(
2583
+            [
2584
+                arg("providers"),
2585
+                arg("microsoft"),
2586
+                arg("setup"),
2587
+                arg("--account"),
2588
+                arg("work"),
2589
+                arg("--browser"),
2590
+                arg("--calendar=cal-2"),
2591
+            ],
2592
+            today.into(),
2593
+            user_config,
2594
+            Some(config_path.clone()),
2595
+        )
2596
+        .expect("setup parses");
2597
+
2598
+        assert_eq!(
2599
+            action,
2600
+            CliAction::Providers(ProviderCliAction::Microsoft(MicrosoftCliAction::Setup {
2601
+                account: "work".to_string(),
2602
+                browser: true,
2603
+                calendar: Some("cal-2".to_string()),
2604
+                config_path,
2605
+                config: microsoft,
2606
+            }))
2607
+        );
2608
+    }
2609
+
2610
+    #[test]
2611
+    fn microsoft_setup_allows_missing_explicit_config_file() {
2612
+        let today = date(2026, Month::April, 23);
2613
+        let path = temp_path("missing-setup/config.toml");
2614
+        let root = path
2615
+            .parent()
2616
+            .expect("config dir")
2617
+            .parent()
2618
+            .expect("test root")
2619
+            .to_path_buf();
2620
+        let _ = fs::remove_dir_all(&root);
2621
+
2622
+        let action = parse_runtime_args(
2623
+            [
2624
+                arg("--config"),
2625
+                path.as_os_str().to_os_string(),
2626
+                arg("providers"),
2627
+                arg("microsoft"),
2628
+                arg("setup"),
2629
+                arg("--account"),
2630
+                arg("work"),
2631
+            ],
2632
+            today.into(),
2633
+        )
2634
+        .expect("setup parses without a preexisting config");
2635
+
2636
+        assert_eq!(
2637
+            action,
2638
+            CliAction::Providers(ProviderCliAction::Microsoft(MicrosoftCliAction::Setup {
2639
+                account: "work".to_string(),
2640
+                browser: false,
2641
+                calendar: None,
2642
+                config_path: path,
2643
+                config: MicrosoftProviderConfig::default(),
2644
+            }))
2645
+        );
2646
+    }
2647
+
2648
+    #[test]
2649
+    fn microsoft_setup_calendar_selection_prefers_editable_default() {
2650
+        let calendars = vec![
2651
+            MicrosoftCalendarInfo {
2652
+                id: "readonly".to_string(),
2653
+                name: "Holidays".to_string(),
2654
+                can_edit: false,
2655
+                is_default: true,
2656
+            },
2657
+            MicrosoftCalendarInfo {
2658
+                id: "cal".to_string(),
2659
+                name: "Calendar".to_string(),
2660
+                can_edit: true,
2661
+                is_default: true,
2662
+            },
2663
+            MicrosoftCalendarInfo {
2664
+                id: "other".to_string(),
2665
+                name: "Other".to_string(),
2666
+                can_edit: true,
2667
+                is_default: false,
2668
+            },
2669
+        ];
2670
+
2671
+        let calendar =
2672
+            choose_setup_calendar(&calendars, None).expect("default editable calendar selected");
2673
+
2674
+        assert_eq!(calendar.id, "cal");
2675
+    }
2676
+
2677
+    #[test]
2678
+    fn microsoft_setup_calendar_selection_uses_first_editable_fallback() {
2679
+        let calendars = vec![
2680
+            MicrosoftCalendarInfo {
2681
+                id: "readonly".to_string(),
2682
+                name: "Holidays".to_string(),
2683
+                can_edit: false,
2684
+                is_default: true,
2685
+            },
2686
+            MicrosoftCalendarInfo {
2687
+                id: "work".to_string(),
2688
+                name: "Work".to_string(),
2689
+                can_edit: true,
2690
+                is_default: false,
2691
+            },
2692
+        ];
2693
+
2694
+        let calendar =
2695
+            choose_setup_calendar(&calendars, None).expect("first editable calendar selected");
2696
+
2697
+        assert_eq!(calendar.id, "work");
2698
+    }
2699
+
2700
+    #[test]
2701
+    fn microsoft_setup_calendar_selection_rejects_missing_or_readonly_requested_calendar() {
2702
+        let calendars = vec![MicrosoftCalendarInfo {
2703
+            id: "readonly".to_string(),
2704
+            name: "Holidays".to_string(),
2705
+            can_edit: false,
2706
+            is_default: false,
2707
+        }];
2708
+
2709
+        let missing =
2710
+            choose_setup_calendar(&calendars, Some("missing")).expect_err("missing calendar fails");
2711
+        let readonly = choose_setup_calendar(&calendars, Some("readonly"))
2712
+            .expect_err("readonly calendar fails");
2713
+
2714
+        assert!(missing.to_string().contains("was not found"));
2715
+        assert!(readonly.to_string().contains("read-only"));
2716
+    }
2717
+
23782718
     #[test]
23792719
     fn microsoft_provider_args_reject_missing_and_duplicate_accounts() {
23802720
         let today = date(2026, Month::April, 23);
src/config.rsmodified
@@ -10,7 +10,8 @@ use serde::Deserialize;
1010
 use crate::{
1111
     app::{KeyBindingError, KeyBindingOverrides, KeyBindings},
1212
     providers::{
13
-        MicrosoftAccountConfig, MicrosoftProviderConfig, ProviderConfig, ProviderCreateTarget,
13
+        MICROSOFT_DEFAULT_TENANT, MICROSOFT_OFFICIAL_CLIENT_ID, MicrosoftAccountConfig,
14
+        MicrosoftProviderConfig, ProviderConfig, ProviderCreateTarget,
1415
     },
1516
 };
1617
 
@@ -37,12 +38,10 @@ create_target = "local"
3738
 
3839
 [providers.microsoft]
3940
 # Microsoft Graph provider for Outlook / Microsoft 365 calendars.
40
-# Current development builds require your own Microsoft Entra app client_id.
41
-# Use tenant = "consumers" for personal Outlook/Hotmail accounts and
42
-# tenant = "organizations" for work or school Microsoft 365 accounts.
41
+# rcal ships an official public/native Microsoft client ID.
42
+# Advanced users may override client_id and tenant inside an account block.
4343
 enabled = false
4444
 default_account = "work"
45
-default_calendar = "CALENDAR_ID"
4645
 sync_past_days = 30
4746
 sync_future_days = 365
4847
 # Provider cache is separate from the local events file.
@@ -50,10 +49,8 @@ sync_future_days = 365
5049
 
5150
 [[providers.microsoft.accounts]]
5251
 id = "work"
53
-client_id = "AZURE_APP_CLIENT_ID"
54
-tenant = "organizations"
5552
 redirect_port = 8765
56
-calendars = ["CALENDAR_ID"]
53
+calendars = []
5754
 
5855
 [keybindings]
5956
 # Normal month/day app commands. Modal/form editing keys are fixed for now.
@@ -184,6 +181,139 @@ pub fn init_config_file(path: Option<PathBuf>, force: bool) -> Result<PathBuf, C
184181
     Ok(path)
185182
 }
186183
 
184
+#[derive(Debug, Clone, PartialEq, Eq)]
185
+pub struct MicrosoftSetupConfig {
186
+    pub account_id: String,
187
+    pub calendar_id: String,
188
+    pub calendar_name: String,
189
+    pub sync_past_days: i32,
190
+    pub sync_future_days: i32,
191
+    pub redirect_port: u16,
192
+}
193
+
194
+pub fn write_microsoft_setup_config(
195
+    path: Option<PathBuf>,
196
+    setup: &MicrosoftSetupConfig,
197
+) -> Result<PathBuf, ConfigError> {
198
+    let path = match path {
199
+        Some(path) => expand_user_path(path)?,
200
+        None => default_config_file(),
201
+    };
202
+    let existing = if path.exists() {
203
+        fs::read_to_string(&path).map_err(|err| ConfigError::Read {
204
+            path: path.clone(),
205
+            reason: err.to_string(),
206
+        })?
207
+    } else {
208
+        String::new()
209
+    };
210
+    let body = microsoft_setup_config_body(&existing, setup);
211
+    if let Some(parent) = path.parent() {
212
+        fs::create_dir_all(parent).map_err(|err| ConfigError::Write {
213
+            path: parent.to_path_buf(),
214
+            reason: err.to_string(),
215
+        })?;
216
+    }
217
+    fs::write(&path, body).map_err(|err| ConfigError::Write {
218
+        path: path.clone(),
219
+        reason: err.to_string(),
220
+    })?;
221
+    Ok(path)
222
+}
223
+
224
+fn microsoft_setup_config_body(existing: &str, setup: &MicrosoftSetupConfig) -> String {
225
+    let mut kept = Vec::new();
226
+    let mut skipping = false;
227
+    for line in existing.lines() {
228
+        if is_toml_table_header(line) {
229
+            skipping = is_managed_provider_header(line);
230
+        }
231
+        if !skipping {
232
+            kept.push(line);
233
+        }
234
+    }
235
+    while kept.last().is_some_and(|line| line.trim().is_empty()) {
236
+        kept.pop();
237
+    }
238
+
239
+    let mut body = kept.join("\n");
240
+    if !body.is_empty() {
241
+        body.push_str("\n\n");
242
+    }
243
+    body.push_str(&format!(
244
+        concat!(
245
+            "[providers]\n",
246
+            "create_target = \"microsoft\"\n\n",
247
+            "[providers.microsoft]\n",
248
+            "enabled = true\n",
249
+            "default_account = {account}\n",
250
+            "default_calendar = {calendar}\n",
251
+            "sync_past_days = {sync_past_days}\n",
252
+            "sync_future_days = {sync_future_days}\n\n",
253
+            "[[providers.microsoft.accounts]]\n",
254
+            "id = {account}\n",
255
+            "redirect_port = {redirect_port}\n",
256
+            "calendars = [{calendar}]\n",
257
+            "# Selected calendar: {calendar_name}\n"
258
+        ),
259
+        account = toml_string(&setup.account_id),
260
+        calendar = toml_string(&setup.calendar_id),
261
+        calendar_name = toml_comment_value(&setup.calendar_name),
262
+        redirect_port = setup.redirect_port,
263
+        sync_past_days = setup.sync_past_days.max(0),
264
+        sync_future_days = setup.sync_future_days.max(1),
265
+    ));
266
+    body
267
+}
268
+
269
+fn is_toml_table_header(line: &str) -> bool {
270
+    let trimmed = line.trim();
271
+    trimmed.starts_with('[') && trimmed.ends_with(']')
272
+}
273
+
274
+fn is_managed_provider_header(line: &str) -> bool {
275
+    let trimmed = line.trim();
276
+    let name = trimmed
277
+        .trim_start_matches('[')
278
+        .trim_start_matches('[')
279
+        .trim_end_matches(']')
280
+        .trim_end_matches(']')
281
+        .trim();
282
+    name == "providers" || name == "providers.microsoft" || name == "providers.microsoft.accounts"
283
+}
284
+
285
+fn toml_string(value: &str) -> String {
286
+    let mut quoted = String::from("\"");
287
+    for character in value.chars() {
288
+        match character {
289
+            '\\' => quoted.push_str("\\\\"),
290
+            '"' => quoted.push_str("\\\""),
291
+            '\n' => quoted.push_str("\\n"),
292
+            '\r' => quoted.push_str("\\r"),
293
+            '\t' => quoted.push_str("\\t"),
294
+            value if value.is_control() => {
295
+                quoted.push_str(&format!("\\u{:04x}", u32::from(value)));
296
+            }
297
+            value => quoted.push(value),
298
+        }
299
+    }
300
+    quoted.push('"');
301
+    quoted
302
+}
303
+
304
+fn toml_comment_value(value: &str) -> String {
305
+    value
306
+        .chars()
307
+        .map(|character| {
308
+            if character.is_control() {
309
+                ' '
310
+            } else {
311
+                character
312
+            }
313
+        })
314
+        .collect::<String>()
315
+}
316
+
187317
 fn raw_config_to_user_config(raw: RawConfig, path: &Path) -> Result<UserConfig, ConfigError> {
188318
     let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
189319
     let events_file = raw
@@ -347,8 +477,8 @@ struct RawMicrosoftProviderConfig {
347477
 #[serde(deny_unknown_fields)]
348478
 struct RawMicrosoftAccountConfig {
349479
     id: String,
350
-    client_id: String,
351
-    tenant: String,
480
+    client_id: Option<String>,
481
+    tenant: Option<String>,
352482
     redirect_port: Option<u16>,
353483
     calendars: Option<Vec<String>>,
354484
 }
@@ -464,8 +594,12 @@ impl RawMicrosoftAccountConfig {
464594
     fn into_config(self) -> MicrosoftAccountConfig {
465595
         MicrosoftAccountConfig {
466596
             id: self.id,
467
-            client_id: self.client_id,
468
-            tenant: self.tenant,
597
+            client_id: self
598
+                .client_id
599
+                .unwrap_or_else(|| MICROSOFT_OFFICIAL_CLIENT_ID.to_string()),
600
+            tenant: self
601
+                .tenant
602
+                .unwrap_or_else(|| MICROSOFT_DEFAULT_TENANT.to_string()),
469603
             redirect_port: self.redirect_port.unwrap_or(8765),
470604
             calendars: self.calendars.unwrap_or_default(),
471605
         }
@@ -656,6 +790,92 @@ calendars = ["cal-1"]
656790
         assert_eq!(config.providers.microsoft.accounts[0].redirect_port, 9001);
657791
     }
658792
 
793
+    #[test]
794
+    fn microsoft_provider_defaults_to_official_public_client() {
795
+        let path = temp_config_path("providers-official/config.toml");
796
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
797
+        fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
798
+        fs::write(
799
+            &path,
800
+            r#"
801
+[providers.microsoft]
802
+enabled = true
803
+default_account = "work"
804
+default_calendar = "cal-1"
805
+
806
+[[providers.microsoft.accounts]]
807
+id = "work"
808
+calendars = ["cal-1"]
809
+"#,
810
+        )
811
+        .expect("config writes");
812
+
813
+        let config = load_config_file(&path).expect("config loads");
814
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
815
+
816
+        let account = &config.providers.microsoft.accounts[0];
817
+        assert_eq!(account.client_id, MICROSOFT_OFFICIAL_CLIENT_ID);
818
+        assert_eq!(account.tenant, MICROSOFT_DEFAULT_TENANT);
819
+    }
820
+
821
+    #[test]
822
+    fn microsoft_setup_config_preserves_unrelated_sections() {
823
+        let path = temp_config_path("providers-setup/config.toml");
824
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
825
+        fs::create_dir_all(path.parent().expect("config dir")).expect("dir creates");
826
+        fs::write(
827
+            &path,
828
+            r#"
829
+[paths]
830
+events_file = "./events.json"
831
+
832
+[providers]
833
+create_target = "local"
834
+
835
+[providers.microsoft]
836
+enabled = false
837
+
838
+[[providers.microsoft.accounts]]
839
+id = "old"
840
+calendars = []
841
+
842
+[keybindings]
843
+quit = ["q"]
844
+"#,
845
+        )
846
+        .expect("config writes");
847
+
848
+        write_microsoft_setup_config(
849
+            Some(path.clone()),
850
+            &MicrosoftSetupConfig {
851
+                account_id: "work".to_string(),
852
+                calendar_id: "cal-1".to_string(),
853
+                calendar_name: "Calendar\nInjected".to_string(),
854
+                sync_past_days: 7,
855
+                sync_future_days: 90,
856
+                redirect_port: 9001,
857
+            },
858
+        )
859
+        .expect("setup config writes");
860
+        let body = fs::read_to_string(&path).expect("config reads");
861
+        let config = load_config_file(&path).expect("config loads");
862
+        let _ = fs::remove_dir_all(path.parent().and_then(Path::parent).expect("test root"));
863
+
864
+        assert!(body.contains("[paths]"));
865
+        assert!(body.contains("[keybindings]"));
866
+        assert!(body.contains("# Selected calendar: Calendar Injected"));
867
+        assert!(!body.contains("id = \"old\""));
868
+        assert_eq!(
869
+            config.providers.create_target,
870
+            ProviderCreateTarget::Microsoft
871
+        );
872
+        assert_eq!(
873
+            config.providers.microsoft.default_calendar.as_deref(),
874
+            Some("cal-1")
875
+        );
876
+        assert_eq!(config.providers.microsoft.accounts[0].redirect_port, 9001);
877
+    }
878
+
659879
     #[test]
660880
     fn invalid_microsoft_provider_config_fails_clearly() {
661881
         let path = temp_config_path("providers-invalid/config.toml");
src/providers.rsmodified
@@ -32,6 +32,8 @@ const GRAPH_BASE_URL: &str = "https://graph.microsoft.com/v1.0";
3232
 const LOGIN_BASE_URL: &str = "https://login.microsoftonline.com";
3333
 const MICROSOFT_SCOPES: &str = "offline_access User.Read Calendars.ReadWrite";
3434
 const KEYRING_SERVICE: &str = "rcal.microsoft";
35
+pub const MICROSOFT_OFFICIAL_CLIENT_ID: &str = "9a49eaac-422b-4192-a65d-82dc8f43c11d";
36
+pub const MICROSOFT_DEFAULT_TENANT: &str = "common";
3537
 
3638
 #[derive(Debug, Clone, PartialEq, Eq)]
3739
 pub struct ProviderConfig {
@@ -184,6 +186,16 @@ pub struct MicrosoftAccountConfig {
184186
 }
185187
 
186188
 impl MicrosoftAccountConfig {
189
+    pub fn new_official(id: impl Into<String>) -> Self {
190
+        Self {
191
+            id: id.into(),
192
+            client_id: MICROSOFT_OFFICIAL_CLIENT_ID.to_string(),
193
+            tenant: MICROSOFT_DEFAULT_TENANT.to_string(),
194
+            redirect_port: 8765,
195
+            calendars: Vec::new(),
196
+        }
197
+    }
198
+
187199
     pub fn token_url(&self) -> String {
188200
         format!("{LOGIN_BASE_URL}/{}/oauth2/v2.0/token", self.tenant)
189201
     }