cargo_shuttle/
args.rs

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    // When running 'cargo shuttle', Cargo passes in the subcommand name to the invoked executable.
23    // Use a hidden, optional positional argument to deal with it.
24    arg(clap::Arg::new("dummy")
25        .value_parser([PossibleValue::new("shuttle")])
26        .required(false)
27        .hide(true))
28)]
29pub struct ShuttleArgs {
30    /// Target a different Shuttle API env (use a separate global config) (default: None (= prod = production))
31    // ("SHUTTLE_ENV" is used for user-facing environments (agnostic of Shuttle API env))
32    #[arg(global = true, long, env = "SHUTTLE_API_ENV", hide = true)]
33    pub api_env: Option<String>,
34    /// URL for the Shuttle API to target (overrides inferred URL from api_env)
35    #[arg(global = true, long, env = "SHUTTLE_API", hide = true)]
36    pub api_url: Option<String>,
37    /// Modify Shuttle API URL to use admin endpoints
38    #[arg(global = true, long, env = "SHUTTLE_ADMIN", hide = true)]
39    pub admin: bool,
40    /// Disable network requests that are not strictly necessary. Limits some features.
41    #[arg(global = true, long, env = "SHUTTLE_OFFLINE")]
42    pub offline: bool,
43    /// Turn on tracing output for Shuttle libraries. (WARNING: can print sensitive data)
44    #[arg(global = true, long, env = "SHUTTLE_DEBUG")]
45    pub debug: bool,
46    /// What format to print output in
47    #[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    // TODO?: add table / non-table / raw table / raw logs variants?
67}
68
69/// Global project-related options
70#[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    /// The name of the project to target or create
75    #[arg(global = true, long)]
76    pub name: Option<String>,
77    /// The id of the project to target
78    #[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        // This second call to cargo metadata in the workspace root seems superfluous,
90        // but it does give a different output if the previous one was run in a workspace member.
91        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/// CLI for the Shuttle platform (https://www.shuttle.dev/)
110///
111/// See the CLI docs for more information: https://docs.shuttle.dev/guides/cli
112#[derive(Subcommand)]
113pub enum Command {
114    /// Generate a Shuttle project from a template
115    #[command(visible_alias = "i")]
116    Init(InitArgs),
117    /// Run a project locally
118    #[command(visible_alias = "r")]
119    Run(RunArgs),
120    /// Build a project
121    #[command(visible_alias = "b", hide = true)]
122    Build(BuildArgs),
123    /// Deploy a project
124    #[command(visible_alias = "d")]
125    Deploy(DeployArgs),
126    /// Manage deployments
127    #[command(subcommand, visible_alias = "depl")]
128    Deployment(DeploymentCommand),
129    /// View build and deployment logs
130    Logs(LogsArgs),
131    /// Manage Shuttle projects
132    #[command(subcommand, visible_alias = "proj")]
133    Project(ProjectCommand),
134    /// Manage resources
135    #[command(subcommand, visible_alias = "res")]
136    Resource(ResourceCommand),
137    /// Manage SSL certificates for custom domains
138    #[command(subcommand, visible_alias = "cert")]
139    Certificate(CertificateCommand),
140    /// Show info about your Shuttle account
141    #[command(visible_alias = "acc")]
142    Account,
143    /// Log in to the Shuttle platform
144    Login(LoginArgs),
145    /// Log out of the Shuttle platform
146    Logout(LogoutArgs),
147    /// Generate shell completions and man page
148    #[command(subcommand)]
149    Generate(GenerateCommand),
150    /// Open an issue on GitHub and provide feedback
151    Feedback,
152    /// Upgrade the Shuttle CLI binary
153    Upgrade {
154        /// Install an unreleased version from the repository's main branch
155        #[arg(long)]
156        preview: bool,
157    },
158    /// Commands for the Shuttle MCP server
159    #[command(subcommand)]
160    Mcp(McpCommand),
161}
162
163#[derive(Subcommand)]
164pub enum McpCommand {
165    /// Start the Shuttle MCP server
166    Start,
167}
168
169#[derive(Subcommand)]
170pub enum GenerateCommand {
171    /// Generate shell completions
172    Shell {
173        /// The shell to generate shell completion for
174        shell: Shell,
175        /// Output to a file (stdout by default)
176        #[arg(short, long)]
177        output_file: Option<PathBuf>,
178    },
179    /// Generate man page to the standard output
180    Manpage,
181}
182
183#[derive(Args)]
184#[command(next_help_heading = "Table options")]
185pub struct TableArgs {
186    /// Output tables without borders
187    #[arg(long, default_value_t = false)]
188    pub raw: bool,
189}
190
191#[derive(Subcommand)]
192pub enum DeploymentCommand {
193    /// List the deployments for a service
194    #[command(visible_alias = "ls")]
195    List {
196        /// Which page to display
197        #[arg(long, default_value = "1")]
198        page: u32,
199
200        /// How many deployments per page to display
201        #[arg(long, default_value = "10", visible_alias = "per-page")]
202        limit: u32,
203
204        #[command(flatten)]
205        table: TableArgs,
206    },
207    /// View status of a deployment
208    #[command(visible_alias = "stat")]
209    Status {
210        /// ID of deployment to get status for
211        deployment_id: Option<String>,
212    },
213    /// Redeploy a previous deployment (if possible)
214    Redeploy {
215        /// ID of deployment to redeploy
216        deployment_id: Option<String>,
217
218        #[command(flatten)]
219        tracking_args: DeploymentTrackingArgs,
220    },
221    /// Stop running deployment(s)
222    Stop {
223        #[command(flatten)]
224        tracking_args: DeploymentTrackingArgs,
225    },
226}
227
228#[derive(Subcommand)]
229pub enum ResourceCommand {
230    /// List the resources for a project
231    #[command(visible_alias = "ls")]
232    List {
233        /// Show secrets from resources (e.g. a password in a connection string)
234        #[arg(long, default_value_t = false)]
235        show_secrets: bool,
236
237        #[command(flatten)]
238        table: TableArgs,
239    },
240    /// Delete a resource
241    #[command(visible_alias = "rm")]
242    Delete {
243        /// Type of the resource to delete.
244        /// Use the string in the 'Type' column as displayed in the `resource list` command.
245        /// For example, 'database::shared::postgres'.
246        resource_type: ResourceType,
247        #[command(flatten)]
248        confirmation: ConfirmationArgs,
249    },
250    /// Dump a resource
251    #[command(hide = true)] // not yet supported on shuttle.dev
252    Dump {
253        /// Type of the resource to dump.
254        /// Use the string in the 'Type' column as displayed in the `resource list` command.
255        /// For example, 'database::shared::postgres'.
256        resource_type: ResourceType,
257    },
258}
259
260#[derive(Subcommand)]
261pub enum CertificateCommand {
262    /// Add an SSL certificate for a custom domain
263    Add {
264        /// Domain name
265        domain: String,
266    },
267    /// List the certificates for a project
268    #[command(visible_alias = "ls")]
269    List {
270        #[command(flatten)]
271        table: TableArgs,
272    },
273    /// Delete an SSL certificate
274    #[command(visible_alias = "rm")]
275    Delete {
276        /// Domain name
277        domain: String,
278        #[command(flatten)]
279        confirmation: ConfirmationArgs,
280    },
281}
282
283#[derive(Subcommand)]
284pub enum ProjectCommand {
285    /// Create a project on Shuttle
286    #[command(visible_alias = "start")]
287    Create,
288    /// Update project config
289    #[command(subcommand, visible_alias = "upd")]
290    Update(ProjectUpdateCommand),
291    /// Get the status of this project on Shuttle
292    #[command(visible_alias = "stat")]
293    Status,
294    /// List all projects you have access to
295    #[command(visible_alias = "ls")]
296    List {
297        #[command(flatten)]
298        table: TableArgs,
299    },
300    /// Delete a project and all linked data
301    #[command(visible_alias = "rm")]
302    Delete(ConfirmationArgs),
303    /// Link this workspace to a Shuttle project
304    Link,
305}
306
307#[derive(Subcommand, Debug)]
308pub enum ProjectUpdateCommand {
309    /// Rename the project, including its default subdomain
310    Name { new_name: String },
311}
312
313#[derive(Args, Debug)]
314pub struct ConfirmationArgs {
315    /// Skip confirmations and proceed
316    #[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    /// Prompt to paste the API key instead of opening the browser
324    #[arg(long, conflicts_with = "api_key", alias = "input")]
325    pub prompt: bool,
326    /// Log in with this Shuttle API key
327    #[arg(long)]
328    pub api_key: Option<String>,
329    /// URL to the Shuttle Console for automatic login
330    #[arg(long, env = "SHUTTLE_CONSOLE", hide = true)]
331    pub console_url: Option<String>,
332}
333
334#[derive(Args, Clone, Debug)]
335pub struct LogoutArgs {
336    /// Reset the API key before logging out
337    #[arg(long)]
338    pub reset_api_key: bool,
339}
340
341#[derive(Args, Default)]
342pub struct DeployArgs {
343    /// WIP: Deploy this Docker image instead of building one
344    #[arg(long, short = 'i', hide = true)]
345    pub image: Option<String>,
346
347    /// Allow deployment with uncommitted files
348    #[arg(long, visible_alias = "ad")]
349    pub allow_dirty: bool,
350    /// Output the deployment archive to a file instead of sending a deployment request
351    #[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    /// Don't follow the deployment status, exit after the operation begins
363    #[arg(long, visible_alias = "nf")]
364    pub no_follow: bool,
365    /// Don't display timestamps and log origin tags
366    #[arg(long)]
367    pub raw: bool,
368}
369
370#[derive(Args, Debug, Default)]
371pub struct RunArgs {
372    /// Port to start service on
373    #[arg(long, short = 'p', env, default_value = "8000")]
374    pub port: u16,
375    /// Use 0.0.0.0 instead of localhost (for usage with local external devices)
376    #[arg(long)]
377    pub external: bool,
378    /// Don't display timestamps and log origin tags
379    #[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    /// Output the build archive to a file instead of building
391    #[arg(long)]
392    pub output_archive: Option<PathBuf>,
393    #[command(flatten)]
394    pub inner: BuildArgsShared,
395}
396
397/// Arguments shared by build and run commands
398#[derive(Args, Debug, Default)]
399pub struct BuildArgsShared {
400    /// Use release mode for building the project
401    #[arg(long, short = 'r')]
402    pub release: bool,
403    /// Uses bacon crate to build/run the project in watch mode
404    #[arg(long)]
405    pub bacon: bool,
406
407    // Docker-related args
408    /// Build/Run with docker instead of natively
409    #[arg(long, hide = true)]
410    pub docker: bool,
411    /// Additional tag for the docker image
412    #[arg(long, short = 't', requires = "docker", hide = true)]
413    pub tag: Option<String>,
414}
415
416#[derive(Args, Debug, Default)]
417pub struct SecretsArgs {
418    /// Use this secrets file instead
419    #[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    /// Clone a starter template from Shuttle's official examples
426    #[arg(long, short, value_enum, conflicts_with_all = &["from", "subfolder"])]
427    pub template: Option<InitTemplateArg>,
428    /// Clone a template from a git repository or local path
429    #[arg(long)]
430    pub from: Option<String>,
431    /// Path to the template in the source (used with --from)
432    #[arg(long, requires = "from")]
433    pub subfolder: Option<String>,
434
435    /// Path where to place the new Shuttle project
436    #[arg(default_value = ".", value_parser = OsStringValueParser::new().try_map(create_and_parse_path))]
437    pub path: PathBuf,
438
439    /// Don't check the project name's validity or availability and use it anyways
440    #[arg(long)]
441    pub force_name: bool,
442    /// Whether to create a project on Shuttle
443    #[arg(long, visible_alias = "create_env")]
444    pub create_project: bool,
445    /// Don't initialize a new git repository
446    #[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 - Modular web framework from the Tokio ecosystem
456    Axum,
457    /// Actix Web - Powerful and fast web framework
458    ActixWeb,
459    /// Rocket - Simple and easy-to-use web framework
460    Rocket,
461    /// Loco - Batteries included web framework based on Axum
462    Loco,
463    /// Salvo - Powerful and simple web framework
464    Salvo,
465    /// Poem - Full-featured and easy-to-use web framework
466    Poem,
467    /// Poise - Discord Bot framework with good slash command support
468    Poise,
469    /// Rama - Modular service framework to build proxies, servers and clients
470    Rama,
471    /// Serenity - Discord Bot framework
472    Serenity,
473    /// Tower - Modular service library
474    Tower,
475    /// Warp - Web framework
476    Warp,
477    /// No template - Make a custom service
478    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    /// Deployment ID to get logs for. Defaults to the current deployment
531    pub deployment_id: Option<String>,
532    #[arg(short, long)]
533    /// View logs from the most recent deployment (which is not always the running one)
534    pub latest: bool,
535    #[arg(short, long, hide = true)]
536    /// Follow log output
537    pub follow: bool,
538    /// Don't display timestamps and log origin tags
539    #[arg(long)]
540    pub raw: bool,
541    /// View the first N log lines
542    #[arg(long, group = "pagination", hide = true)]
543    pub head: Option<u32>,
544    /// View the last N log lines
545    #[arg(long, group = "pagination", hide = true)]
546    pub tail: Option<u32>,
547    /// View all log lines
548    #[arg(long, group = "pagination", hide = true)]
549    pub all: bool,
550    /// Get logs from all deployments instead of one deployment
551    #[arg(long, hide = true)]
552    pub all_deployments: bool,
553}
554
555/// Helper function to parse and return the absolute path
556fn 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
565/// Helper function to parse, create if not exists, and return the absolute path
566pub(crate) fn create_and_parse_path(path: OsString) -> Result<PathBuf, io::Error> {
567    // Create the directory if does not exist
568    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        // pre-defined template (only hello world)
592        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        // pre-defined template (multiple)
607        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        // pre-defined "none" template
622        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        // git template with path
637        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        // No template or repo chosen
652        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}