From 5e29ec059231be3b694b5b7e963b1af57fa63232 Mon Sep 17 00:00:00 2001 From: mkh09353 <6936686+mkh09353@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:54:18 -0500 Subject: [PATCH 1/2] Improve setup project creation error recovery --- .../improve-tos-guidance-project-create.md | 8 + src/setup.rs | 233 +++++++++++++++--- 2 files changed, 207 insertions(+), 34 deletions(-) create mode 100644 .changeset/improve-tos-guidance-project-create.md diff --git a/.changeset/improve-tos-guidance-project-create.md b/.changeset/improve-tos-guidance-project-create.md new file mode 100644 index 0000000..66d0759 --- /dev/null +++ b/.changeset/improve-tos-guidance-project-create.md @@ -0,0 +1,8 @@ +--- +"@googleworkspace/cli": patch +--- + +Improve `gws auth setup` project creation failures in step 3: +- Detect Google Cloud Terms of Service precondition failures and show actionable guidance (`gcloud auth list`, account verification, Console ToS URL). +- Detect invalid project ID format / already-in-use errors and show clearer guidance. +- In interactive setup, keep the wizard open and re-prompt for a new project ID instead of exiting immediately on create failures. diff --git a/src/setup.rs b/src/setup.rs index a2730ec..841764c 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -627,6 +627,90 @@ fn get_access_token() -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +fn is_tos_precondition_error(gcloud_output: &str) -> bool { + let lower = gcloud_output.to_ascii_lowercase(); + lower.contains("callers must accept terms of service") + || (lower.contains("terms of service") && lower.contains("type: tos")) + || (lower.contains("failed_precondition") && lower.contains("type: tos")) +} + +fn is_invalid_project_id_error(gcloud_output: &str) -> bool { + let lower = gcloud_output.to_ascii_lowercase(); + lower.contains("argument project_id: bad value") + || lower.contains("project ids are immutable") + || lower.contains("project ids must be between 6 and 30 characters") +} + +fn is_project_id_in_use_error(gcloud_output: &str) -> bool { + let lower = gcloud_output.to_ascii_lowercase(); + lower.contains("already in use") + || lower.contains("already exists") + || lower.contains("already being used") +} + +fn primary_gcloud_error_line(gcloud_output: &str) -> Option { + gcloud_output + .lines() + .map(str::trim) + .find(|line| line.starts_with("ERROR:")) + .map(ToString::to_string) +} + +fn format_project_create_failure(project_id: &str, account: &str, gcloud_output: &str) -> String { + if is_tos_precondition_error(gcloud_output) { + let mut msg = format!( + concat!( + "Failed to create project '{project_id}' because the active gcloud account has not accepted Google Cloud Terms of Service.\n\n", + "Fix:\n", + "1. Verify the active account: `gcloud auth list` and `gcloud config get-value account`\n", + "2. Sign in to https://console.cloud.google.com/ with that same account and accept Terms of Service.\n", + "3. Retry `gws auth setup` (or `gcloud projects create {project_id}`).\n\n", + "If this is a Google Workspace-managed account, an org admin may need to enable Google Cloud for the domain first." + ), + project_id = project_id + ); + if !account.trim().is_empty() { + msg.push_str(&format!("\n\nActive account in this setup run: {account}")); + } + return msg; + } + + if is_invalid_project_id_error(gcloud_output) { + return format!( + concat!( + "Failed to create project '{project_id}' because the project ID format is invalid.\n\n", + "Project IDs must:\n", + "- be 6 to 30 characters\n", + "- start with a lowercase letter\n", + "- use only lowercase letters, digits, or hyphens\n\n", + "Enter a new project ID and retry." + ), + project_id = project_id + ); + } + + if is_project_id_in_use_error(gcloud_output) { + return format!( + "Failed to create project '{project_id}' because the ID is already in use. Enter a different unique project ID and retry." + ); + } + + if let Some(primary) = primary_gcloud_error_line(gcloud_output) { + return format!( + "Failed to create project '{project_id}'.\n\n{primary}\n\nEnter a different project ID and retry." + ); + } + + let details = gcloud_output.trim(); + if details.is_empty() { + return format!( + "Failed to create project '{project_id}'. Enter a different project ID and retry." + ); + } + + format!("Failed to create project '{project_id}'.\n\ngcloud error:\n{details}") +} + // ── API enabling ──────────────────────────────────────────────── /// Enable selected Workspace APIs for a project. @@ -1047,43 +1131,83 @@ fn stage_project(ctx: &mut SetupContext) -> Result { let chosen = items.iter().find(|i| i.selected); match chosen { Some(item) if item.label.starts_with('➕') => { - let project_name = match ctx - .wizard - .as_mut() - .unwrap() - .show_input("Create new GCP project", "Enter a unique project ID", None) - .map_err(|e| GwsError::Validation(format!("TUI error: {e}")))? - { - crate::setup_tui::InputResult::Confirmed(v) if !v.is_empty() => v, - _ => { - return Err(GwsError::Validation( - "Project creation cancelled by user".to_string(), - )) + let mut last_attempt: Option = None; + loop { + let project_name = match ctx + .wizard + .as_mut() + .unwrap() + .show_input( + "Create new GCP project", + "Enter a unique project ID", + last_attempt.as_deref(), + ) + .map_err(|e| GwsError::Validation(format!("TUI error: {e}")))? + { + crate::setup_tui::InputResult::Confirmed(v) => { + let trimmed = v.trim().to_string(); + if trimmed.is_empty() { + if let Some(ref mut w) = ctx.wizard { + w.show_message("Project ID cannot be empty. Enter a valid ID, press ↑ to go back, or Esc to cancel.") + .ok(); + } + continue; + } + trimmed + } + crate::setup_tui::InputResult::GoBack => { + return Ok(SetupStage::Project); + } + crate::setup_tui::InputResult::Cancelled => { + ctx.finish_wizard(); + return Err(GwsError::Validation( + "Setup cancelled".to_string(), + )); + } + }; + + ctx.wizard + .as_mut() + .unwrap() + .show_message(&format!("Creating project '{}'...", project_name)) + .ok(); + + let output = gcloud_cmd() + .args(["projects", "create", &project_name]) + .output() + .map_err(|e| { + GwsError::Validation(format!("Failed to create project: {e}")) + })?; + if output.status.success() { + set_gcloud_project(&project_name)?; + ctx.wiz(2, StepStatus::Done(project_name.clone())); + ctx.project_id = project_name; + break Ok(SetupStage::EnableApis); } - }; - ctx.wizard - .as_mut() - .unwrap() - .show_message(&format!("Creating project '{}'...", project_name)) - .ok(); - - let status = gcloud_cmd() - .args(["projects", "create", &project_name]) - .status() - .map_err(|e| { - GwsError::Validation(format!("Failed to create project: {e}")) - })?; - if !status.success() { - return Err(GwsError::Validation(format!( - "Failed to create project '{}'. Check the ID is valid and unique.", - project_name - ))); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let mut combined = stderr.trim().to_string(); + if !stdout.trim().is_empty() { + if !combined.is_empty() { + combined.push('\n'); + } + combined.push_str(stdout.trim()); + } + + let message = format_project_create_failure( + &project_name, + &ctx.account, + &combined, + ); + if let Some(ref mut w) = ctx.wizard { + w.show_message(&format!( + "{message}\n\nTry another project ID, press ↑ to return to project selection, or Esc to cancel." + )) + .ok(); + } + last_attempt = Some(project_name); } - set_gcloud_project(&project_name)?; - ctx.wiz(2, StepStatus::Done(project_name.clone())); - ctx.project_id = project_name; - Ok(SetupStage::EnableApis) } Some(item) if item.label.starts_with('⌨') => { let project_id = match ctx @@ -1678,6 +1802,47 @@ mod tests { assert_eq!(opts.project.as_deref(), Some("p")); } + #[test] + fn test_format_project_create_failure_tos_guidance() { + let msg = format_project_create_failure( + "example-project-123456", + "user@example.com", + "Operation failed: 9: Callers must accept Terms of Service\n type: TOS", + ); + + assert!(msg.contains("has not accepted Google Cloud Terms of Service")); + assert!(msg.contains("gcloud auth list")); + assert!(msg.contains("gcloud config get-value account")); + assert!(msg.contains("https://console.cloud.google.com/")); + assert!(msg.contains("user@example.com")); + } + + #[test] + fn test_format_project_create_failure_invalid_id_guidance() { + let msg = format_project_create_failure( + "example-project-123456", + "", + "ERROR: (gcloud.projects.create) argument PROJECT_ID: Bad value [bad]: Project IDs must be between 6 and 30 characters.", + ); + + assert!(msg.contains("project ID format is invalid")); + assert!(msg.contains("be 6 to 30 characters")); + assert!(msg.contains("start with a lowercase letter")); + assert!(msg.contains("lowercase letters, digits, or hyphens")); + } + + #[test] + fn test_format_project_create_failure_in_use_guidance() { + let msg = format_project_create_failure( + "example-project-123456", + "", + "Project ID already in use", + ); + + assert!(msg.contains("ID is already in use")); + assert!(msg.contains("different unique project ID")); + } + // ── Account selection → gcloud action ─────────────────────── #[test] From 0b53fa6b32b28761abf519c9bde2b940d09cb037 Mon Sep 17 00:00:00 2001 From: mkh09353 <6936686+mkh09353@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:04:42 -0500 Subject: [PATCH 2/2] Add post-setup login continuation flow --- src/auth_commands.rs | 10 +++- src/setup.rs | 118 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 47d2d4e..70743b2 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -141,6 +141,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { " (e.g. -s drive,gmail,sheets)\n", " setup Configure GCP project + OAuth client (requires gcloud)\n", " --project Use a specific GCP project\n", + " --login Run `gws auth login` after successful setup\n", " status Show current authentication state\n", " export Print decrypted credentials to stdout\n", " logout Clear saved credentials and token cache", @@ -153,7 +154,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { } match args[0].as_str() { - "login" => handle_login(&args[1..]).await, + "login" => run_login(&args[1..]).await, "setup" => crate::setup::run_setup(&args[1..]).await, "status" => handle_status().await, "export" => { @@ -166,6 +167,13 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { ))), } } + +/// Run the `auth login` flow. +/// +/// Exposed for internal orchestration (e.g. `auth setup --login`). +pub async fn run_login(args: &[String]) -> Result<(), GwsError> { + handle_login(args).await +} /// Custom delegate that prints the OAuth URL on its own line for easy copying. /// Optionally includes `login_hint` in the URL for account pre-selection. struct CliFlowDelegate { diff --git a/src/setup.rs b/src/setup.rs index 841764c..10aebe1 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -373,12 +373,14 @@ pub async fn fetch_scopes_for_apis(enabled_api_ids: &[String]) -> Vec, pub dry_run: bool, + pub login: bool, } /// Parse setup flags from args. pub fn parse_setup_args(args: &[String]) -> SetupOptions { let mut project = None; let mut dry_run = false; + let mut login = false; let mut i = 0; while i < args.len() { if args[i] == "--project" && i + 1 < args.len() { @@ -390,11 +392,18 @@ pub fn parse_setup_args(args: &[String]) -> SetupOptions { } else if args[i] == "--dry-run" { dry_run = true; i += 1; + } else if args[i] == "--login" { + login = true; + i += 1; } else { i += 1; } } - SetupOptions { project, dry_run } + SetupOptions { + project, + dry_run, + login, + } } // ── gcloud helpers ────────────────────────────────────────────── @@ -637,7 +646,6 @@ fn is_tos_precondition_error(gcloud_output: &str) -> bool { fn is_invalid_project_id_error(gcloud_output: &str) -> bool { let lower = gcloud_output.to_ascii_lowercase(); lower.contains("argument project_id: bad value") - || lower.contains("project ids are immutable") || lower.contains("project ids must be between 6 and 30 characters") } @@ -646,6 +654,7 @@ fn is_project_id_in_use_error(gcloud_output: &str) -> bool { lower.contains("already in use") || lower.contains("already exists") || lower.contains("already being used") + || lower.contains("project ids are immutable") } fn primary_gcloud_error_line(gcloud_output: &str) -> Option { @@ -1555,6 +1564,38 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result bool { + interactive && !dry_run && !login_requested && stdout_is_terminal +} + +fn prompt_login_after_setup() -> Result { + use std::io::Write; + + let mut input = String::new(); + loop { + eprint!("Run `gws auth login` now? [Y/n]: "); + std::io::stderr() + .flush() + .map_err(|e| GwsError::Validation(format!("Failed to flush prompt: {e}")))?; + + input.clear(); + std::io::stdin() + .read_line(&mut input) + .map_err(|e| GwsError::Validation(format!("Failed to read prompt input: {e}")))?; + + match input.trim().to_ascii_lowercase().as_str() { + "" | "y" | "yes" => return Ok(true), + "n" | "no" => return Ok(false), + _ => eprintln!("Please answer 'y' or 'n'."), + } + } +} + /// Run the full setup flow. Orchestrates all steps and outputs JSON summary. pub async fn run_setup(args: &[String]) -> Result<(), GwsError> { let opts = parse_setup_args(args); @@ -1604,9 +1645,28 @@ pub async fn run_setup(args: &[String]) -> Result<(), GwsError> { ctx.finish_wizard(); + let run_login = if ctx.opts.login { + true + } else if should_offer_login_prompt( + ctx.interactive, + ctx.dry_run, + ctx.opts.login, + std::io::IsTerminal::is_terminal(&std::io::stdout()), + ) { + prompt_login_after_setup()? + } else { + false + }; + + let message = if run_login { + "Setup complete! Starting `gws auth login`..." + } else { + "Setup complete! Run `gws auth login` to authenticate." + }; + let output = json!({ "status": "success", - "message": "Setup complete! Run `gws auth login` to authenticate.", + "message": message, "account": ctx.account, "project": ctx.project_id, "apis_enabled": ctx.enabled.len(), @@ -1619,7 +1679,11 @@ pub async fn run_setup(args: &[String]) -> Result<(), GwsError> { serde_json::to_string_pretty(&output).unwrap_or_default() ); - eprintln!("\n✅ Setup complete! Run `gws auth login` to authenticate."); + eprintln!("\n✅ {message}"); + + if run_login { + crate::auth_commands::run_login(&[]).await?; + } Ok(()) } @@ -1764,6 +1828,7 @@ mod tests { let opts = parse_setup_args(&[]); assert!(opts.project.is_none()); assert!(!opts.dry_run); + assert!(!opts.login); } #[test] @@ -1771,6 +1836,7 @@ mod tests { let args = vec!["--project".into(), "my-project".into()]; let opts = parse_setup_args(&args); assert_eq!(opts.project.as_deref(), Some("my-project")); + assert!(!opts.login); } #[test] @@ -1778,6 +1844,7 @@ mod tests { let args = vec!["--project=my-project".into()]; let opts = parse_setup_args(&args); assert_eq!(opts.project.as_deref(), Some("my-project")); + assert!(!opts.login); } #[test] @@ -1785,6 +1852,7 @@ mod tests { let args = vec!["--verbose".into(), "--unknown".into()]; let opts = parse_setup_args(&args); assert!(opts.project.is_none()); + assert!(!opts.login); } #[test] @@ -1792,6 +1860,7 @@ mod tests { let args = vec!["--dry-run".into()]; let opts = parse_setup_args(&args); assert!(opts.dry_run); + assert!(!opts.login); } #[test] @@ -1800,6 +1869,36 @@ mod tests { let opts = parse_setup_args(&args); assert!(opts.dry_run); assert_eq!(opts.project.as_deref(), Some("p")); + assert!(!opts.login); + } + + #[test] + fn test_parse_setup_args_login_flag() { + let args: Vec = vec!["--login".into()]; + let opts = parse_setup_args(&args); + assert!(opts.login); + assert!(!opts.dry_run); + assert!(opts.project.is_none()); + } + + #[test] + fn test_should_offer_login_prompt_default_interactive() { + assert!(should_offer_login_prompt(true, false, false, true)); + } + + #[test] + fn test_should_not_offer_login_prompt_when_login_requested() { + assert!(!should_offer_login_prompt(true, false, true, true)); + } + + #[test] + fn test_should_not_offer_login_prompt_non_interactive() { + assert!(!should_offer_login_prompt(false, false, false, true)); + } + + #[test] + fn test_should_not_offer_login_prompt_dry_run() { + assert!(!should_offer_login_prompt(true, true, false, true)); } #[test] @@ -1843,6 +1942,17 @@ mod tests { assert!(msg.contains("different unique project ID")); } + #[test] + fn test_format_project_create_failure_immutable_guidance() { + let msg = format_project_create_failure( + "example-project-123456", + "", + "Project IDs are immutable and can be set only during project creation.", + ); + + assert!(msg.contains("ID is already in use")); + } + // ── Account selection → gcloud action ─────────────────────── #[test]