rapid_cli/commands/
new.rs

1use super::RapidCommand;
2use crate::{
3	cli::{current_directory, Config},
4	constants::BOLT_EMOJI,
5	tui::{indent, logo, rapid_logo},
6};
7use clap::{Arg, ArgAction, ArgMatches, Command};
8use colorful::{Color, Colorful};
9use include_dir::{include_dir, Dir};
10use log::error;
11use requestty::{prompt, prompt_one, Answer, Question};
12use spinach::Spinach;
13use std::{
14	fs::remove_dir_all,
15	path::PathBuf,
16	process::{exit, Command as StdCommand},
17	thread, time,
18};
19
20// We need to get the project directory to extract the template files (this is because include_dir!() is yoinked inside of a workspace)
21static PROJECT_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates/server");
22static REMIX_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates/remix");
23static NEXTJS_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates/nextjs");
24static REMIX_WITHOUT_CLERK_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates/remix-without-clerk");
25
26pub struct New {}
27
28impl RapidCommand for New {
29	fn cmd() -> clap::Command {
30		Command::new("new")
31			.about("Creates a new Rapid project at the current working directory!")
32			.arg(
33				Arg::new("remix")
34					.long("remix")
35					.required(false)
36					.conflicts_with("nextjs")
37					.conflicts_with("server")
38					.action(ArgAction::SetTrue)
39					.value_name("REMIX")
40					.help("Scaffolds a fullstack Rapid project with remix!"),
41			)
42			.arg(
43				Arg::new("nextjs")
44					.long("nextjs")
45					.required(false)
46					.conflicts_with("server")
47					.conflicts_with("remix")
48					.action(ArgAction::SetTrue)
49					.value_name("NEXTJS")
50					.help("Scaffolds a fullstack Rapid project with Nextjs!"),
51			)
52			.arg(
53				Arg::new("server")
54					.long("server")
55					.required(false)
56					.conflicts_with("nextjs")
57					.conflicts_with("remix")
58					.action(ArgAction::SetTrue)
59					.value_name("SERVER")
60					.help("Scaffolds a server-side only Rapid project!"),
61			)
62	}
63
64	fn execute(_: &Config, args: &ArgMatches) -> Result<(), crate::cli::CliError<'static>> {
65		println!("{}", logo());
66		parse_new_args(args);
67		Ok(())
68	}
69}
70
71pub fn parse_new_args(args: &ArgMatches) {
72	// Get the current working directory of the user
73	let current_working_directory = current_directory();
74
75	let is_remix = args.get_one::<bool>("remix").unwrap_or(&true);
76	let is_server = args.get_one::<bool>("server").unwrap_or(&false);
77	let is_nextjs = args.get_one::<bool>("nextjs").unwrap_or(&false);
78
79	match (is_remix, is_server, is_nextjs) {
80		(false, false, true) => {
81			init_nextjs_template(current_working_directory);
82		}
83		(true, false, false) => {
84			init_remix_template(current_working_directory);
85		}
86		(false, true, false) => {
87			init_server_template(current_working_directory, "server");
88		}
89		(false, false, false) => {
90			init_remix_template(current_working_directory);
91		}
92		_ => unreachable!(),
93	}
94}
95
96// TODO: scaffold a new remix + rapid app as the default fullstack app (we will then support nextjs, etc)
97pub fn init_remix_template(current_working_directory: PathBuf) {
98	// Ask the user what they want to name their project
99	let project_name = prompt_one(
100		Question::input("project_name")
101			.message("What will your project be called?")
102			.default("my-app")
103			.build(),
104	)
105	.expect("Error: Could not scaffold project. Please try again!");
106
107	let project_name = project_name.as_string().unwrap();
108
109	// Validate that the project name does not contain any invalid chars
110	if !project_name.chars().all(|x| x.is_alphanumeric() || x == '-' || x == '_') {
111		error!("Aborting...your project name may only contain alphanumeric characters along with '-' and '_'...");
112		exit(64);
113	}
114
115	let path = current_working_directory.join(project_name);
116
117	// Check if the path already exists (if it does we want to ask the user if they want to delete it)
118	if path.exists() {
119		let force = prompt_one(
120			Question::confirm("force_delete")
121				.message("Your specified directory is not empty and has files currently in it, do you want to overwrite?")
122				.default(false)
123				.build(),
124		)
125		.expect("Error: Could not scaffold project. Please try again!");
126
127		match !force.as_bool().unwrap() {
128			true => {
129				exit(64);
130			}
131			false => {
132				remove_dir_all(&path).expect("Error: Could not scaffold project. The specified directory must be empty. Please try again!");
133			}
134		}
135	}
136
137	// Before we initialize the project, lets ask the user to specify which package manager they want to use and then run the install process with that package manager
138	let manager_choices = vec!["pnpm", "npm", "yarn"];
139
140	println!("{}", indent(1));
141
142	let package_manager = requestty::Question::select("packageManagerSelect")
143		.message("Which package manager would you like to use?:")
144		.choices(manager_choices)
145		.page_size(6)
146		.build();
147
148	let package_manager = prompt(vec![package_manager]).expect("Error: Could not scaffold project. Please try again!");
149
150	let package_manager = match package_manager.get("packageManagerSelect") {
151		Some(Answer::ListItem(choice)) => choice.text.clone(),
152		_ => {
153			error!(
154				"Aborting...an error occurred while trying to parse package manager selection. Please try again!"
155			);
156			exit(64);
157		}
158	};
159
160	println!("{}", indent(1));
161
162	// Have the user select from a list of technologies they might or might not want to use
163	// TODO: use this when we actually have more choices (like prettier, eslint, rapid-ui, diesel, sea-orm, sqlx, etc)
164	let _ = vec!["Clerk (authentication)"];
165
166	let tech_choices =
167		requestty::Question::multi_select("What technologies would you like included?").choice_with_default("Clerk (authentication)", true);
168
169	let tech_choices = prompt_one(tech_choices).expect("Error: Could not scaffold project. Please try again!");
170
171	let tech_choices = match tech_choices {
172		Answer::ListItems(choices) => choices,
173		_ => {
174			error!(
175				"{}",
176				"aborting...an error occurred while trying to parse technology choices. Please try again!"
177			);
178			exit(64);
179		}
180	};
181
182	let should_include_clerk = tech_choices.iter().any(|x| x.text == "Clerk (authentication)");
183
184	println!("{}", indent(1));
185
186	let loading = Spinach::new(format!("{}", "Initializing a new Rapid Remix application..".color(Color::LightCyan)));
187
188	// Run the scaffold commands
189	StdCommand::new("sh")
190		.current_dir(current_directory())
191		.arg("-c")
192		.arg(format!("mkdir {}", project_name))
193		.spawn()
194		.unwrap()
195		.wait()
196		.expect("Error: Could not scaffold project. Please try again!");
197
198	// Initialize a git repo
199	StdCommand::new("sh")
200		.current_dir(current_directory().join(project_name))
201		.arg("-c")
202		.arg(format!("git init --quiet"))
203		.spawn()
204		.unwrap()
205		.wait()
206		.expect("Error: Could not scaffold project. Please try again!");
207
208	// Replace the default source dir with our own template files
209	if !should_include_clerk {
210		REMIX_WITHOUT_CLERK_DIR
211			.extract(current_working_directory.join(project_name).clone())
212			.unwrap();
213	} else {
214		REMIX_DIR.extract(current_working_directory.join(project_name).clone()).unwrap();
215	}
216
217	// Rename cargo.toml file (We have to set it to Cargo__toml due to a random bug with cargo publish command in a workspace)
218	StdCommand::new("sh")
219		.current_dir(current_directory().join(project_name))
220		.arg("-c")
221		.arg(format!("mv Cargo__toml Cargo.toml"))
222		.spawn()
223		.unwrap()
224		.wait()
225		.expect("Error: Could not scaffold project. Please try again!");
226
227	// Sleep a little to show loading animation, etc (there is a nice one we could use from the "tui" crate)
228	let timeout = time::Duration::from_millis(1000);
229	thread::sleep(timeout);
230
231	// stop showing the loader
232	loading.succeed("Initialized!");
233
234	// Take the package manager and run the install command
235	let loading = Spinach::new(format!("{}", "Installing dependencies...".color(Color::LightCyan)));
236
237	StdCommand::new("sh")
238		.current_dir(current_directory().join(project_name))
239		.arg("-c")
240		.arg(format!("{} install > /dev/null 2>&1", package_manager))
241		.spawn()
242		.unwrap()
243		.wait()
244		.expect("Error: Could not install project dependencies!");
245
246	loading.succeed("Installed dependencies!");
247
248	println!(
249		"\n\n{} {} {} {}",
250		format!("{}", rapid_logo()).bold(),
251		"Success".bg_blue().color(Color::White).bold(),
252		BOLT_EMOJI,
253		"Welcome to your new Rapid application with Remix!"
254	);
255
256	println!(
257		"{} {} {} {} {}",
258		"\n\nšŸš€".bold(),
259		"Next Steps".bg_blue().color(Color::White).bold(),
260		BOLT_EMOJI,
261		format!("\n\ncd {}", project_name).bold(),
262		"\nrapid run".bold()
263	);
264}
265
266pub fn init_server_template(current_working_directory: PathBuf, _: &str) {
267	// Ask the user what they want to name their project
268	let project_name = prompt_one(
269		Question::input("project_name")
270			.message("What will your project be called?")
271			.default("my-app")
272			.build(),
273	)
274	.expect("Error: Could not scaffold project. Please try again!");
275
276	let project_name = project_name.as_string().unwrap();
277
278	// Validate that the project name does not contain any invalid chars
279	if !project_name.chars().all(|x| x.is_alphanumeric() || x == '-' || x == '_') {
280		error!("aborting...your project name may only contain alphanumeric characters along with '-' and '_'...");
281		exit(64);
282	}
283
284	let path = current_working_directory.join(project_name);
285
286	// Check if the path already exists (if it does we want to ask the user if they want to delete it)
287	if path.exists() {
288		let force = prompt_one(
289			Question::confirm("force_delete")
290				.message("Your specified directory is not empty and has files currently in it, do you want to overwrite?")
291				.default(false)
292				.build(),
293		)
294		.expect("Error: Could not scaffold project. Please try again!");
295
296		match !force.as_bool().unwrap() {
297			true => {
298				exit(64);
299			}
300			false => {
301				remove_dir_all(&path).expect("Error: Could not scaffold project. The specified directory must be empty. Please try again!");
302			}
303		}
304	}
305
306	println!("{}", indent(1));
307
308	let loading = Spinach::new(format!("{}", "Initializing a new Rapid server application..".color(Color::LightCyan)));
309
310	// Run the cargo commands
311	StdCommand::new("sh")
312		.current_dir(current_directory())
313		.arg("-c")
314		.arg(format!("cargo new {} --quiet", project_name))
315		.spawn()
316		.unwrap()
317		.wait()
318		.expect("Error: Could not scaffold project. Please try again!");
319
320	StdCommand::new("sh")
321		.current_dir(current_directory().join(project_name))
322		.arg("-c")
323		.arg("cargo add rapid-web futures-util include_dir --quiet")
324		.spawn()
325		.unwrap()
326		.wait()
327		.expect("Error: Could not scaffold project. Please try again!");
328
329	// Remove the default src directory
330	remove_dir_all(current_working_directory.join(format!("{}/src", project_name))).unwrap();
331
332	// Replace the default source dir with our own template files
333	PROJECT_DIR.extract(current_working_directory.join(project_name).clone()).unwrap();
334
335	// Sleep a little to show loading animation, etc (there is a nice one we could use from the "tui" crate)
336	let timeout = time::Duration::from_millis(675);
337	thread::sleep(timeout);
338
339	// Stop our loading spinner
340	loading.stop();
341
342	println!(
343		"\n\n{} {} {} {}",
344		format!("{}", rapid_logo()).bold(),
345		"Success".bg_blue().color(Color::White).bold(),
346		BOLT_EMOJI,
347		"Welcome to your new rapid-web server application!"
348	);
349
350	println!(
351		"{} {} {} {} {}",
352		"\n\nšŸš€".bold(),
353		"Next Steps".bg_blue().color(Color::White).bold(),
354		BOLT_EMOJI,
355		format!("\n\ncd {}", project_name).bold(),
356		"\nrapid run".bold()
357	);
358}
359
360
361pub fn init_nextjs_template(current_working_directory: PathBuf) {
362	// Ask the user what they want to name their project
363	let project_name = prompt_one(
364		Question::input("project_name")
365			.message("What will your project be called?")
366			.default("my-app")
367			.build(),
368	)
369	.expect("Error: Could not scaffold project. Please try again!");
370
371	let project_name = project_name.as_string().unwrap();
372
373	// Validate that the project name does not contain any invalid chars
374	if !project_name.chars().all(|x| x.is_alphanumeric() || x == '-' || x == '_') {
375		error!("aborting...your project name may only contain alphanumeric characters along with '-' and '_'...");
376		exit(64);
377	}
378
379	let path = current_working_directory.join(project_name);
380
381	// Check if the path already exists (if it does we want to ask the user if they want to delete it)
382	if path.exists() {
383		let force = prompt_one(
384			Question::confirm("force_delete")
385				.message("Your specified directory is not empty and has files currently in it, do you want to overwrite?")
386				.default(false)
387				.build(),
388		)
389		.expect("Error: Could not scaffold project. Please try again!");
390
391		match !force.as_bool().unwrap() {
392			true => {
393				exit(64);
394			}
395			false => {
396				remove_dir_all(&path).expect("Error: Could not scaffold project. The specified directory must be empty. Please try again!");
397			}
398		}
399	}
400
401	// Before we initialize the project, lets ask the user to specify which package manager they want to use and then run the install process with that package manager
402	let manager_choices = vec!["pnpm", "npm", "yarn"];
403
404	println!("{}", indent(1));
405
406	let package_manager = requestty::Question::select("packageManagerSelect")
407		.message("Which package manager would you like to use?:")
408		.choices(manager_choices)
409		.page_size(6)
410		.build();
411
412	let package_manager = prompt(vec![package_manager]).expect("Error: Could not scaffold project. Please try again!");
413
414	let package_manager = match package_manager.get("packageManagerSelect") {
415		Some(Answer::ListItem(choice)) => choice.text.clone(),
416		_ => {
417			println!(
418				"{}",
419				"Aborting...an error occurred while trying to parse package manager selection. Please try again!"
420					.bold()
421					.color(Color::Red)
422			);
423			exit(64);
424		}
425	};
426
427	println!("{}", indent(1));
428
429	let loading = Spinach::new(format!("{}", "Initializing a new Rapid Nextjs application..".color(Color::LightCyan)));
430
431	// Run the scaffold commands
432	StdCommand::new("sh")
433		.current_dir(current_directory())
434		.arg("-c")
435		.arg(format!("mkdir {}", project_name))
436		.spawn()
437		.unwrap()
438		.wait()
439		.expect("Error: Could not scaffold project. Please try again!");
440
441	// Initialize a git repo
442	StdCommand::new("sh")
443		.current_dir(current_directory().join(project_name))
444		.arg("-c")
445		.arg(format!("git init --quiet"))
446		.spawn()
447		.unwrap()
448		.wait()
449		.expect("Error: Could not scaffold project. Please try again!");
450
451	// Replace the default source dir with our own template files
452	NEXTJS_DIR.extract(current_working_directory.join(project_name).clone()).unwrap();
453
454	// Rename cargo.toml file (We have to set it to Cargo__toml due to a random bug with cargo publish command in a workspace)
455	StdCommand::new("sh")
456		.current_dir(current_directory().join(project_name))
457		.arg("-c")
458		.arg(format!("mv Cargo__toml Cargo.toml"))
459		.spawn()
460		.unwrap()
461		.wait()
462		.expect("Error: Could not scaffold project. Please try again!");
463
464	// Sleep a little to show loading animation, etc (there is a nice one we could use from the "tui" crate)
465	let timeout = time::Duration::from_millis(1000);
466	thread::sleep(timeout);
467
468	// stop showing the loader
469	loading.succeed("Initialized!");
470
471	// Take the package manager and run the install command
472	let loading = Spinach::new(format!("{}", "Installing dependencies...".color(Color::LightCyan)));
473
474	StdCommand::new("sh")
475		.current_dir(current_directory().join(project_name))
476		.arg("-c")
477		.arg(format!("{} install > /dev/null 2>&1", package_manager))
478		.spawn()
479		.unwrap()
480		.wait()
481		.expect("Error: Could not install project dependencies!");
482
483	loading.succeed("Installed dependencies!");
484
485	println!(
486		"\n\n{} {} {} {}",
487		format!("{}", rapid_logo()).bold(),
488		"Success".bg_blue().color(Color::White).bold(),
489		BOLT_EMOJI,
490		"Welcome to your new Rapid application with Nextjs!"
491	);
492
493	println!(
494		"{} {} {} {} {} {}",
495		"\n\nšŸš€".bold(),
496		"Next Steps".bg_blue().color(Color::White).bold(),
497		BOLT_EMOJI,
498		format!("\n\ncd {}", project_name).bold(),
499		"\nrapid run".bold(),
500		format!("\n{} run dev", package_manager).bold()
501	);
502}