diff --git a/.changeset/fix-529-deselect-apis.md b/.changeset/fix-529-deselect-apis.md new file mode 100644 index 00000000..7a6fe4ce --- /dev/null +++ b/.changeset/fix-529-deselect-apis.md @@ -0,0 +1,10 @@ +--- +"@googleworkspace/cli": patch +--- + +fix(setup): allow deselecting previously enabled APIs in `gws auth setup` + +The API picker in `gws auth setup` marked already-enabled APIs as fixed +(non-toggleable), preventing users from reducing their enabled API set. +Now previously enabled APIs are pre-selected but can be deselected. +Deselected APIs are disabled via `gcloud services disable`. diff --git a/src/setup.rs b/src/setup.rs index e11957b5..b34353ff 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -798,6 +798,65 @@ async fn enable_apis( (enabled, skipped, failed) } +/// Disable deselected Workspace APIs for a project. +/// Returns (disabled, failed) where failed includes the gcloud error message. +async fn disable_apis( + project_id: &str, + api_ids: &[String], +) -> (Vec, Vec<(String, String)>) { + if api_ids.is_empty() { + return (Vec::new(), Vec::new()); + } + + use futures_util::stream::StreamExt; + + let results = futures_util::stream::iter(api_ids.to_vec()) + .map(|api_id| { + let project_id = project_id.to_string(); + async move { + let result = tokio::process::Command::new(gcloud_bin()) + .env("CLOUDSDK_CORE_DISABLE_PROMPTS", "1") + .args(["services", "disable", &api_id, "--project", &project_id]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .output() + .await; + (api_id, result) + } + }) + .buffer_unordered(5) + .collect::>() + .await; + + let mut disabled = Vec::new(); + let mut failed = Vec::new(); + + for (api_id, result) in results { + match result { + Ok(output) if output.status.success() => { + disabled.push(api_id); + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let msg = if stderr.is_empty() { + format!( + "gcloud services disable failed (exit code {:?})", + output.status.code() + ) + } else { + stderr + }; + failed.push((api_id, msg)); + } + Err(e) => { + failed.push((api_id, format!("Failed to run gcloud: {e}"))); + } + } + } + + (disabled, failed) +} + /// Get the list of already-enabled API service names for a project. pub fn get_enabled_apis(project_id: &str) -> Vec { let output = gcloud_cmd() @@ -924,6 +983,7 @@ struct SetupContext { client_id: String, client_secret: String, enabled: Vec, + disabled: Vec, skipped: Vec, failed: Vec<(String, String)>, } @@ -1297,7 +1357,7 @@ async fn stage_enable_apis(ctx: &mut SetupContext) -> Result Result; match result { PickerResult::Confirmed(items) => { ctx.api_ids = items @@ -1324,6 +1385,12 @@ async fn stage_enable_apis(ctx: &mut SetupContext) -> Result>(); + // APIs that were enabled but the user deselected + apis_to_disable = already_enabled + .iter() + .filter(|id| !ctx.api_ids.contains(id)) + .cloned() + .collect(); } PickerResult::GoBack => { return Ok(SetupStage::Project); @@ -1333,41 +1400,92 @@ async fn stage_enable_apis(ctx: &mut SetupContext) -> Result Result Result<(), GwsError> { client_id: String::new(), client_secret: String::new(), enabled: Vec::new(), + disabled: Vec::new(), skipped: Vec::new(), failed: Vec::new(), }; @@ -2244,6 +2367,40 @@ mod tests { } } + #[test] + fn test_pipeline_previously_enabled_can_be_deselected() { + // Simulate: first two APIs are already enabled (pre-selected, but NOT fixed) + let items: Vec = WORKSPACE_APIS + .iter() + .enumerate() + .map(|(i, a)| { + let already = i < 2; + SelectItem { + label: a.name.to_string(), + description: if already { + format!("{} (already enabled)", a.id) + } else { + a.id.to_string() + }, + selected: already, + is_fixed: false, // The fix: previously this was `already` + is_template: false, + template_selects: vec![], + } + }) + .collect(); + // Press space to deselect first item, then Enter to confirm + let result = simulate_picker(items, &[KeyCode::Char(' '), KeyCode::Enter], true); + match resolve_api_selection(&result) { + SetupAction::EnableApis(ids) => { + // First API should be deselected, second should remain + assert_eq!(ids.len(), 1); + assert_eq!(ids[0], WORKSPACE_APIS[1].id); + } + _ => panic!("Expected EnableApis"), + } + } + // ── enable_apis unit tests ────────────────────────────────── #[tokio::test] @@ -2271,6 +2428,25 @@ mod tests { assert!(!failed[0].1.is_empty(), "Error message should not be empty"); } + // ── disable_apis unit tests ───────────────────────────────── + + #[tokio::test] + async fn test_disable_apis_with_no_apis() { + let (disabled, failed) = disable_apis("__nonexistent__", &[]).await; + assert!(disabled.is_empty()); + assert!(failed.is_empty()); + } + + #[tokio::test] + async fn test_disable_apis_with_invalid_project() { + let apis = vec!["storage.googleapis.com".to_string()]; + let (disabled, failed) = disable_apis("__nonexistent_project_99999__", &apis).await; + assert!(disabled.is_empty()); + assert_eq!(failed.len(), 1); + assert_eq!(failed[0].0, "storage.googleapis.com"); + assert!(!failed[0].1.is_empty(), "Error message should not be empty"); + } + #[test] fn test_failed_apis_json_structure() { // Verify the JSON output structure for failed APIs includes