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