From f23c723ba9d142d727d7b054dde7bcc3e42bcade Mon Sep 17 00:00:00 2001 From: Troy Date: Wed, 10 Jun 2026 15:07:59 +0800 Subject: [PATCH] feat(auth): add non-interactive `--oauth` flag to `auth login` Enables headless OAuth login for background/agent contexts by skipping raw terminal mode and ESC key detection. Supports optional `--env` and `--profile` flags (defaulting to production and default profile). Co-Authored-By: Claude Opus 4.6 --- crates/cdcx-cli/src/cli_builder.rs | 11 ++- crates/cdcx-cli/src/groups/auth_login.rs | 90 ++++++++++++++++++++++++ crates/cdcx-cli/src/groups/setup.rs | 84 ++++++++++++++++++++++ crates/cdcx-cli/src/main.rs | 14 +++- 4 files changed, 196 insertions(+), 3 deletions(-) diff --git a/crates/cdcx-cli/src/cli_builder.rs b/crates/cdcx-cli/src/cli_builder.rs index 16cd7e0..d406f4a 100644 --- a/crates/cdcx-cli/src/cli_builder.rs +++ b/crates/cdcx-cli/src/cli_builder.rs @@ -152,7 +152,16 @@ pub fn build_static_cli() -> clap::Command { .subcommand_required(true) .arg_required_else_help(true) .subcommand(clap::Command::new("list").about("List all profiles with account balances")) - .subcommand(clap::Command::new("login").about("Log in via browser-based OAuth")), + .subcommand( + clap::Command::new("login") + .about("Log in via browser-based OAuth") + .arg( + clap::Arg::new("oauth") + .long("oauth") + .help("Non-interactive login using production environment and default profile") + .action(clap::ArgAction::SetTrue), + ), + ), ); app = app.subcommand( clap::Command::new("mcp") diff --git a/crates/cdcx-cli/src/groups/auth_login.rs b/crates/cdcx-cli/src/groups/auth_login.rs index 8695009..7388a28 100644 --- a/crates/cdcx-cli/src/groups/auth_login.rs +++ b/crates/cdcx-cli/src/groups/auth_login.rs @@ -84,6 +84,96 @@ pub async fn run_auth_login() -> Result<(), CdcxError> { super::setup::run_setup().await } +/// Non-interactive OAuth login. Defaults to production environment and default profile. +/// Skips raw mode and ESC detection so it can run in background/agent contexts. +pub async fn run_auth_login_oauth( + env: Option<&str>, + profile: Option<&str>, +) -> Result<(), CdcxError> { + let environment = env.unwrap_or("production"); + let session_id = Uuid::new_v4().to_string(); + + let url = oauth_url(environment, &session_id); + println!(); + println!(" Opening browser for authentication..."); + println!(" {}", url); + + if let Err(_) = open::that(&url) { + println!(); + println!(" Could not open browser automatically."); + println!(" Please open the URL above manually."); + } + + println!(); + println!(" Waiting for authentication..."); + + let (api_key, secret_key) = poll_for_credentials_headless(environment, &session_id).await?; + + super::setup::save_profile(&api_key, &secret_key, environment, profile)?; + + let profile_label = profile.unwrap_or("default"); + println!(); + println!( + " ✓ Logged in successfully ({}, {} profile)", + environment, profile_label + ); + println!(" Test with: cdcx account summary"); + println!(); + + Ok(()) +} + +/// Poll without raw mode or terminal interaction — safe for non-interactive contexts. +async fn poll_for_credentials_headless( + environment: &str, + session_id: &str, +) -> Result<(String, String), CdcxError> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .no_proxy() + .pool_max_idle_per_host(0) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let base_url = poll_url(environment); + let start = Instant::now(); + + loop { + if start.elapsed() > POLL_TIMEOUT { + return Err(CdcxError::Config( + "Authentication timed out after 5 minutes".into(), + )); + } + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + let url = format!("{}?ts={}", base_url, ts); + let body = serde_json::json!({ "session": session_id, "ts": ts }); + + if let Ok(r) = client + .post(&url) + .json(&body) + .header("Cache-Control", "no-cache, no-store") + .header("Pragma", "no-cache") + .send() + .await + { + if r.status() == reqwest::StatusCode::OK { + if let Ok(poll_resp) = r.json::().await { + if poll_resp.code == 0 { + if let Some(result) = poll_resp.result { + return Ok((result.api_key, result.secret_key)); + } + } + } + } + } + + tokio::time::sleep(POLL_INTERVAL).await; + } +} + async fn poll_for_credentials( environment: &str, session_id: &str, diff --git a/crates/cdcx-cli/src/groups/setup.rs b/crates/cdcx-cli/src/groups/setup.rs index 656a8f0..f5c903d 100644 --- a/crates/cdcx-cli/src/groups/setup.rs +++ b/crates/cdcx-cli/src/groups/setup.rs @@ -64,6 +64,90 @@ fn prompt_choice(label: &str, options: &[&str], default: usize) -> usize { .min(options.len() - 1) } +/// Save credentials to a profile (non-interactive). +/// If `profile_name` is None, saves to the default profile. +pub fn save_profile( + api_key: &str, + api_secret: &str, + environment: &str, + profile_name: Option<&str>, +) -> Result<(), CdcxError> { + let path = config_path(); + + let existing = if path.exists() { + let content = std::fs::read_to_string(&path) + .map_err(|e| CdcxError::Config(format!("Failed to read config: {}", e)))?; + Some(Config::parse(&content)?) + } else { + None + }; + + let new_profile = ProfileConfig { + api_key: api_key.to_string(), + api_secret: api_secret.to_string(), + environment: environment.to_string(), + envs: HashMap::new(), + }; + + let config_file = if let Some(name) = profile_name { + let mut cf = if let Some(ref existing_cfg) = existing { + ConfigFile { + default: existing_cfg.default.clone(), + profiles: existing_cfg.profiles.clone(), + } + } else { + ConfigFile { + default: None, + profiles: None, + } + }; + if cf.profiles.is_none() { + cf.profiles = Some(HashMap::new()); + } + cf.profiles + .as_mut() + .unwrap() + .insert(name.to_string(), new_profile); + cf + } else if let Some(ref existing_cfg) = existing { + ConfigFile { + default: Some(new_profile), + profiles: existing_cfg.profiles.clone(), + } + } else { + ConfigFile { + default: Some(new_profile), + profiles: None, + } + }; + + let body = toml::to_string_pretty(&config_file) + .map_err(|e| CdcxError::Config(format!("Failed to serialize config: {}", e)))?; + let schema_url = cdcx_core::github::raw("main", "schemas/configs/config.json"); + let toml_content = format!("#:schema {}\n\n{}", schema_url, body); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| CdcxError::Config(format!("Failed to create config directory: {}", e)))?; + config::set_dir_owner_only(parent) + .map_err(|e| CdcxError::Config(format!("Failed to secure config directory: {}", e)))?; + } + + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&path) + .map_err(|e| CdcxError::Config(format!("Failed to open config file: {}", e)))?; + file.write_all(toml_content.as_bytes()) + .map_err(|e| CdcxError::Config(format!("Failed to write config: {}", e)))?; + drop(file); + config::set_file_owner_only(&path) + .map_err(|e| CdcxError::Config(format!("Failed to secure config file: {}", e)))?; + + Ok(()) +} + pub async fn run_setup() -> Result<(), CdcxError> { println!(); println!(" cdcx setup"); diff --git a/crates/cdcx-cli/src/main.rs b/crates/cdcx-cli/src/main.rs index 48dfbb9..75a1369 100644 --- a/crates/cdcx-cli/src/main.rs +++ b/crates/cdcx-cli/src/main.rs @@ -194,8 +194,18 @@ async fn main() { std::process::exit(1); } } - Some(("login", _)) => { - if let Err(e) = groups::auth_login::run_auth_login().await { + Some(("login", login_sub)) => { + let oauth = login_sub.get_flag("oauth"); + let result = if oauth { + groups::auth_login::run_auth_login_oauth( + global.env.as_deref(), + global.profile.as_deref(), + ) + .await + } else { + groups::auth_login::run_auth_login().await + }; + if let Err(e) = result { eprintln!("{}", format_error(&e.to_envelope(), format)); std::process::exit(1); }