1use std::{
2 ffi::OsString,
3 fs::create_dir_all,
4 io::{self, ErrorKind},
5 path::PathBuf,
6};
7
8use anyhow::{bail, Context};
9use cargo_metadata::MetadataCommand;
10use clap::{
11 builder::{OsStringValueParser, PossibleValue, TypedValueParser},
12 Args, Parser, Subcommand, ValueEnum,
13};
14use clap_complete::Shell;
15use shuttle_common::{constants::EXAMPLES_REPO, models::resource::ResourceType};
16
17#[derive(Parser)]
18#[command(
19 version,
20 next_help_heading = "Global options",
21 arg(clap::Arg::new("dummy")
24 .value_parser([PossibleValue::new("shuttle")])
25 .required(false)
26 .hide(true))
27)]
28pub struct ShuttleArgs {
29 #[arg(global = true, long, env = "SHUTTLE_API_ENV", hide = true)]
32 pub api_env: Option<String>,
33 #[arg(global = true, long, env = "SHUTTLE_API", hide = true)]
35 pub api_url: Option<String>,
36 #[arg(global = true, long, env = "SHUTTLE_ADMIN", hide = true)]
38 pub admin: bool,
39 #[arg(global = true, long, env = "SHUTTLE_OFFLINE")]
41 pub offline: bool,
42 #[arg(global = true, long, env = "SHUTTLE_DEBUG")]
44 pub debug: bool,
45 #[arg(
47 global = true,
48 long = "output",
49 env = "SHUTTLE_OUTPUT_MODE",
50 default_value = "normal"
51 )]
52 pub output_mode: OutputMode,
53 #[command(flatten)]
54 pub project_args: ProjectArgs,
55
56 #[command(subcommand)]
57 pub cmd: Command,
58}
59
60#[derive(ValueEnum, Clone, Debug, Default, PartialEq)]
61pub enum OutputMode {
62 #[default]
63 Normal,
64 Json,
65 }
67
68#[derive(Args, Clone, Debug)]
70pub struct ProjectArgs {
71 #[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 let path = MetadataCommand::new()
86 .current_dir(&self.working_directory)
87 .exec()
88 .context("Failed to find a Rust project in this directory. Try again in a cargo workspace, or provide a --name or --id argument.")?
89 .workspace_root
90 .into();
91
92 Ok(path)
93 }
94
95 pub fn project_name(&self) -> anyhow::Result<String> {
96 let workspace_path = self.workspace_path()?;
97
98 let meta = MetadataCommand::new()
102 .current_dir(&workspace_path)
103 .exec()
104 .expect("metadata command to succeed in cargo workspace root");
105 let package_name = if let Some(root_package) = meta.root_package() {
106 root_package.name.clone()
107 } else {
108 workspace_path
109 .file_name()
110 .context("failed to get project name from workspace path")?
111 .to_os_string()
112 .into_string()
113 .expect("workspace directory name should be valid unicode")
114 };
115
116 Ok(package_name)
117 }
118}
119
120#[allow(rustdoc::bare_urls)]
121#[derive(Subcommand)]
125pub enum Command {
126 Init(InitArgs),
128 Run(RunArgs),
130 Deploy(DeployArgs),
132 #[command(subcommand, visible_alias = "depl")]
134 Deployment(DeploymentCommand),
135 Logs(LogsArgs),
137 #[command(subcommand, visible_alias = "proj")]
139 Project(ProjectCommand),
140 #[command(subcommand, visible_alias = "res")]
142 Resource(ResourceCommand),
143 #[command(subcommand, visible_alias = "cert")]
145 Certificate(CertificateCommand),
146 #[command(visible_alias = "acc")]
148 Account,
149 Login(LoginArgs),
151 Logout(LogoutArgs),
153 #[command(subcommand)]
155 Generate(GenerateCommand),
156 Feedback,
158 Upgrade {
160 #[arg(long)]
162 preview: bool,
163 },
164 #[command(subcommand)]
166 Mcp(McpCommand),
167}
168
169#[derive(Subcommand)]
170pub enum McpCommand {
171 Start,
173}
174
175#[derive(Subcommand)]
176pub enum GenerateCommand {
177 Shell {
179 shell: Shell,
181 #[arg(short, long)]
183 output_file: Option<PathBuf>,
184 },
185 Manpage,
187}
188
189#[derive(Args)]
190#[command(next_help_heading = "Table options")]
191pub struct TableArgs {
192 #[arg(long, default_value_t = false)]
194 pub raw: bool,
195}
196
197#[derive(Subcommand)]
198pub enum DeploymentCommand {
199 #[command(visible_alias = "ls")]
201 List {
202 #[arg(long, default_value = "1")]
204 page: u32,
205
206 #[arg(long, default_value = "10", visible_alias = "per-page")]
208 limit: u32,
209
210 #[command(flatten)]
211 table: TableArgs,
212 },
213 #[command(visible_alias = "stat")]
215 Status {
216 id: Option<String>,
218 },
219 Redeploy {
221 id: Option<String>,
223
224 #[command(flatten)]
225 tracking_args: DeploymentTrackingArgs,
226 },
227 Stop {
229 #[command(flatten)]
230 tracking_args: DeploymentTrackingArgs,
231 },
232}
233
234#[derive(Subcommand)]
235pub enum ResourceCommand {
236 #[command(visible_alias = "ls")]
238 List {
239 #[arg(long, default_value_t = false)]
241 show_secrets: bool,
242
243 #[command(flatten)]
244 table: TableArgs,
245 },
246 #[command(visible_alias = "rm")]
248 Delete {
249 resource_type: ResourceType,
253 #[command(flatten)]
254 confirmation: ConfirmationArgs,
255 },
256 #[command(hide = true)] Dump {
259 resource_type: ResourceType,
263 },
264}
265
266#[derive(Subcommand)]
267pub enum CertificateCommand {
268 Add {
270 domain: String,
272 },
273 #[command(visible_alias = "ls")]
275 List {
276 #[command(flatten)]
277 table: TableArgs,
278 },
279 #[command(visible_alias = "rm")]
281 Delete {
282 domain: String,
284 #[command(flatten)]
285 confirmation: ConfirmationArgs,
286 },
287}
288
289#[derive(Subcommand)]
290pub enum ProjectCommand {
291 #[command(visible_alias = "start")]
293 Create,
294 #[command(subcommand, visible_alias = "upd")]
296 Update(ProjectUpdateCommand),
297 #[command(visible_alias = "stat")]
299 Status,
300 #[command(visible_alias = "ls")]
302 List {
303 #[command(flatten)]
304 table: TableArgs,
305 },
306 #[command(visible_alias = "rm")]
308 Delete(ConfirmationArgs),
309 Link,
311}
312
313#[derive(Subcommand, Debug)]
314pub enum ProjectUpdateCommand {
315 Name { name: String },
317}
318
319#[derive(Args, Debug)]
320pub struct ConfirmationArgs {
321 #[arg(long, short, default_value_t = false)]
323 pub yes: bool,
324}
325
326#[derive(Args, Clone, Debug, Default)]
327#[command(next_help_heading = "Login options")]
328pub struct LoginArgs {
329 #[arg(long, conflicts_with = "api_key", alias = "input")]
331 pub prompt: bool,
332 #[arg(long)]
334 pub api_key: Option<String>,
335 #[arg(long, env = "SHUTTLE_CONSOLE", hide = true)]
337 pub console_url: Option<String>,
338}
339
340#[derive(Args, Clone, Debug)]
341pub struct LogoutArgs {
342 #[arg(long)]
344 pub reset_api_key: bool,
345}
346
347#[derive(Args, Default)]
348pub struct DeployArgs {
349 #[arg(long, short = 'i', hide = true)]
351 pub image: Option<String>,
352
353 #[arg(long, visible_alias = "ad")]
355 pub allow_dirty: bool,
356 #[arg(long)]
358 pub output_archive: Option<PathBuf>,
359
360 #[command(flatten)]
361 pub tracking_args: DeploymentTrackingArgs,
362
363 #[command(flatten)]
364 pub secret_args: SecretsArgs,
365}
366#[derive(Args, Default)]
367pub struct DeploymentTrackingArgs {
368 #[arg(long, visible_alias = "nf")]
370 pub no_follow: bool,
371 #[arg(long)]
373 pub raw: bool,
374}
375
376#[derive(Args, Debug)]
377pub struct RunArgs {
378 #[arg(long, short = 'p', env, default_value = "8000")]
380 pub port: u16,
381 #[arg(long)]
383 pub external: bool,
384 #[arg(long, short = 'r')]
386 pub release: bool,
387 #[arg(long)]
389 pub raw: bool,
390 #[arg(long)]
392 pub bacon: bool,
393
394 #[command(flatten)]
395 pub secret_args: SecretsArgs,
396}
397
398#[derive(Args, Debug, Default)]
399pub struct SecretsArgs {
400 #[arg(long, value_parser = OsStringValueParser::new().try_map(parse_path))]
402 pub secrets: Option<PathBuf>,
403}
404
405#[derive(Args, Clone, Debug, Default)]
406pub struct InitArgs {
407 #[arg(long, short, value_enum, conflicts_with_all = &["from", "subfolder"])]
409 pub template: Option<InitTemplateArg>,
410 #[arg(long)]
412 pub from: Option<String>,
413 #[arg(long, requires = "from")]
415 pub subfolder: Option<String>,
416
417 #[arg(default_value = ".", value_parser = OsStringValueParser::new().try_map(create_and_parse_path))]
419 pub path: PathBuf,
420
421 #[arg(long)]
423 pub force_name: bool,
424 #[arg(long, visible_alias = "create_env")]
426 pub create_project: bool,
427 #[arg(long)]
429 pub no_git: bool,
430
431 #[command(flatten)]
432 pub login_args: LoginArgs,
433}
434
435#[derive(ValueEnum, Clone, Debug, strum::EnumMessage, strum::VariantArray)]
436pub enum InitTemplateArg {
437 Axum,
439 ActixWeb,
441 Rocket,
443 Loco,
445 Salvo,
447 Poem,
449 Poise,
451 Rama,
453 Serenity,
455 Tower,
457 Thruster,
459 Tide,
461 Warp,
463 None,
465}
466
467#[derive(Clone, Debug, PartialEq)]
468pub struct TemplateLocation {
469 pub auto_path: String,
470 pub subfolder: Option<String>,
471}
472
473impl InitArgs {
474 pub fn git_template(&self) -> anyhow::Result<Option<TemplateLocation>> {
475 if self.from.is_some() && self.template.is_some() {
476 bail!("Template and From args can not be set at the same time.");
477 }
478 Ok(if let Some(from) = self.from.clone() {
479 Some(TemplateLocation {
480 auto_path: from,
481 subfolder: self.subfolder.clone(),
482 })
483 } else {
484 self.template.as_ref().map(|t| t.template())
485 })
486 }
487}
488
489impl InitTemplateArg {
490 pub fn template(&self) -> TemplateLocation {
491 use InitTemplateArg::*;
492 let path = match self {
493 ActixWeb => "actix-web/hello-world",
494 Axum => "axum/hello-world",
495 Loco => "loco/hello-world",
496 Poem => "poem/hello-world",
497 Poise => "poise/hello-world",
498 Rocket => "rocket/hello-world",
499 Salvo => "salvo/hello-world",
500 Rama => "rama/hello-world",
501 Serenity => "serenity/hello-world",
502 Thruster => "thruster/hello-world",
503 Tide => "tide/hello-world",
504 Tower => "tower/hello-world",
505 Warp => "warp/hello-world",
506 None => "custom-service/none",
507 };
508
509 TemplateLocation {
510 auto_path: EXAMPLES_REPO.into(),
511 subfolder: Some(path.to_string()),
512 }
513 }
514}
515
516#[derive(Args, Clone, Debug, Default)]
517pub struct LogsArgs {
518 pub id: Option<String>,
520 #[arg(short, long)]
521 pub latest: bool,
523 #[arg(short, long, hide = true)]
524 pub follow: bool,
526 #[arg(long)]
528 pub raw: bool,
529 #[arg(long, group = "pagination", hide = true)]
531 pub head: Option<u32>,
532 #[arg(long, group = "pagination", hide = true)]
534 pub tail: Option<u32>,
535 #[arg(long, group = "pagination", hide = true)]
537 pub all: bool,
538 #[arg(long, hide = true)]
540 pub all_deployments: bool,
541}
542
543fn parse_path(path: OsString) -> Result<PathBuf, io::Error> {
545 dunce::canonicalize(&path).map_err(|e| {
546 io::Error::new(
547 ErrorKind::InvalidInput,
548 format!("could not turn {path:?} into a real path: {e}"),
549 )
550 })
551}
552
553pub(crate) fn create_and_parse_path(path: OsString) -> Result<PathBuf, io::Error> {
555 create_dir_all(&path).map_err(|e| {
557 io::Error::new(
558 ErrorKind::InvalidInput,
559 format!("Could not create directory: {e}"),
560 )
561 })?;
562
563 parse_path(path)
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::tests::path_from_workspace_root;
570 use clap::CommandFactory;
571
572 #[test]
573 fn test_shuttle_args() {
574 ShuttleArgs::command().debug_assert();
575 }
576
577 #[test]
578 fn test_init_args_framework() {
579 let init_args = InitArgs {
581 template: Some(InitTemplateArg::Tower),
582 from: None,
583 subfolder: None,
584 ..Default::default()
585 };
586 assert_eq!(
587 init_args.git_template().unwrap(),
588 Some(TemplateLocation {
589 auto_path: EXAMPLES_REPO.into(),
590 subfolder: Some("tower/hello-world".into())
591 })
592 );
593
594 let init_args = InitArgs {
596 template: Some(InitTemplateArg::Axum),
597 from: None,
598 subfolder: None,
599 ..Default::default()
600 };
601 assert_eq!(
602 init_args.git_template().unwrap(),
603 Some(TemplateLocation {
604 auto_path: EXAMPLES_REPO.into(),
605 subfolder: Some("axum/hello-world".into())
606 })
607 );
608
609 let init_args = InitArgs {
611 template: Some(InitTemplateArg::None),
612 from: None,
613 subfolder: None,
614 ..Default::default()
615 };
616 assert_eq!(
617 init_args.git_template().unwrap(),
618 Some(TemplateLocation {
619 auto_path: EXAMPLES_REPO.into(),
620 subfolder: Some("custom-service/none".into())
621 })
622 );
623
624 let init_args = InitArgs {
626 template: None,
627 from: Some("https://github.com/some/repo".into()),
628 subfolder: Some("some/path".into()),
629 ..Default::default()
630 };
631 assert_eq!(
632 init_args.git_template().unwrap(),
633 Some(TemplateLocation {
634 auto_path: "https://github.com/some/repo".into(),
635 subfolder: Some("some/path".into())
636 })
637 );
638
639 let init_args = InitArgs {
641 template: None,
642 from: None,
643 subfolder: None,
644 ..Default::default()
645 };
646 assert_eq!(init_args.git_template().unwrap(), None);
647 }
648
649 #[test]
650 fn workspace_path() {
651 let project_args = ProjectArgs {
652 working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
653 name: None,
654 id: None,
655 };
656
657 assert_eq!(
658 project_args.workspace_path().unwrap(),
659 path_from_workspace_root("examples/axum/hello-world/")
660 );
661 }
662
663 #[test]
664 fn project_name() {
665 let project_args = ProjectArgs {
666 working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
667 name: None,
668 id: None,
669 };
670
671 assert_eq!(
672 project_args.project_name().unwrap().to_string(),
673 "hello-world"
674 );
675 }
676
677 #[test]
678 fn project_name_in_workspace() {
679 let project_args = ProjectArgs {
680 working_directory: path_from_workspace_root(
681 "examples/rocket/workspace/hello-world/src",
682 ),
683 name: None,
684 id: None,
685 };
686
687 assert_eq!(
688 project_args.project_name().unwrap().to_string(),
689 "workspace"
690 );
691 }
692}