From 898c2644859abe8541bb3b1ca80d68e9cd3ebd52 Mon Sep 17 00:00:00 2001 From: Stijn Seghers Date: Tue, 29 Nov 2022 18:32:24 +0100 Subject: [PATCH] feat: interactive project initialization --- Cargo.lock | 42 ++++++ cargo-shuttle/Cargo.toml | 1 + cargo-shuttle/src/args.rs | 95 ++++++++++++- cargo-shuttle/src/init.rs | 169 +++++++++--------------- cargo-shuttle/src/lib.rs | 130 ++++++++++++++---- cargo-shuttle/tests/integration/init.rs | 96 +++----------- 6 files changed, 323 insertions(+), 210 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb00254c2a..5a32beb618 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1238,6 +1238,7 @@ dependencies = [ "clap_complete", "crossbeam-channel", "crossterm", + "dialoguer", "dirs", "flate2", "futures", @@ -1543,6 +1544,20 @@ dependencies = [ "cache-padded", ] +[[package]] +name = "console" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c050367d967ced717c04b65d8c619d863ef9292ce0c5760028655a2fb298718c" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "terminal_size", + "unicode-width", + "winapi", +] + [[package]] name = "const_fn" version = "0.4.9" @@ -2016,6 +2031,18 @@ dependencies = [ "syn 1.0.99", ] +[[package]] +name = "dialoguer" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92e7e37ecef6857fdc0c0c5d42fd5b0938e46590c2183cc92dd310a6d078eb1" +dependencies = [ + "console", + "fuzzy-matcher", + "tempfile", + "zeroize", +] + [[package]] name = "diff" version = "0.1.12" @@ -2122,6 +2149,12 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.31" @@ -2439,6 +2472,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "fwdansi" version = "1.1.0" diff --git a/cargo-shuttle/Cargo.toml b/cargo-shuttle/Cargo.toml index 6f46127ad5..a7cc195d93 100644 --- a/cargo-shuttle/Cargo.toml +++ b/cargo-shuttle/Cargo.toml @@ -18,6 +18,7 @@ clap = { version = "3.2.17", features = ["derive", "env"] } clap_complete = "3.2.5" crossbeam-channel = "0.5.6" crossterm = "0.25.0" +dialoguer = { version = "0.10.2", features = ["fuzzy-select"] } dirs = "4.0.0" flate2 = "1.0.24" futures = "0.3.23" diff --git a/cargo-shuttle/src/args.rs b/cargo-shuttle/src/args.rs index de3f12a63e..53ed38216a 100644 --- a/cargo-shuttle/src/args.rs +++ b/cargo-shuttle/src/args.rs @@ -10,6 +10,8 @@ use clap_complete::Shell; use shuttle_common::project::ProjectName; use uuid::Uuid; +use crate::init::Framework; + #[derive(Parser)] #[clap( version, @@ -113,7 +115,7 @@ pub enum ProjectCommand { Status, } -#[derive(Parser)] +#[derive(Parser, Clone, Debug)] pub struct LoginArgs { /// api key for the shuttle platform #[clap(long)] @@ -173,6 +175,11 @@ pub struct InitArgs { /// Initialize with thruster framework #[clap(long, conflicts_with_all = &["axum", "rocket", "tide", "tower", "poem", "warp", "salvo", "serenity"])] pub thruster: bool, + /// Whether to create the environment for this project on Shuttle + #[clap(long)] + pub new: bool, + #[clap(flatten)] + pub login_args: LoginArgs, /// Path to initialize a new shuttle project #[clap( parse(try_from_os_str = parse_init_path), @@ -181,6 +188,32 @@ pub struct InitArgs { pub path: PathBuf, } +impl InitArgs { + pub fn framework(&self) -> Option { + if self.axum { + Some(Framework::Axum) + } else if self.rocket { + Some(Framework::Rocket) + } else if self.tide { + Some(Framework::Tide) + } else if self.tower { + Some(Framework::Tower) + } else if self.poem { + Some(Framework::Poem) + } else if self.salvo { + Some(Framework::Salvo) + } else if self.serenity { + Some(Framework::Serenity) + } else if self.warp { + Some(Framework::Warp) + } else if self.thruster { + Some(Framework::Thruster) + } else { + None + } + } +} + // Helper function to parse and return the absolute path fn parse_path(path: &OsStr) -> Result { canonicalize(path).map_err(|e| { @@ -198,3 +231,63 @@ fn parse_init_path(path: &OsStr) -> Result { parse_path(path) } + +#[cfg(test)] +mod tests { + use super::*; + + fn init_args_factory(framework: &str) -> InitArgs { + let mut init_args = InitArgs { + axum: false, + rocket: false, + tide: false, + tower: false, + poem: false, + salvo: false, + serenity: false, + warp: false, + thruster: false, + new: false, + login_args: LoginArgs { api_key: None }, + path: PathBuf::new(), + }; + + match framework { + "axum" => init_args.axum = true, + "rocket" => init_args.rocket = true, + "tide" => init_args.tide = true, + "tower" => init_args.tower = true, + "poem" => init_args.poem = true, + "salvo" => init_args.salvo = true, + "serenity" => init_args.serenity = true, + "warp" => init_args.warp = true, + "thruster" => init_args.thruster = true, + _ => unreachable!(), + } + + init_args + } + + #[test] + fn test_init_args_framework() { + let framework_strs = vec![ + "axum", "rocket", "tide", "tower", "poem", "salvo", "serenity", "warp", "thruster", + ]; + let frameworks: Vec = vec![ + Framework::Axum, + Framework::Rocket, + Framework::Tide, + Framework::Tower, + Framework::Poem, + Framework::Salvo, + Framework::Serenity, + Framework::Warp, + Framework::Thruster, + ]; + + for (framework_str, expected_framework) in framework_strs.into_iter().zip(frameworks) { + let framework = init_args_factory(framework_str).framework(); + assert_eq!(framework, Some(expected_framework)); + } + } +} diff --git a/cargo-shuttle/src/init.rs b/cargo-shuttle/src/init.rs index d4309aa7e8..c4dc297440 100644 --- a/cargo-shuttle/src/init.rs +++ b/cargo-shuttle/src/init.rs @@ -1,8 +1,8 @@ +use std::fmt; use std::fs::{read_to_string, File}; use std::io::Write; use std::path::{Path, PathBuf}; -use crate::args::InitArgs; use anyhow::Result; use cargo::ops::NewOptions; use cargo_edit::{find, get_latest_dependency, registry_url}; @@ -10,6 +10,66 @@ use indoc::indoc; use toml_edit::{value, Array, Document, Item, Table}; use url::Url; +pub const FRAMEWORKS: [Framework; 9] = [ + Framework::Axum, + Framework::Rocket, + Framework::Tide, + Framework::Tower, + Framework::Poem, + Framework::Salvo, + Framework::Serenity, + Framework::Warp, + Framework::Thruster, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Framework { + Axum, + Rocket, + Tide, + Tower, + Poem, + Salvo, + Serenity, + Warp, + Thruster, +} + +impl Framework { + /// Returns a framework-specific struct that implements the trait `ShuttleInit` + /// for writing framework-specific dependencies to `Cargo.toml` and generating + /// boilerplate code in `src/lib.rs`. + pub fn init_config(&self) -> Box { + match self { + Framework::Axum => Box::new(ShuttleInitAxum), + Framework::Rocket => Box::new(ShuttleInitRocket), + Framework::Tide => Box::new(ShuttleInitTide), + Framework::Tower => Box::new(ShuttleInitTower), + Framework::Poem => Box::new(ShuttleInitPoem), + Framework::Salvo => Box::new(ShuttleInitSalvo), + Framework::Serenity => Box::new(ShuttleInitSerenity), + Framework::Warp => Box::new(ShuttleInitWarp), + Framework::Thruster => Box::new(ShuttleInitThruster), + } + } +} + +impl fmt::Display for Framework { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Framework::Axum => "axum", + Framework::Rocket => "rocket", + Framework::Tide => "tide", + Framework::Tower => "tower", + Framework::Poem => "poem", + Framework::Salvo => "salvo", + Framework::Serenity => "serenity", + Framework::Warp => "warp", + Framework::Thruster => "thruster", + }) + } +} + pub trait ShuttleInit { fn set_cargo_dependencies( &self, @@ -552,49 +612,6 @@ impl ShuttleInit for ShuttleInitNoOp { } } -/// Returns a framework-specific struct that implements the trait `ShuttleInit` -/// for writing framework-specific dependencies to `Cargo.toml` and generating -/// boilerplate code in `src/lib.rs`. -pub fn get_framework(init_args: &InitArgs) -> Box { - if init_args.axum { - return Box::new(ShuttleInitAxum); - } - - if init_args.rocket { - return Box::new(ShuttleInitRocket); - } - - if init_args.tide { - return Box::new(ShuttleInitTide); - } - - if init_args.tower { - return Box::new(ShuttleInitTower); - } - - if init_args.poem { - return Box::new(ShuttleInitPoem); - } - - if init_args.salvo { - return Box::new(ShuttleInitSalvo); - } - - if init_args.serenity { - return Box::new(ShuttleInitSerenity); - } - - if init_args.warp { - return Box::new(ShuttleInitWarp); - } - - if init_args.thruster { - return Box::new(ShuttleInitThruster); - } - - Box::new(ShuttleInitNoOp) -} - /// Interoprates with `cargo` crate and calls `cargo init --libs [path]`. pub fn cargo_init(path: PathBuf) -> Result<()> { let opts = NewOptions::new(None, false, true, path, None, None, None)?; @@ -610,7 +627,7 @@ pub fn cargo_init(path: PathBuf) -> Result<()> { } /// Performs shuttle init on the existing files generated by `cargo init --libs [path]`. -pub fn cargo_shuttle_init(path: PathBuf, framework: Box) -> Result<()> { +pub fn cargo_shuttle_init(path: PathBuf, framework: Framework) -> Result<()> { let cargo_toml_path = path.join("Cargo.toml"); let mut cargo_doc = read_to_string(cargo_toml_path.clone()) .unwrap() @@ -642,8 +659,10 @@ pub fn cargo_shuttle_init(path: PathBuf, framework: Box) -> Res get_latest_dependency_version, ); + let init_config = framework.init_config(); + // Set framework-specific dependencies to the `dependencies` table - framework.set_cargo_dependencies( + init_config.set_cargo_dependencies( &mut dependencies, &manifest_path, &url, @@ -658,7 +677,7 @@ pub fn cargo_shuttle_init(path: PathBuf, framework: Box) -> Res // Write boilerplate to `src/lib.rs` file let lib_path = path.join("src").join("lib.rs"); - let boilerplate = framework.get_boilerplate_code_for_framework(); + let boilerplate = init_config.get_boilerplate_code_for_framework(); if !boilerplate.is_empty() { write_lib_file(boilerplate, &lib_path)?; } @@ -740,36 +759,6 @@ pub fn write_lib_file(boilerplate: &'static str, lib_path: &Path) -> Result<()> mod shuttle_init_tests { use super::*; - fn init_args_factory(framework: &str) -> InitArgs { - let mut init_args = InitArgs { - axum: false, - rocket: false, - tide: false, - tower: false, - poem: false, - salvo: false, - serenity: false, - warp: false, - thruster: false, - path: PathBuf::new(), - }; - - match framework { - "axum" => init_args.axum = true, - "rocket" => init_args.rocket = true, - "tide" => init_args.tide = true, - "tower" => init_args.tower = true, - "poem" => init_args.poem = true, - "salvo" => init_args.salvo = true, - "serenity" => init_args.serenity = true, - "warp" => init_args.warp = true, - "thruster" => init_args.thruster = true, - _ => unreachable!(), - } - - init_args - } - fn cargo_toml_factory() -> Document { indoc! {r#" [dependencies] @@ -787,32 +776,6 @@ mod shuttle_init_tests { "1.0".to_string() } - #[test] - fn test_get_framework_via_get_boilerplate_code() { - let frameworks = vec![ - "axum", "rocket", "tide", "tower", "poem", "salvo", "serenity", "warp", "thruster", - ]; - let framework_inits: Vec> = vec![ - Box::new(ShuttleInitAxum), - Box::new(ShuttleInitRocket), - Box::new(ShuttleInitTide), - Box::new(ShuttleInitTower), - Box::new(ShuttleInitPoem), - Box::new(ShuttleInitSalvo), - Box::new(ShuttleInitSerenity), - Box::new(ShuttleInitWarp), - Box::new(ShuttleInitThruster), - ]; - - for (framework, expected_framework_init) in frameworks.into_iter().zip(framework_inits) { - let framework_init = get_framework(&init_args_factory(framework)); - assert_eq!( - framework_init.get_boilerplate_code_for_framework(), - expected_framework_init.get_boilerplate_code_for_framework(), - ); - } - } - #[test] fn test_set_inline_table_dependency_features() { let mut cargo_toml = cargo_toml_factory(); diff --git a/cargo-shuttle/src/lib.rs b/cargo-shuttle/src/lib.rs index 53838c344b..9c364dde8e 100644 --- a/cargo-shuttle/src/lib.rs +++ b/cargo-shuttle/src/lib.rs @@ -6,15 +6,14 @@ mod init; use std::collections::BTreeMap; use std::fs::{read_to_string, File}; -use std::io::Write; -use std::io::{self, stdout}; +use std::io::stdout; use std::net::{Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::rc::Rc; -use anyhow::{anyhow, Context, Result}; -pub use args::{Args, Command, DeployArgs, InitArgs, ProjectArgs, RunArgs}; -use args::{AuthArgs, LoginArgs}; +use anyhow::{anyhow, bail, Context, Result}; +use args::AuthArgs; +pub use args::{Args, Command, DeployArgs, InitArgs, LoginArgs, ProjectArgs, RunArgs}; use cargo::core::resolver::CliFeatures; use cargo::core::Workspace; use cargo::ops::{PackageOpts, Packages}; @@ -23,6 +22,7 @@ use clap::CommandFactory; use clap_complete::{generate, Shell}; use config::RequestContext; use crossterm::style::Stylize; +use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input, Password}; use factory::LocalFactory; use flate2::read::GzDecoder; use flate2::write::GzEncoder; @@ -38,6 +38,7 @@ use uuid::Uuid; use crate::args::{DeploymentCommand, ProjectCommand}; use crate::client::Client; +use crate::init::FRAMEWORKS; pub struct Shuttle { ctx: RequestContext, @@ -65,14 +66,14 @@ impl Shuttle { self.load_project(&mut args.project_args)?; } + self.ctx.set_api_url(args.api_url); + match args.cmd { - Command::Init(init_args) => self.init(init_args).await, + Command::Init(init_args) => self.init(init_args, args.project_args).await, Command::Generate { shell, output } => self.complete(shell, output).await, Command::Login(login_args) => self.login(login_args).await, Command::Run(run_args) => self.local_run(run_args).await, need_client => { - self.ctx.set_api_url(args.api_url); - let mut client = Client::new(self.ctx.api_url()); client.set_api_key(self.ctx.api_key()?); @@ -103,14 +104,91 @@ impl Shuttle { .map(|_| CommandOutcome::Ok) } - async fn init(&self, args: InitArgs) -> Result<()> { - // Interface with cargo to initialize new lib package for shuttle - let path = args.path.clone(); - init::cargo_init(path.clone())?; + /// Log in, initialize a project and potentially create the Shuttle environment for it. + /// + /// If both a project name and framework are passed as arguments, it will run without any extra + /// interaction. + async fn init(&mut self, args: InitArgs, mut project_args: ProjectArgs) -> Result<()> { + let interactive = project_args.name.is_none() || args.framework().is_none(); + + let theme = ColorfulTheme::default(); + + // 1. Log in (if not logged in yet) + if self.ctx.api_key().is_err() { + if interactive { + println!("First, let's log in to your Shuttle account."); + self.login(args.login_args.clone()).await?; + println!(); + } else if args.new && args.login_args.api_key.is_some() { + self.login(args.login_args.clone()).await?; + } else { + bail!("Tried to login to create a Shuttle environment, but no API key was set.") + } + } - let framework = init::get_framework(&args); + // 2. Ask for project name + if project_args.name.is_none() { + println!("How do you want to name your project? It will be hosted at ${{project_name}}.shuttleapp.rs."); + // TODO: Check whether the project name is still available + project_args.name = Some( + Input::with_theme(&theme) + .with_prompt("Project name") + .interact()?, + ); + println!(); + } + + // 3. Confirm the project directory + let path = if interactive { + println!("Where should we create this project?"); + let directory_str: String = Input::with_theme(&theme) + .with_prompt("Directory") + .with_initial_text(args.path.display().to_string()) + .interact()?; + println!(); + PathBuf::from(directory_str) + } else { + args.path.clone() + }; + + // 4. Ask for the framework + let framework = match args.framework() { + Some(framework) => framework, + None => { + println!( + "Shuttle works with a range of web frameworks. Which one do you want to use?" + ); + let index = FuzzySelect::with_theme(&theme) + .items(&FRAMEWORKS) + .default(0) + .interact()?; + println!(); + FRAMEWORKS[index] + } + }; + + // 5. Initialize locally + init::cargo_init(path.clone())?; init::cargo_shuttle_init(path, framework)?; + // 6. Confirm that the user wants to create the project environment on Shuttle + let should_create_environment = if !interactive { + args.new + } else if args.new { + true + } else { + Confirm::with_theme(&theme) + .with_prompt("Do you want to create the project environment on Shuttle?") + .default(true) + .interact()? + }; + if should_create_environment { + self.load_project(&mut project_args)?; + let mut client = Client::new(self.ctx.api_url()); + client.set_api_key(self.ctx.api_key()?); + self.project_create(&client).await?; + } + Ok(()) } @@ -133,23 +211,21 @@ impl Shuttle { self.ctx.load_local(project_args) } + /// Log in with the given API key or after prompting the user for one. async fn login(&mut self, login_args: LoginArgs) -> Result<()> { - let api_key_str = login_args.api_key.unwrap_or_else(|| { - let url = "https://shuttle.rs/login"; + let api_key_str = match login_args.api_key { + Some(api_key) => api_key, + None => { + let url = "https://shuttle.rs/login"; + let _ = webbrowser::open(url); - let _ = webbrowser::open(url); + println!("If your browser did not automatically open, go to {url}"); - println!("If your browser did not automatically open, go to {url}"); - print!("Enter Api Key: "); - - stdout().flush().unwrap(); - - let mut input = String::new(); - - io::stdin().read_line(&mut input).unwrap(); - - input - }); + Password::with_theme(&ColorfulTheme::default()) + .with_prompt("API key") + .interact()? + } + }; let api_key = api_key_str.trim().parse()?; diff --git a/cargo-shuttle/tests/integration/init.rs b/cargo-shuttle/tests/integration/init.rs index 25de270081..b2d427949c 100644 --- a/cargo-shuttle/tests/integration/init.rs +++ b/cargo-shuttle/tests/integration/init.rs @@ -1,92 +1,28 @@ -use std::{ - fs::read_to_string, - path::{Path, PathBuf}, -}; +use std::fs::read_to_string; -use cargo_shuttle::{Args, Command, CommandOutcome, InitArgs, ProjectArgs, Shuttle}; +use cargo_shuttle::{Args, Shuttle}; +use clap::Parser; use indoc::indoc; use tempfile::Builder; -/// creates a `cargo-shuttle` init instance with some reasonable defaults set. -async fn cargo_shuttle_init(path: PathBuf) -> anyhow::Result { - let working_directory = Path::new(".").to_path_buf(); - - Shuttle::new() - .unwrap() - .run(Args { - api_url: Some("http://shuttle.invalid:80".to_string()), - project_args: ProjectArgs { - working_directory, - name: None, - }, - cmd: Command::Init(InitArgs { - axum: false, - rocket: false, - tide: false, - tower: false, - poem: false, - salvo: false, - serenity: false, - warp: false, - thruster: false, - path, - }), - }) - .await -} - -/// creates a `cargo-shuttle` init instance for initializing the `rocket` framework -async fn cargo_shuttle_init_framework(path: PathBuf) -> anyhow::Result { - let working_directory = Path::new(".").to_path_buf(); - - Shuttle::new() - .unwrap() - .run(Args { - api_url: Some("http://shuttle.invalid:80".to_string()), - project_args: ProjectArgs { - working_directory, - name: None, - }, - cmd: Command::Init(InitArgs { - axum: false, - rocket: true, - tide: false, - tower: false, - poem: false, - salvo: false, - serenity: false, - warp: false, - thruster: false, - path, - }), - }) - .await -} - -#[tokio::test] -async fn basic_init() { - let temp_dir = Builder::new().prefix("basic-init").tempdir().unwrap(); - let temp_dir_path = temp_dir.path().to_owned(); - - cargo_shuttle_init(temp_dir_path.clone()).await.unwrap(); - let cargo_toml = read_to_string(temp_dir_path.join("Cargo.toml")).unwrap(); - - // Expected: name = "basic-initRANDOM_CHARS" - assert!(cargo_toml.contains("name = \"basic-init")); - assert!(cargo_toml.contains("shuttle-service = { version = ")); -} - #[tokio::test] -async fn framework_init() { +async fn non_interactive_rocket_init() { let temp_dir = Builder::new().prefix("rocket-init").tempdir().unwrap(); let temp_dir_path = temp_dir.path().to_owned(); - cargo_shuttle_init_framework(temp_dir_path.clone()) - .await - .unwrap(); + let args = Args::parse_from([ + "cargo-shuttle", + "--api-url", + "http://shuttle.invalid:80", + "init", + "--name", + "my-project", + "--rocket", + temp_dir_path.to_str().unwrap(), + ]); + Shuttle::new().unwrap().run(args).await.unwrap(); let cargo_toml = read_to_string(temp_dir_path.join("Cargo.toml")).unwrap(); - // Expected: name = "rocket-initRANDOM_CHARS" assert!(cargo_toml.contains("name = \"rocket-init")); assert!(cargo_toml.contains("shuttle-service = { version = ")); @@ -112,3 +48,5 @@ async fn framework_init() { assert_eq!(lib_file, expected); } + +// TODO: Test interactive route