fix: Improve auth setup project-creation error handling and retry UX (#95)

* Improve setup project creation error recovery

* Add post-setup login continuation flow

---------

Co-authored-by: mkh09353 <6936686+mkh09353@users.noreply.github.com>
This commit is contained in:
Max Headley
2026-03-09 13:30:31 -04:00
committed by GitHub
parent 6c266eb524
commit f083eb9af0
3 changed files with 328 additions and 37 deletions
@@ -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.
+9 -1
View File
@@ -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 {
+311 -36
View File
@@ -373,12 +373,14 @@ pub async fn fetch_scopes_for_apis(enabled_api_ids: &[String]) -> Vec<Discovered
pub struct SetupOptions {
pub project: Option<String>,
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 ──────────────────────────────────────────────
@@ -627,6 +636,90 @@ fn get_access_token() -> Result<String, GwsError> {
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 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")
|| lower.contains("project ids are immutable")
}
fn primary_gcloud_error_line(gcloud_output: &str) -> Option<String> {
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 +1140,83 @@ fn stage_project(ctx: &mut SetupContext) -> Result<SetupStage, GwsError> {
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<String> = 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 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 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 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
@@ -1431,6 +1564,38 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result<SetupStage, Gws
Ok(SetupStage::Finish)
}
fn should_offer_login_prompt(
interactive: bool,
dry_run: bool,
login_requested: bool,
stdout_is_terminal: bool,
) -> bool {
interactive && !dry_run && !login_requested && stdout_is_terminal
}
fn prompt_login_after_setup() -> Result<bool, GwsError> {
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);
@@ -1480,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(),
@@ -1495,7 +1679,11 @@ pub async fn run_setup(args: &[String]) -> Result<(), GwsError> {
serde_json::to_string_pretty(&output).unwrap_or_default()
);
eprintln!("\nSetup complete! Run `gws auth login` to authenticate.");
eprintln!("\n{message}");
if run_login {
crate::auth_commands::run_login(&[]).await?;
}
Ok(())
}
@@ -1640,6 +1828,7 @@ mod tests {
let opts = parse_setup_args(&[]);
assert!(opts.project.is_none());
assert!(!opts.dry_run);
assert!(!opts.login);
}
#[test]
@@ -1647,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]
@@ -1654,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]
@@ -1661,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]
@@ -1668,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]
@@ -1676,6 +1869,88 @@ 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<String> = 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]
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"));
}
#[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 ───────────────────────