use std::time::Duration;
use crate::cli::DialoguerExt;
use async_trait::async_trait;
use clap::Args;
use dialoguer::{Confirm, Input, Password, theme::ColorfulTheme};
use crate::cli::lifecycle::{
CliLifecycle, CreateArgsBase, CreateArgsLike, LifecycleService, ScopesArg, SecretRef,
resolve_scopes,
};
use zad::config::{GcalServiceCfg, ProjectConfig};
use zad::error::{Result, ZadError};
use zad::oauth::{LoopbackConfig, RedirectScheme, run_loopback_flow};
use zad::secrets::{self, Scope};
use zad::service::gcal::{AUTH_URL, GcalHttp, TOKEN_URL};
const DEFAULT_SCOPES: &[&str] = &["calendars.read", "events.read", "events.write"];
const ALL_SCOPES: &[&str] = &[
"calendars.read",
"events.read",
"events.write",
"events.invite",
"events.remind",
];
const GCP_CREDENTIALS_URL: &str = "https://console.cloud.google.com/apis/credentials";
const LOOPBACK_TIMEOUT: Duration = Duration::from_secs(120);
pub struct GcalSecrets {
pub client_id: String,
pub client_secret: String,
pub refresh_token: String,
}
#[derive(Debug, Args)]
pub struct CreateArgs {
#[command(flatten)]
pub base: CreateArgsBase,
#[command(flatten)]
pub scopes: ScopesArg,
#[arg(long)]
pub client_id: Option<String>,
#[arg(long, conflicts_with = "client_id")]
pub client_id_env: Option<String>,
#[arg(long, conflicts_with = "client_secret_env")]
pub client_secret: Option<String>,
#[arg(long, conflicts_with = "client_secret")]
pub client_secret_env: Option<String>,
#[arg(long, conflicts_with = "refresh_token_env")]
pub refresh_token: Option<String>,
#[arg(long, conflicts_with = "refresh_token")]
pub refresh_token_env: Option<String>,
#[arg(long)]
pub default_calendar: Option<String>,
#[arg(long)]
pub self_email: Option<String>,
}
impl CreateArgsLike for CreateArgs {
fn base(&self) -> &CreateArgsBase {
&self.base
}
}
pub struct GcalLifecycle;
#[async_trait]
impl LifecycleService for GcalLifecycle {
const NAME: &'static str = "gcal";
const DISPLAY: &'static str = "Google Calendar";
type Cfg = GcalServiceCfg;
type Secrets = GcalSecrets;
fn enable_in_project(cfg: &mut ProjectConfig) {
cfg.enable_gcal();
}
fn disable_in_project(cfg: &mut ProjectConfig) {
cfg.disable_gcal();
}
async fn validate(_cfg: &GcalServiceCfg, creds: &mut GcalSecrets) -> Result<String> {
let http = GcalHttp::unscoped(
creds.client_id.clone(),
creds.client_secret.clone(),
creds.refresh_token.clone(),
);
let info = http.userinfo().await?;
let email = info.email.unwrap_or_else(|| "<unknown>".into());
http.probe_calendar_list().await?;
Ok(email)
}
fn store_secrets(creds: &GcalSecrets, scope: Scope<'_>) -> Result<Vec<SecretRef>> {
let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
secrets::store(&client_id_acct, &creds.client_id)?;
secrets::store(&client_secret_acct, &creds.client_secret)?;
secrets::store(&refresh_acct, &creds.refresh_token)?;
Ok(vec![
SecretRef {
label: "client id",
account: client_id_acct,
present: true,
},
SecretRef {
label: "client secret",
account: client_secret_acct,
present: true,
},
SecretRef {
label: "refresh token",
account: refresh_acct,
present: true,
},
])
}
fn delete_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
secrets::delete(&client_id_acct)?;
secrets::delete(&client_secret_acct)?;
secrets::delete(&refresh_acct)?;
Ok(vec![
SecretRef {
label: "client id",
account: client_id_acct,
present: false,
},
SecretRef {
label: "client secret",
account: client_secret_acct,
present: false,
},
SecretRef {
label: "refresh token",
account: refresh_acct,
present: false,
},
])
}
fn inspect_secrets(scope: Scope<'_>) -> Result<Vec<SecretRef>> {
let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
let client_id_present = secrets::load(&client_id_acct)?.is_some();
let client_secret_present = secrets::load(&client_secret_acct)?.is_some();
let refresh_present = secrets::load(&refresh_acct)?.is_some();
Ok(vec![
SecretRef {
label: "client id",
account: client_id_acct,
present: client_id_present,
},
SecretRef {
label: "client secret",
account: client_secret_acct,
present: client_secret_present,
},
SecretRef {
label: "refresh token",
account: refresh_acct,
present: refresh_present,
},
])
}
fn load_secrets(scope: Scope<'_>) -> Result<Option<GcalSecrets>> {
let client_id_acct = secrets::account(Self::NAME, "client-id", scope.clone());
let client_secret_acct = secrets::account(Self::NAME, "client-secret", scope.clone());
let refresh_acct = secrets::account(Self::NAME, "refresh", scope);
let (Some(id), Some(secret), Some(refresh)) = (
secrets::load(&client_id_acct)?,
secrets::load(&client_secret_acct)?,
secrets::load(&refresh_acct)?,
) else {
return Ok(None);
};
Ok(Some(GcalSecrets {
client_id: id,
client_secret: secret,
refresh_token: refresh,
}))
}
fn cfg_human(cfg: &GcalServiceCfg) -> Vec<(&'static str, String)> {
let mut out = vec![];
if let Some(c) = &cfg.default_calendar {
out.push(("calendar", c.clone()));
}
if let Some(e) = &cfg.self_email {
out.push(("email", e.clone()));
}
out
}
fn cfg_json(cfg: &GcalServiceCfg) -> serde_json::Value {
serde_json::json!({
"default_calendar": cfg.default_calendar,
"self_email": cfg.self_email,
})
}
fn scopes_of(cfg: &GcalServiceCfg) -> &[String] {
&cfg.scopes
}
fn post_create_hint(_cfg: &GcalServiceCfg) -> Option<String> {
None
}
}
#[async_trait]
impl CliLifecycle for GcalLifecycle {
type CreateArgs = CreateArgs;
async fn resolve(
args: &CreateArgs,
non_interactive: bool,
) -> Result<(GcalServiceCfg, GcalSecrets)> {
let open_browser = !args.base.no_browser;
let scopes = resolve_scopes(
args.scopes.scopes.as_deref(),
DEFAULT_SCOPES,
ALL_SCOPES,
non_interactive,
)?;
let client_id = resolve_client_id(
args.client_id.as_deref(),
args.client_id_env.as_deref(),
open_browser,
non_interactive,
)?;
let client_secret = resolve_client_secret(
args.client_secret.as_deref(),
args.client_secret_env.as_deref(),
non_interactive,
)?;
let refresh_token = if let Some(v) = args.refresh_token.clone() {
v
} else if let Some(env) = args.refresh_token_env.as_deref() {
std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()))?
} else {
resolve_refresh_via_loopback(
&client_id,
&client_secret,
&scopes,
open_browser,
non_interactive,
)
.await?
};
Ok((
GcalServiceCfg {
scopes,
default_calendar: args.default_calendar.clone(),
self_email: args.self_email.clone(),
},
GcalSecrets {
client_id,
client_secret,
refresh_token,
},
))
}
}
fn theme() -> ColorfulTheme {
ColorfulTheme::default()
}
fn resolve_client_id(
flag: Option<&str>,
env_flag: Option<&str>,
open_browser: bool,
non_interactive: bool,
) -> Result<String> {
if let Some(env) = env_flag {
return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
}
if let Some(v) = flag {
return Ok(v.to_string());
}
if non_interactive {
return Err(ZadError::MissingRequired("--client-id or --client-id-env"));
}
println!();
println!("Google Calendar uses OAuth 2.0. You need a Google Cloud OAuth client:");
println!(" 1. Open the Google Cloud Console credentials page:");
println!(" {GCP_CREDENTIALS_URL}");
println!(" 2. Create an OAuth client of type \"Desktop app\".");
println!(" 3. Enable the \"Google Calendar API\" under APIs & Services → Library.");
println!(" 4. Copy the Client ID and Client Secret back here.");
if open_browser {
let _ = open::that(GCP_CREDENTIALS_URL);
}
let v: String = Input::with_theme(&theme())
.with_prompt("Google OAuth Client ID")
.interact_text()
.into_zad()?;
Ok(v.trim().to_string())
}
fn resolve_client_secret(
flag: Option<&str>,
env_flag: Option<&str>,
non_interactive: bool,
) -> Result<String> {
if let Some(env) = env_flag {
return std::env::var(env).map_err(|_| ZadError::MissingEnv(env.to_string()));
}
if let Some(v) = flag {
return Ok(v.to_string());
}
if non_interactive {
return Err(ZadError::MissingRequired(
"--client-secret or --client-secret-env",
));
}
let v = Password::with_theme(&theme())
.with_prompt("Google OAuth Client Secret")
.interact()
.into_zad()?;
Ok(v)
}
async fn resolve_refresh_via_loopback(
client_id: &str,
client_secret: &str,
zad_scopes: &[String],
open_browser: bool,
non_interactive: bool,
) -> Result<String> {
if non_interactive {
return Err(ZadError::MissingRequired(
"--refresh-token or --refresh-token-env (non-interactive mode cannot open a browser)",
));
}
println!();
println!("No refresh token provided — starting the browser OAuth flow.");
println!(
"Make sure the OAuth client you created in Google Cloud Console is of type \"Desktop app\"."
);
let want = Confirm::with_theme(&theme())
.with_prompt("Continue with the browser flow?")
.default(true)
.interact()
.into_zad()?;
if !want {
return Err(ZadError::Invalid(
"browser OAuth flow declined by operator; pass --refresh-token to skip it".into(),
));
}
let google_scopes = google_scopes_for(zad_scopes);
let cfg = LoopbackConfig {
service_name: "gcal",
display_name: "Google Calendar",
auth_url: AUTH_URL.to_string(),
token_url: TOKEN_URL.to_string(),
client_id: client_id.to_string(),
client_secret: Some(client_secret.to_string()),
scopes: google_scopes,
extra_auth_params: vec![
("access_type".into(), "offline".into()),
("prompt".into(), "consent".into()),
("include_granted_scopes".into(), "true".into()),
],
timeout: LOOPBACK_TIMEOUT,
redirect_scheme: RedirectScheme::Http,
};
let tokens = run_loopback_flow(&cfg, open_browser).await?;
tokens.refresh_token.ok_or_else(|| ZadError::Service {
name: "gcal",
message: "Google did not return a refresh token. Check that the consent screen \
granted access and that the OAuth client is type 'Desktop app'. \
Re-run `zad service create gcal` to retry."
.into(),
})
}
pub fn google_scopes_for(zad_scopes: &[String]) -> Vec<String> {
let mut out: Vec<String> = vec!["openid".into(), "email".into()];
let has = |s: &str| zad_scopes.iter().any(|z| z == s);
if has("events.write")
|| has("events.invite")
|| has("events.remind")
|| (!has("calendars.read") && !has("events.read"))
{
out.push("https://www.googleapis.com/auth/calendar.events".into());
} else if has("events.read") {
out.push("https://www.googleapis.com/auth/calendar.events.readonly".into());
}
if has("calendars.read") && !has("events.write") {
out.push("https://www.googleapis.com/auth/calendar.calendarlist.readonly".into());
}
out.sort();
out.dedup();
out
}