Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion crates/cdcx-cli/src/cli_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
90 changes: 90 additions & 0 deletions crates/cdcx-cli/src/groups/auth_login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<PollResponse>().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,
Expand Down
84 changes: 84 additions & 0 deletions crates/cdcx-cli/src/groups/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
14 changes: 12 additions & 2 deletions crates/cdcx-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading