diff --git a/cargo-shuttle/src/args.rs b/cargo-shuttle/src/args.rs index 21830c44f..930b9df5e 100644 --- a/cargo-shuttle/src/args.rs +++ b/cargo-shuttle/src/args.rs @@ -225,7 +225,7 @@ pub struct ProjectStartArgs { pub idle_minutes: u64, } -#[derive(Parser, Clone, Debug)] +#[derive(Parser, Clone, Debug, Default)] pub struct LoginArgs { /// API key for the Shuttle platform #[arg(long)] @@ -261,7 +261,7 @@ pub struct RunArgs { pub release: bool, } -#[derive(Parser, Clone, Debug)] +#[derive(Parser, Clone, Debug, Default)] pub struct InitArgs { /// Clone a starter template from Shuttle's official examples #[arg(long, short, value_enum, conflicts_with_all = &["from", "subfolder"])] @@ -277,6 +277,9 @@ pub struct InitArgs { #[arg(default_value = ".", value_parser = OsStringValueParser::new().try_map(parse_init_path))] pub path: PathBuf, + /// Don't check the project name's validity or availability and use it anyways + #[arg(long)] + pub force_name: bool, /// Whether to start the container for this project on Shuttle, and claim the project name #[arg(long)] pub create_env: bool, @@ -398,9 +401,7 @@ mod tests { template: Some(InitTemplateArg::Tower), from: None, subfolder: None, - create_env: false, - login_args: LoginArgs { api_key: None }, - path: PathBuf::new(), + ..Default::default() }; assert_eq!( init_args.git_template().unwrap(), @@ -415,9 +416,7 @@ mod tests { template: Some(InitTemplateArg::Axum), from: None, subfolder: None, - create_env: false, - login_args: LoginArgs { api_key: None }, - path: PathBuf::new(), + ..Default::default() }; assert_eq!( init_args.git_template().unwrap(), @@ -432,9 +431,7 @@ mod tests { template: Some(InitTemplateArg::None), from: None, subfolder: None, - create_env: false, - login_args: LoginArgs { api_key: None }, - path: PathBuf::new(), + ..Default::default() }; assert_eq!( init_args.git_template().unwrap(), @@ -449,9 +446,7 @@ mod tests { template: None, from: Some("https://github.com/some/repo".into()), subfolder: Some("some/path".into()), - create_env: false, - login_args: LoginArgs { api_key: None }, - path: PathBuf::new(), + ..Default::default() }; assert_eq!( init_args.git_template().unwrap(), @@ -466,9 +461,7 @@ mod tests { template: None, from: None, subfolder: None, - create_env: false, - login_args: LoginArgs { api_key: None }, - path: PathBuf::new(), + ..Default::default() }; assert_eq!(init_args.git_template().unwrap(), None); } diff --git a/cargo-shuttle/src/lib.rs b/cargo-shuttle/src/lib.rs index c94d5d7cc..b57bdea24 100644 --- a/cargo-shuttle/src/lib.rs +++ b/cargo-shuttle/src/lib.rs @@ -302,14 +302,15 @@ impl Shuttle { provided_path_to_init: bool, ) -> Result { // Turns the template or git args (if present) to a repo+folder. - let git_templates = args.git_template()?; + let git_template = args.git_template()?; let unauthorized = self.ctx.api_key().is_err() && args.login_args.api_key.is_none(); - let interactive = project_args.name.is_none() - || git_templates.is_none() - || !provided_path_to_init - || unauthorized; + let needs_name = project_args.name.is_none(); + let needs_template = git_template.is_none(); + let needs_path = !provided_path_to_init; + let needs_login = unauthorized; + let interactive = needs_name || needs_template || needs_path || needs_login; let theme = ColorfulTheme::default(); @@ -318,9 +319,9 @@ impl Shuttle { let login_args = LoginArgs { api_key: Some(api_key.as_ref().to_string()), }; - + // TODO: this re-applies an already loaded API key self.login(login_args).await?; - } else if interactive { + } else if needs_login { println!("First, let's log in to your Shuttle account."); self.login(args.login_args.clone()).await?; println!(); @@ -330,54 +331,55 @@ impl Shuttle { bail!("Tried to login to create a Shuttle environment, but no API key was set.") } - // 2. Ask for project name - if project_args.name.is_none() { + // 2. Ask for project name or validate the given one + if needs_name { printdoc! {" What do you want to name your project? It will be hosted at ${{project_name}}.shuttleapp.rs, so choose something unique! " }; - let client = self.client.as_ref().unwrap(); - loop { - // not using validate_with due to being blocking - let p: String = Input::with_theme(&theme) + } + let mut prev_name: Option = None; + loop { + // prompt if interactive + let name: String = if let Some(name) = project_args.name.clone() { + name + } else { + // not using `validate_with` due to being blocking. + Input::with_theme(&theme) .with_prompt("Project name") - .interact()?; - match client.check_project_name(&p).await { - Ok(true) => { - println!("{} {}", "Project name already taken:".red(), p); - println!("{}", "Try a different name.".yellow()); - } - Ok(false) => { - project_args.name = Some(p); - break; - } - Err(e) => { - // If API error contains message regarding format of error name, print that error and prompt again - if let Ok(api_error) = e.downcast::() { - // If the returned error string changes, this could break - if api_error.message.contains("Invalid project name") { - println!("{}", api_error.message.yellow()); - println!("{}", "Try a different name.".yellow()); - continue; - } - } - // Else, the API error was about something else. - // Ignore and keep going to not prevent the flow of the init command. - project_args.name = Some(p); - println!( - "{}", - "Failed to check if project name is available.".yellow() - ); - break; - } - } + .interact()? + }; + let force_name = args.force_name + || (needs_name && prev_name.as_ref().is_some_and(|prev| prev == &name)); + if force_name { + project_args.name = Some(name); + break; + } + // validate and take action based on result + if self + .check_project_name(&mut project_args, name.clone()) + .await + { + // success + break; + } else if needs_name { + // try again + println!(r#"Type the same name again to use "{}" anyways."#, name); + prev_name = Some(name); + } else { + // don't continue if non-interactive + bail!( + "Invalid or unavailable project name. Use `--force-name` to use this project name anyways." + ); } + } + if needs_name { println!(); } // 3. Confirm the project directory - let path = if interactive { + let path = if needs_path { let path = args .path .to_str() @@ -410,8 +412,8 @@ impl Shuttle { }; // 4. Ask for the framework - let template = match git_templates { - Some(git_templates) => git_templates, + let template = match git_template { + Some(git_template) => git_template, None => { println!( "Shuttle works with a range of web frameworks. Which one do you want to use?" @@ -427,11 +429,10 @@ impl Shuttle { } }; - let serenity_idle_hint = if let Some(s) = template.subfolder.as_ref() { - s.contains("serenity") || s.contains("poise") - } else { - false - }; + let serenity_idle_hint = template + .subfolder + .as_ref() + .is_some_and(|s| s.contains("serenity") || s.contains("poise")); // 5. Initialize locally init::generate_project( @@ -511,6 +512,44 @@ impl Shuttle { Ok(CommandOutcome::Ok) } + /// true -> success/neutral. false -> try again. + async fn check_project_name(&self, project_args: &mut ProjectArgs, name: String) -> bool { + let client = self.client.as_ref().unwrap(); + match client.check_project_name(&name).await { + Ok(true) => { + println!("{} {}", "Project name already taken:".red(), name); + println!("{}", "Try a different name.".yellow()); + + false + } + Ok(false) => { + project_args.name = Some(name); + + true + } + Err(e) => { + // If API error contains message regarding format of error name, print that error and prompt again + if let Ok(api_error) = e.downcast::() { + // If the returned error string changes, this could break + if api_error.message.contains("Invalid project name") { + println!("{}", api_error.message.yellow()); + println!("{}", "Try a different name.".yellow()); + return false; + } + } + // Else, the API error was about something else. + // Ignore and keep going to not prevent the flow of the init command. + project_args.name = Some(name); + println!( + "{}", + "Failed to check if project name is available.".yellow() + ); + + true + } + } + } + pub fn load_project(&mut self, project_args: &ProjectArgs) -> Result<()> { trace!("loading project arguments: {project_args:?}"); diff --git a/cargo-shuttle/tests/integration/init.rs b/cargo-shuttle/tests/integration/init.rs index e787d64bf..a414752cf 100644 --- a/cargo-shuttle/tests/integration/init.rs +++ b/cargo-shuttle/tests/integration/init.rs @@ -19,11 +19,10 @@ async fn non_interactive_basic_init() { let args = ShuttleArgs::parse_from([ "cargo-shuttle", - "--api-url", - "http://shuttle.invalid:80", "init", "--api-key", "dh9z58jttoes3qvt", + "--force-name", "--name", "my-project", "--template", @@ -48,11 +47,10 @@ async fn non_interactive_rocket_init() { let args = ShuttleArgs::parse_from([ "cargo-shuttle", - "--api-url", - "http://shuttle.invalid:80", "init", "--api-key", "dh9z58jttoes3qvt", + "--force-name", "--name", "my-project", "--template", @@ -75,11 +73,10 @@ async fn non_interactive_init_with_from_url() { let args = ShuttleArgs::parse_from([ "cargo-shuttle", - "--api-url", - "http://shuttle.invalid:80", "init", "--api-key", "dh9z58jttoes3qvt", + "--force-name", "--name", "my-project", "--from", @@ -106,11 +103,10 @@ async fn non_interactive_init_with_from_gh() { let args = ShuttleArgs::parse_from([ "cargo-shuttle", - "--api-url", - "http://shuttle.invalid:80", "init", "--api-key", "dh9z58jttoes3qvt", + "--force-name", "--name", "my-project", "--from", @@ -137,11 +133,10 @@ async fn non_interactive_init_with_from_repo_name() { let args = ShuttleArgs::parse_from([ "cargo-shuttle", - "--api-url", - "http://shuttle.invalid:80", "init", "--api-key", "dh9z58jttoes3qvt", + "--force-name", "--name", "my-project", "--from", @@ -168,11 +163,10 @@ async fn non_interactive_init_with_from_local_path() { let args = ShuttleArgs::parse_from([ "cargo-shuttle", - "--api-url", - "http://shuttle.invalid:80", "init", "--api-key", "dh9z58jttoes3qvt", + "--force-name", "--name", "my-project", "--from", @@ -199,13 +193,7 @@ fn interactive_rocket_init() -> Result<(), Box> { let bin_path = assert_cmd::cargo::cargo_bin("cargo-shuttle"); let mut command = Command::new(bin_path); - command.args([ - "--api-url", - "http://shuttle.invalid:80", - "init", - "--api-key", - "dh9z58jttoes3qvt", - ]); + command.args(["init", "--force-name", "--api-key", "dh9z58jttoes3qvt"]); let mut session = rexpect::session::spawn_command(command, Some(EXPECT_TIMEOUT_MS))?; session.exp_string("What do you want to name your project?")?; @@ -240,13 +228,7 @@ fn interactive_rocket_init_manually_choose_template() -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box