@@ -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 \n Active 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 \n Enter 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 \n gcloud 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 \n Try 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