1use std::{
2 ffi::OsString,
3 fs::create_dir_all,
4 io::{self, ErrorKind},
5 path::PathBuf,
6};
7
8use anyhow::{bail, Context};
9use clap::{
10 builder::{OsStringValueParser, PossibleValue, TypedValueParser},
11 Args, Parser, Subcommand, ValueEnum,
12};
13use clap_complete::Shell;
14use shuttle_common::{constants::EXAMPLES_REPO, models::resource::ResourceType};
15
16use crate::util::cargo_metadata;
17
18#[derive(Parser)]
19#[command(
20 version,
21 next_help_heading = "Global options",
22 arg(clap::Arg::new("dummy")
25 .value_parser([PossibleValue::new("shuttle")])
26 .required(false)
27 .hide(true))
28)]
29pub struct ShuttleArgs {
30 #[arg(global = true, long, env = "SHUTTLE_API_ENV", hide = true)]
33 pub api_env: Option<String>,
34 #[arg(global = true, long, env = "SHUTTLE_API", hide = true)]
36 pub api_url: Option<String>,
37 #[arg(global = true, long, env = "SHUTTLE_ADMIN", hide = true)]
39 pub admin: bool,
40 #[arg(global = true, long, env = "SHUTTLE_OFFLINE")]
42 pub offline: bool,
43 #[arg(global = true, long, env = "SHUTTLE_DEBUG")]
45 pub debug: bool,
46 #[arg(
48 global = true,
49 long = "output",
50 env = "SHUTTLE_OUTPUT_MODE",
51 default_value = "normal"
52 )]
53 pub output_mode: OutputMode,
54 #[command(flatten)]
55 pub project_args: ProjectArgs,
56
57 #[command(subcommand)]
58 pub cmd: Command,
59}
60
61#[derive(ValueEnum, Clone, Debug, Default, PartialEq)]
62pub enum OutputMode {
63 #[default]
64 Normal,
65 Json,
66 }
68
69#[derive(Args, Clone, Debug)]
71pub struct ProjectArgs {
72 #[arg(global = true, long, visible_alias = "wd", default_value = ".", value_parser = OsStringValueParser::new().try_map(parse_path))]
73 pub working_directory: PathBuf,
74 #[arg(global = true, long)]
76 pub name: Option<String>,
77 #[arg(global = true, long)]
79 pub id: Option<String>,
80}
81
82impl ProjectArgs {
83 pub fn workspace_path(&self) -> anyhow::Result<PathBuf> {
84 cargo_metadata(self.working_directory.as_path()).map(|meta| meta.workspace_root.into())
85 }
86
87 pub fn project_name(&self) -> anyhow::Result<String> {
88 let workspace_path = self.workspace_path()?;
89 let meta = cargo_metadata(workspace_path.as_path())?;
92
93 let package_name = if let Some(root_package) = meta.root_package() {
94 root_package.name.to_string()
95 } else {
96 workspace_path
97 .file_name()
98 .context("failed to get project name from workspace path")?
99 .to_os_string()
100 .into_string()
101 .expect("workspace directory name should be valid unicode")
102 };
103
104 Ok(package_name)
105 }
106}
107
108#[allow(rustdoc::bare_urls)]
109#[derive(Subcommand)]
113pub enum Command {
114 #[command(visible_alias = "i")]
116 Init(InitArgs),
117 #[command(visible_alias = "r")]
119 Run(RunArgs),
120 #[command(visible_alias = "b", hide = true)]
122 Build(BuildArgs),
123 #[command(visible_alias = "d")]
125 Deploy(DeployArgs),
126 #[command(subcommand, visible_alias = "depl")]
128 Deployment(DeploymentCommand),
129 Logs(LogsArgs),
131 #[command(subcommand, visible_alias = "proj")]
133 Project(ProjectCommand),
134 #[command(subcommand, visible_alias = "res")]
136 Resource(ResourceCommand),
137 #[command(subcommand, visible_alias = "cert")]
139 Certificate(CertificateCommand),
140 #[command(visible_alias = "acc")]
142 Account,
143 Login(LoginArgs),
145 Logout(LogoutArgs),
147 #[command(subcommand)]
149 Generate(GenerateCommand),
150 Feedback,
152 Upgrade {
154 #[arg(long)]
156 preview: bool,
157 },
158 #[command(subcommand)]
160 Mcp(McpCommand),
161}
162
163#[derive(Subcommand)]
164pub enum McpCommand {
165 Start,
167}
168
169#[derive(Subcommand)]
170pub enum GenerateCommand {
171 Shell {
173 shell: Shell,
175 #[arg(short, long)]
177 output_file: Option<PathBuf>,
178 },
179 Manpage,
181}
182
183#[derive(Args)]
184#[command(next_help_heading = "Table options")]
185pub struct TableArgs {
186 #[arg(long, default_value_t = false)]
188 pub raw: bool,
189}
190
191#[derive(Subcommand)]
192pub enum DeploymentCommand {
193 #[command(visible_alias = "ls")]
195 List {
196 #[arg(long, default_value = "1")]
198 page: u32,
199
200 #[arg(long, default_value = "10", visible_alias = "per-page")]
202 limit: u32,
203
204 #[command(flatten)]
205 table: TableArgs,
206 },
207 #[command(visible_alias = "stat")]
209 Status {
210 deployment_id: Option<String>,
212 },
213 Redeploy {
215 deployment_id: Option<String>,
217
218 #[command(flatten)]
219 tracking_args: DeploymentTrackingArgs,
220 },
221 Stop {
223 #[command(flatten)]
224 tracking_args: DeploymentTrackingArgs,
225 },
226}
227
228#[derive(Subcommand)]
229pub enum ResourceCommand {
230 #[command(visible_alias = "ls")]
232 List {
233 #[arg(long, default_value_t = false)]
235 show_secrets: bool,
236
237 #[command(flatten)]
238 table: TableArgs,
239 },
240 #[command(visible_alias = "rm")]
242 Delete {
243 resource_type: ResourceType,
247 #[command(flatten)]
248 confirmation: ConfirmationArgs,
249 },
250 #[command(hide = true)] Dump {
253 resource_type: ResourceType,
257 },
258}
259
260#[derive(Subcommand)]
261pub enum CertificateCommand {
262 Add {
264 domain: String,
266 },
267 #[command(visible_alias = "ls")]
269 List {
270 #[command(flatten)]
271 table: TableArgs,
272 },
273 #[command(visible_alias = "rm")]
275 Delete {
276 domain: String,
278 #[command(flatten)]
279 confirmation: ConfirmationArgs,
280 },
281}
282
283#[derive(Subcommand)]
284pub enum ProjectCommand {
285 #[command(visible_alias = "start")]
287 Create,
288 #[command(subcommand, visible_alias = "upd")]
290 Update(ProjectUpdateCommand),
291 #[command(visible_alias = "stat")]
293 Status,
294 #[command(visible_alias = "ls")]
296 List {
297 #[command(flatten)]
298 table: TableArgs,
299 },
300 #[command(visible_alias = "rm")]
302 Delete(ConfirmationArgs),
303 Link,
305}
306
307#[derive(Subcommand, Debug)]
308pub enum ProjectUpdateCommand {
309 Name { new_name: String },
311}
312
313#[derive(Args, Debug)]
314pub struct ConfirmationArgs {
315 #[arg(long, short, default_value_t = false)]
317 pub yes: bool,
318}
319
320#[derive(Args, Clone, Debug, Default)]
321#[command(next_help_heading = "Login options")]
322pub struct LoginArgs {
323 #[arg(long, conflicts_with = "api_key", alias = "input")]
325 pub prompt: bool,
326 #[arg(long)]
328 pub api_key: Option<String>,
329 #[arg(long, env = "SHUTTLE_CONSOLE", hide = true)]
331 pub console_url: Option<String>,
332}
333
334#[derive(Args, Clone, Debug)]
335pub struct LogoutArgs {
336 #[arg(long)]
338 pub reset_api_key: bool,
339}
340
341#[derive(Args, Default)]
342pub struct DeployArgs {
343 #[arg(long, short = 'i', hide = true)]
345 pub image: Option<String>,
346
347 #[arg(long, visible_alias = "ad")]
349 pub allow_dirty: bool,
350 #[arg(long)]
352 pub output_archive: Option<PathBuf>,
353
354 #[command(flatten)]
355 pub tracking_args: DeploymentTrackingArgs,
356
357 #[command(flatten)]
358 pub secret_args: SecretsArgs,
359}
360#[derive(Args, Default)]
361pub struct DeploymentTrackingArgs {
362 #[arg(long, visible_alias = "nf")]
364 pub no_follow: bool,
365 #[arg(long)]
367 pub raw: bool,
368}
369
370#[derive(Args, Debug, Default)]
371pub struct RunArgs {
372 #[arg(long, short = 'p', env, default_value = "8000")]
374 pub port: u16,
375 #[arg(long)]
377 pub external: bool,
378 #[arg(long)]
380 pub raw: bool,
381
382 #[command(flatten)]
383 pub secret_args: SecretsArgs,
384 #[command(flatten)]
385 pub build_args: BuildArgsShared,
386}
387
388#[derive(Args, Debug, Default)]
389pub struct BuildArgs {
390 #[arg(long)]
392 pub output_archive: Option<PathBuf>,
393 #[command(flatten)]
394 pub inner: BuildArgsShared,
395}
396
397#[derive(Args, Debug, Default)]
399pub struct BuildArgsShared {
400 #[arg(long, short = 'r')]
402 pub release: bool,
403 #[arg(long)]
405 pub bacon: bool,
406
407 #[arg(long, hide = true)]
410 pub docker: bool,
411 #[arg(long, short = 't', requires = "docker", hide = true)]
413 pub tag: Option<String>,
414}
415
416#[derive(Args, Debug, Default)]
417pub struct SecretsArgs {
418 #[arg(long, value_parser = OsStringValueParser::new().try_map(parse_path))]
420 pub secrets: Option<PathBuf>,
421}
422
423#[derive(Args, Clone, Debug, Default)]
424pub struct InitArgs {
425 #[arg(long, short, value_enum, conflicts_with_all = &["from", "subfolder"])]
427 pub template: Option<InitTemplateArg>,
428 #[arg(long)]
430 pub from: Option<String>,
431 #[arg(long, requires = "from")]
433 pub subfolder: Option<String>,
434
435 #[arg(default_value = ".", value_parser = OsStringValueParser::new().try_map(create_and_parse_path))]
437 pub path: PathBuf,
438
439 #[arg(long)]
441 pub force_name: bool,
442 #[arg(long, visible_alias = "create_env")]
444 pub create_project: bool,
445 #[arg(long)]
447 pub no_git: bool,
448
449 #[command(flatten)]
450 pub login_args: LoginArgs,
451}
452
453#[derive(ValueEnum, Clone, Debug, strum::EnumMessage, strum::VariantArray)]
454pub enum InitTemplateArg {
455 Axum,
457 ActixWeb,
459 Rocket,
461 Loco,
463 Salvo,
465 Poem,
467 Poise,
469 Rama,
471 Serenity,
473 Tower,
475 Warp,
477 None,
479}
480
481#[derive(Clone, Debug, PartialEq)]
482pub struct TemplateLocation {
483 pub auto_path: String,
484 pub subfolder: Option<String>,
485}
486
487impl InitArgs {
488 pub fn git_template(&self) -> anyhow::Result<Option<TemplateLocation>> {
489 if self.from.is_some() && self.template.is_some() {
490 bail!("Template and From args can not be set at the same time.");
491 }
492 Ok(if let Some(from) = self.from.clone() {
493 Some(TemplateLocation {
494 auto_path: from,
495 subfolder: self.subfolder.clone(),
496 })
497 } else {
498 self.template.as_ref().map(|t| t.template())
499 })
500 }
501}
502
503impl InitTemplateArg {
504 pub fn template(&self) -> TemplateLocation {
505 use InitTemplateArg::*;
506 let path = match self {
507 ActixWeb => "actix-web/hello-world",
508 Axum => "axum/hello-world",
509 Loco => "loco/hello-world",
510 Poem => "poem/hello-world",
511 Poise => "poise/hello-world",
512 Rocket => "rocket/hello-world",
513 Salvo => "salvo/hello-world",
514 Rama => "rama/hello-world",
515 Serenity => "serenity/hello-world",
516 Tower => "tower/hello-world",
517 Warp => "warp/hello-world",
518 None => "custom-service/none",
519 };
520
521 TemplateLocation {
522 auto_path: EXAMPLES_REPO.into(),
523 subfolder: Some(path.to_string()),
524 }
525 }
526}
527
528#[derive(Args, Clone, Debug, Default)]
529pub struct LogsArgs {
530 pub deployment_id: Option<String>,
532 #[arg(short, long)]
533 pub latest: bool,
535 #[arg(short, long, hide = true)]
536 pub follow: bool,
538 #[arg(long)]
540 pub raw: bool,
541 #[arg(long, group = "pagination", hide = true)]
543 pub head: Option<u32>,
544 #[arg(long, group = "pagination", hide = true)]
546 pub tail: Option<u32>,
547 #[arg(long, group = "pagination", hide = true)]
549 pub all: bool,
550 #[arg(long, hide = true)]
552 pub all_deployments: bool,
553}
554
555fn parse_path(path: OsString) -> Result<PathBuf, io::Error> {
557 dunce::canonicalize(&path).map_err(|e| {
558 io::Error::new(
559 ErrorKind::InvalidInput,
560 format!("could not turn {path:?} into a real path: {e}"),
561 )
562 })
563}
564
565pub(crate) fn create_and_parse_path(path: OsString) -> Result<PathBuf, io::Error> {
567 create_dir_all(&path).map_err(|e| {
569 io::Error::new(
570 ErrorKind::InvalidInput,
571 format!("Could not create directory: {e}"),
572 )
573 })?;
574
575 parse_path(path)
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581 use crate::tests::path_from_workspace_root;
582 use clap::CommandFactory;
583
584 #[test]
585 fn test_shuttle_args() {
586 ShuttleArgs::command().debug_assert();
587 }
588
589 #[test]
590 fn test_init_args_framework() {
591 let init_args = InitArgs {
593 template: Some(InitTemplateArg::Tower),
594 from: None,
595 subfolder: None,
596 ..Default::default()
597 };
598 assert_eq!(
599 init_args.git_template().unwrap(),
600 Some(TemplateLocation {
601 auto_path: EXAMPLES_REPO.into(),
602 subfolder: Some("tower/hello-world".into())
603 })
604 );
605
606 let init_args = InitArgs {
608 template: Some(InitTemplateArg::Axum),
609 from: None,
610 subfolder: None,
611 ..Default::default()
612 };
613 assert_eq!(
614 init_args.git_template().unwrap(),
615 Some(TemplateLocation {
616 auto_path: EXAMPLES_REPO.into(),
617 subfolder: Some("axum/hello-world".into())
618 })
619 );
620
621 let init_args = InitArgs {
623 template: Some(InitTemplateArg::None),
624 from: None,
625 subfolder: None,
626 ..Default::default()
627 };
628 assert_eq!(
629 init_args.git_template().unwrap(),
630 Some(TemplateLocation {
631 auto_path: EXAMPLES_REPO.into(),
632 subfolder: Some("custom-service/none".into())
633 })
634 );
635
636 let init_args = InitArgs {
638 template: None,
639 from: Some("https://github.com/some/repo".into()),
640 subfolder: Some("some/path".into()),
641 ..Default::default()
642 };
643 assert_eq!(
644 init_args.git_template().unwrap(),
645 Some(TemplateLocation {
646 auto_path: "https://github.com/some/repo".into(),
647 subfolder: Some("some/path".into())
648 })
649 );
650
651 let init_args = InitArgs {
653 template: None,
654 from: None,
655 subfolder: None,
656 ..Default::default()
657 };
658 assert_eq!(init_args.git_template().unwrap(), None);
659 }
660
661 #[test]
662 fn workspace_path() {
663 let project_args = ProjectArgs {
664 working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
665 name: None,
666 id: None,
667 };
668
669 assert_eq!(
670 project_args.workspace_path().unwrap(),
671 path_from_workspace_root("examples/axum/hello-world/")
672 );
673 }
674
675 #[test]
676 fn project_name() {
677 let project_args = ProjectArgs {
678 working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
679 name: None,
680 id: None,
681 };
682
683 assert_eq!(
684 project_args.project_name().unwrap().to_string(),
685 "hello-world"
686 );
687 }
688
689 #[test]
690 fn project_name_in_workspace() {
691 let project_args = ProjectArgs {
692 working_directory: path_from_workspace_root(
693 "examples/rocket/workspace/hello-world/src",
694 ),
695 name: None,
696 id: None,
697 };
698
699 assert_eq!(
700 project_args.project_name().unwrap().to_string(),
701 "workspace"
702 );
703 }
704}