Skip to content

Commit ea5ed6b

Browse files
committed
Improve setup project creation error recovery
1 parent 99a6284 commit ea5ed6b

2 files changed

Lines changed: 207 additions & 34 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@googleworkspace/cli": patch
3+
---
4+
5+
Improve `gws auth setup` project creation failures in step 3:
6+
- Detect Google Cloud Terms of Service precondition failures and show actionable guidance (`gcloud auth list`, account verification, Console ToS URL).
7+
- Detect invalid project ID format / already-in-use errors and show clearer guidance.
8+
- In interactive setup, keep the wizard open and re-prompt for a new project ID instead of exiting immediately on create failures.

src/setup.rs

Lines changed: 199 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,90 @@ fn get_access_token() -> Result<String, GwsError> {
613613
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
614614
}
615615

616+
fn is_tos_precondition_error(gcloud_output: &str) -> bool {
617+
let lower = gcloud_output.to_ascii_lowercase();
618+
lower.contains("callers must accept terms of service")
619+
|| (lower.contains("terms of service") && lower.contains("type: tos"))
620+
|| (lower.contains("failed_precondition") && lower.contains("type: tos"))
621+
}
622+
623+
fn is_invalid_project_id_error(gcloud_output: &str) -> bool {
624+
let lower = gcloud_output.to_ascii_lowercase();
625+
lower.contains("argument project_id: bad value")
626+
|| lower.contains("project ids are immutable")
627+
|| lower.contains("project ids must be between 6 and 30 characters")
628+
}
629+
630+
fn is_project_id_in_use_error(gcloud_output: &str) -> bool {
631+
let lower = gcloud_output.to_ascii_lowercase();
632+
lower.contains("already in use")
633+
|| lower.contains("already exists")
634+
|| lower.contains("already being used")
635+
}
636+
637+
fn primary_gcloud_error_line(gcloud_output: &str) -> Option<String> {
638+
gcloud_output
639+
.lines()
640+
.map(str::trim)
641+
.find(|line| line.starts_with("ERROR:"))
642+
.map(ToString::to_string)
643+
}
644+
645+
fn format_project_create_failure(project_id: &str, account: &str, gcloud_output: &str) -> String {
646+
if is_tos_precondition_error(gcloud_output) {
647+
let mut msg = format!(
648+
concat!(
649+
"Failed to create project '{project_id}' because the active gcloud account has not accepted Google Cloud Terms of Service.\n\n",
650+
"Fix:\n",
651+
"1. Verify the active account: `gcloud auth list` and `gcloud config get-value account`\n",
652+
"2. Sign in to https://console.cloud.google.com/ with that same account and accept Terms of Service.\n",
653+
"3. Retry `gws auth setup` (or `gcloud projects create {project_id}`).\n\n",
654+
"If this is a Google Workspace-managed account, an org admin may need to enable Google Cloud for the domain first."
655+
),
656+
project_id = project_id
657+
);
658+
if !account.trim().is_empty() {
659+
msg.push_str(&format!("\n\nActive account in this setup run: {account}"));
660+
}
661+
return msg;
662+
}
663+
664+
if is_invalid_project_id_error(gcloud_output) {
665+
return format!(
666+
concat!(
667+
"Failed to create project '{project_id}' because the project ID format is invalid.\n\n",
668+
"Project IDs must:\n",
669+
"- be 6 to 30 characters\n",
670+
"- start with a lowercase letter\n",
671+
"- use only lowercase letters, digits, or hyphens\n\n",
672+
"Enter a new project ID and retry."
673+
),
674+
project_id = project_id
675+
);
676+
}
677+
678+
if is_project_id_in_use_error(gcloud_output) {
679+
return format!(
680+
"Failed to create project '{project_id}' because the ID is already in use. Enter a different unique project ID and retry."
681+
);
682+
}
683+
684+
if let Some(primary) = primary_gcloud_error_line(gcloud_output) {
685+
return format!(
686+
"Failed to create project '{project_id}'.\n\n{primary}\n\nEnter a different project ID and retry."
687+
);
688+
}
689+
690+
let details = gcloud_output.trim();
691+
if details.is_empty() {
692+
return format!(
693+
"Failed to create project '{project_id}'. Enter a different project ID and retry."
694+
);
695+
}
696+
697+
format!("Failed to create project '{project_id}'.\n\ngcloud error:\n{details}")
698+
}
699+
616700
// ── API enabling ────────────────────────────────────────────────
617701

618702
/// Enable selected Workspace APIs for a project.
@@ -1023,43 +1107,83 @@ fn stage_project(ctx: &mut SetupContext) -> Result<SetupStage, GwsError> {
10231107
let chosen = items.iter().find(|i| i.selected);
10241108
match chosen {
10251109
Some(item) if item.label.starts_with('➕') => {
1026-
let project_name = match ctx
1027-
.wizard
1028-
.as_mut()
1029-
.unwrap()
1030-
.show_input("Create new GCP project", "Enter a unique project ID", None)
1031-
.map_err(|e| GwsError::Validation(format!("TUI error: {e}")))?
1032-
{
1033-
crate::setup_tui::InputResult::Confirmed(v) if !v.is_empty() => v,
1034-
_ => {
1035-
return Err(GwsError::Validation(
1036-
"Project creation cancelled by user".to_string(),
1037-
))
1110+
let mut last_attempt: Option<String> = None;
1111+
loop {
1112+
let project_name = match ctx
1113+
.wizard
1114+
.as_mut()
1115+
.unwrap()
1116+
.show_input(
1117+
"Create new GCP project",
1118+
"Enter a unique project ID",
1119+
last_attempt.as_deref(),
1120+
)
1121+
.map_err(|e| GwsError::Validation(format!("TUI error: {e}")))?
1122+
{
1123+
crate::setup_tui::InputResult::Confirmed(v) => {
1124+
let trimmed = v.trim().to_string();
1125+
if trimmed.is_empty() {
1126+
if let Some(ref mut w) = ctx.wizard {
1127+
w.show_message("Project ID cannot be empty. Enter a valid ID, press ↑ to go back, or Esc to cancel.")
1128+
.ok();
1129+
}
1130+
continue;
1131+
}
1132+
trimmed
1133+
}
1134+
crate::setup_tui::InputResult::GoBack => {
1135+
return Ok(SetupStage::Project);
1136+
}
1137+
crate::setup_tui::InputResult::Cancelled => {
1138+
ctx.finish_wizard();
1139+
return Err(GwsError::Validation(
1140+
"Setup cancelled".to_string(),
1141+
));
1142+
}
1143+
};
1144+
1145+
ctx.wizard
1146+
.as_mut()
1147+
.unwrap()
1148+
.show_message(&format!("Creating project '{}'...", project_name))
1149+
.ok();
1150+
1151+
let output = gcloud_cmd()
1152+
.args(["projects", "create", &project_name])
1153+
.output()
1154+
.map_err(|e| {
1155+
GwsError::Validation(format!("Failed to create project: {e}"))
1156+
})?;
1157+
if output.status.success() {
1158+
set_gcloud_project(&project_name)?;
1159+
ctx.wiz(2, StepStatus::Done(project_name.clone()));
1160+
ctx.project_id = project_name;
1161+
break Ok(SetupStage::EnableApis);
10381162
}
1039-
};
10401163

1041-
ctx.wizard
1042-
.as_mut()
1043-
.unwrap()
1044-
.show_message(&format!("Creating project '{}'...", project_name))
1045-
.ok();
1046-
1047-
let status = gcloud_cmd()
1048-
.args(["projects", "create", &project_name])
1049-
.status()
1050-
.map_err(|e| {
1051-
GwsError::Validation(format!("Failed to create project: {e}"))
1052-
})?;
1053-
if !status.success() {
1054-
return Err(GwsError::Validation(format!(
1055-
"Failed to create project '{}'. Check the ID is valid and unique.",
1056-
project_name
1057-
)));
1164+
let stderr = String::from_utf8_lossy(&output.stderr);
1165+
let stdout = String::from_utf8_lossy(&output.stdout);
1166+
let mut combined = stderr.trim().to_string();
1167+
if !stdout.trim().is_empty() {
1168+
if !combined.is_empty() {
1169+
combined.push('\n');
1170+
}
1171+
combined.push_str(stdout.trim());
1172+
}
1173+
1174+
let message = format_project_create_failure(
1175+
&project_name,
1176+
&ctx.account,
1177+
&combined,
1178+
);
1179+
if let Some(ref mut w) = ctx.wizard {
1180+
w.show_message(&format!(
1181+
"{message}\n\nTry another project ID, press ↑ to return to project selection, or Esc to cancel."
1182+
))
1183+
.ok();
1184+
}
1185+
last_attempt = Some(project_name);
10581186
}
1059-
set_gcloud_project(&project_name)?;
1060-
ctx.wiz(2, StepStatus::Done(project_name.clone()));
1061-
ctx.project_id = project_name;
1062-
Ok(SetupStage::EnableApis)
10631187
}
10641188
Some(item) => {
10651189
set_gcloud_project(&item.label)?;
@@ -1627,6 +1751,47 @@ mod tests {
16271751
assert_eq!(opts.project.as_deref(), Some("p"));
16281752
}
16291753

1754+
#[test]
1755+
fn test_format_project_create_failure_tos_guidance() {
1756+
let msg = format_project_create_failure(
1757+
"example-project-123456",
1758+
"user@example.com",
1759+
"Operation failed: 9: Callers must accept Terms of Service\n type: TOS",
1760+
);
1761+
1762+
assert!(msg.contains("has not accepted Google Cloud Terms of Service"));
1763+
assert!(msg.contains("gcloud auth list"));
1764+
assert!(msg.contains("gcloud config get-value account"));
1765+
assert!(msg.contains("https://console.cloud.google.com/"));
1766+
assert!(msg.contains("user@example.com"));
1767+
}
1768+
1769+
#[test]
1770+
fn test_format_project_create_failure_invalid_id_guidance() {
1771+
let msg = format_project_create_failure(
1772+
"example-project-123456",
1773+
"",
1774+
"ERROR: (gcloud.projects.create) argument PROJECT_ID: Bad value [bad]: Project IDs must be between 6 and 30 characters.",
1775+
);
1776+
1777+
assert!(msg.contains("project ID format is invalid"));
1778+
assert!(msg.contains("be 6 to 30 characters"));
1779+
assert!(msg.contains("start with a lowercase letter"));
1780+
assert!(msg.contains("lowercase letters, digits, or hyphens"));
1781+
}
1782+
1783+
#[test]
1784+
fn test_format_project_create_failure_in_use_guidance() {
1785+
let msg = format_project_create_failure(
1786+
"example-project-123456",
1787+
"",
1788+
"Project ID already in use",
1789+
);
1790+
1791+
assert!(msg.contains("ID is already in use"));
1792+
assert!(msg.contains("different unique project ID"));
1793+
}
1794+
16301795
// ── Account selection → gcloud action ───────────────────────
16311796

16321797
#[test]

0 commit comments

Comments
 (0)