use std::{
ffi::OsString,
fs::create_dir_all,
io::{self, ErrorKind},
path::PathBuf,
};
use anyhow::{bail, Context};
use clap::{
builder::{OsStringValueParser, PossibleValue, TypedValueParser},
Args, Parser, Subcommand, ValueEnum,
};
use clap_complete::Shell;
use shuttle_common::{constants::EXAMPLES_REPO, models::resource::ResourceType};
use crate::util::cargo_metadata;
#[derive(Parser)]
#[command(
version,
next_help_heading = "Global options",
// When running 'cargo shuttle', Cargo passes in the subcommand name to the invoked executable.
// Use a hidden, optional positional argument to deal with it.
arg(clap::Arg::new("dummy")
.value_parser([PossibleValue::new("shuttle")])
.required(false)
.hide(true))
)]
pub struct ShuttleArgs {
#[arg(global = true, long, env = "SHUTTLE_API_ENV", hide = true)]
pub api_env: Option<String>,
#[arg(global = true, long, env = "SHUTTLE_API", hide = true)]
pub api_url: Option<String>,
#[arg(global = true, long, env = "SHUTTLE_ADMIN", hide = true)]
pub admin: bool,
#[arg(global = true, long, env = "SHUTTLE_OFFLINE")]
pub offline: bool,
#[arg(global = true, long, env = "SHUTTLE_DEBUG")]
pub debug: bool,
#[arg(
global = true,
long = "output",
env = "SHUTTLE_OUTPUT_MODE",
default_value = "normal"
)]
pub output_mode: OutputMode,
#[command(flatten)]
pub project_args: ProjectArgs,
#[command(subcommand)]
pub cmd: Command,
}
#[derive(ValueEnum, Clone, Debug, Default, PartialEq)]
pub enum OutputMode {
#[default]
Normal,
Json,
}
#[derive(Args, Clone, Debug)]
pub struct ProjectArgs {
#[arg(global = true, long, visible_alias = "wd", default_value = ".", value_parser = OsStringValueParser::new().try_map(parse_path))]
pub working_directory: PathBuf,
#[arg(global = true, long)]
pub name: Option<String>,
#[arg(global = true, long)]
pub id: Option<String>,
}
impl ProjectArgs {
pub fn workspace_path(&self) -> anyhow::Result<PathBuf> {
cargo_metadata(self.working_directory.as_path()).map(|meta| meta.workspace_root.into())
}
pub fn project_name(&self) -> anyhow::Result<String> {
let workspace_path = self.workspace_path()?;
let meta = cargo_metadata(workspace_path.as_path())?;
let package_name = if let Some(root_package) = meta.root_package() {
root_package.name.to_string()
} else {
workspace_path
.file_name()
.context("failed to get project name from workspace path")?
.to_os_string()
.into_string()
.expect("workspace directory name should be valid unicode")
};
Ok(package_name)
}
}
#[allow(rustdoc::bare_urls)]
#[derive(Subcommand)]
pub enum Command {
#[command(visible_alias = "i")]
Init(InitArgs),
#[command(visible_alias = "r")]
Run(RunArgs),
#[command(visible_alias = "b", hide = true)]
Build(BuildArgs),
#[command(visible_alias = "d")]
Deploy(DeployArgs),
#[command(subcommand, visible_alias = "depl")]
Deployment(DeploymentCommand),
Logs(LogsArgs),
#[command(subcommand, visible_alias = "proj")]
Project(ProjectCommand),
#[command(subcommand, visible_alias = "res")]
Resource(ResourceCommand),
#[command(subcommand, visible_alias = "cert")]
Certificate(CertificateCommand),
#[command(visible_alias = "acc")]
Account,
Login(LoginArgs),
Logout(LogoutArgs),
#[command(subcommand)]
Generate(GenerateCommand),
Feedback,
Upgrade {
#[arg(long)]
preview: bool,
},
#[command(subcommand)]
Mcp(McpCommand),
}
#[derive(Subcommand)]
pub enum McpCommand {
Start,
}
#[derive(Subcommand)]
pub enum GenerateCommand {
Shell {
shell: Shell,
#[arg(short, long)]
output_file: Option<PathBuf>,
},
Manpage,
}
#[derive(Args)]
#[command(next_help_heading = "Table options")]
pub struct TableArgs {
#[arg(long, default_value_t = false)]
pub raw: bool,
}
#[derive(Subcommand)]
pub enum DeploymentCommand {
#[command(visible_alias = "ls")]
List {
#[arg(long, default_value = "1")]
page: u32,
#[arg(long, default_value = "10", visible_alias = "per-page")]
limit: u32,
#[command(flatten)]
table: TableArgs,
},
#[command(visible_alias = "stat")]
Status {
deployment_id: Option<String>,
},
Redeploy {
deployment_id: Option<String>,
#[command(flatten)]
tracking_args: DeploymentTrackingArgs,
},
Stop {
#[command(flatten)]
tracking_args: DeploymentTrackingArgs,
},
}
#[derive(Subcommand)]
pub enum ResourceCommand {
#[command(visible_alias = "ls")]
List {
#[arg(long, default_value_t = false)]
show_secrets: bool,
#[command(flatten)]
table: TableArgs,
},
#[command(visible_alias = "rm")]
Delete {
resource_type: ResourceType,
#[command(flatten)]
confirmation: ConfirmationArgs,
},
#[command(hide = true)] Dump {
resource_type: ResourceType,
},
}
#[derive(Subcommand)]
pub enum CertificateCommand {
Add {
domain: String,
},
#[command(visible_alias = "ls")]
List {
#[command(flatten)]
table: TableArgs,
},
#[command(visible_alias = "rm")]
Delete {
domain: String,
#[command(flatten)]
confirmation: ConfirmationArgs,
},
}
#[derive(Subcommand)]
pub enum ProjectCommand {
#[command(visible_alias = "start")]
Create,
#[command(subcommand, visible_alias = "upd")]
Update(ProjectUpdateCommand),
#[command(visible_alias = "stat")]
Status,
#[command(visible_alias = "ls")]
List {
#[command(flatten)]
table: TableArgs,
},
#[command(visible_alias = "rm")]
Delete(ConfirmationArgs),
Link,
}
#[derive(Subcommand, Debug)]
pub enum ProjectUpdateCommand {
Name { new_name: String },
}
#[derive(Args, Debug)]
pub struct ConfirmationArgs {
#[arg(long, short, default_value_t = false)]
pub yes: bool,
}
#[derive(Args, Clone, Debug, Default)]
#[command(next_help_heading = "Login options")]
pub struct LoginArgs {
#[arg(long, conflicts_with = "api_key", alias = "input")]
pub prompt: bool,
#[arg(long)]
pub api_key: Option<String>,
#[arg(long, env = "SHUTTLE_CONSOLE", hide = true)]
pub console_url: Option<String>,
}
#[derive(Args, Clone, Debug)]
pub struct LogoutArgs {
#[arg(long)]
pub reset_api_key: bool,
}
#[derive(Args, Default)]
pub struct DeployArgs {
#[arg(long, short = 'i', hide = true)]
pub image: Option<String>,
#[arg(long, visible_alias = "ad")]
pub allow_dirty: bool,
#[arg(long)]
pub output_archive: Option<PathBuf>,
#[command(flatten)]
pub tracking_args: DeploymentTrackingArgs,
#[command(flatten)]
pub secret_args: SecretsArgs,
}
#[derive(Args, Default)]
pub struct DeploymentTrackingArgs {
#[arg(long, visible_alias = "nf")]
pub no_follow: bool,
#[arg(long)]
pub raw: bool,
}
#[derive(Args, Debug, Default)]
pub struct RunArgs {
#[arg(long, short = 'p', env, default_value = "8000")]
pub port: u16,
#[arg(long)]
pub external: bool,
#[arg(long)]
pub raw: bool,
#[command(flatten)]
pub secret_args: SecretsArgs,
#[command(flatten)]
pub build_args: BuildArgsShared,
}
#[derive(Args, Debug, Default)]
pub struct BuildArgs {
#[arg(long)]
pub output_archive: Option<PathBuf>,
#[command(flatten)]
pub inner: BuildArgsShared,
}
#[derive(Args, Debug, Default)]
pub struct BuildArgsShared {
#[arg(long, short = 'r')]
pub release: bool,
#[arg(long)]
pub bacon: bool,
#[arg(long, hide = true)]
pub docker: bool,
#[arg(long, short = 't', requires = "docker", hide = true)]
pub tag: Option<String>,
}
#[derive(Args, Debug, Default)]
pub struct SecretsArgs {
#[arg(long, value_parser = OsStringValueParser::new().try_map(parse_path))]
pub secrets: Option<PathBuf>,
}
#[derive(Args, Clone, Debug, Default)]
pub struct InitArgs {
#[arg(long, short, value_enum, conflicts_with_all = &["from", "subfolder"])]
pub template: Option<InitTemplateArg>,
#[arg(long)]
pub from: Option<String>,
#[arg(long, requires = "from")]
pub subfolder: Option<String>,
#[arg(default_value = ".", value_parser = OsStringValueParser::new().try_map(create_and_parse_path))]
pub path: PathBuf,
#[arg(long)]
pub force_name: bool,
#[arg(long, visible_alias = "create_env")]
pub create_project: bool,
#[arg(long)]
pub no_git: bool,
#[command(flatten)]
pub login_args: LoginArgs,
}
#[derive(ValueEnum, Clone, Debug, strum::EnumMessage, strum::VariantArray)]
pub enum InitTemplateArg {
Axum,
ActixWeb,
Rocket,
Loco,
Salvo,
Poem,
Poise,
Rama,
Serenity,
Tower,
Warp,
None,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TemplateLocation {
pub auto_path: String,
pub subfolder: Option<String>,
}
impl InitArgs {
pub fn git_template(&self) -> anyhow::Result<Option<TemplateLocation>> {
if self.from.is_some() && self.template.is_some() {
bail!("Template and From args can not be set at the same time.");
}
Ok(if let Some(from) = self.from.clone() {
Some(TemplateLocation {
auto_path: from,
subfolder: self.subfolder.clone(),
})
} else {
self.template.as_ref().map(|t| t.template())
})
}
}
impl InitTemplateArg {
pub fn template(&self) -> TemplateLocation {
use InitTemplateArg::*;
let path = match self {
ActixWeb => "actix-web/hello-world",
Axum => "axum/hello-world",
Loco => "loco/hello-world",
Poem => "poem/hello-world",
Poise => "poise/hello-world",
Rocket => "rocket/hello-world",
Salvo => "salvo/hello-world",
Rama => "rama/hello-world",
Serenity => "serenity/hello-world",
Tower => "tower/hello-world",
Warp => "warp/hello-world",
None => "custom-service/none",
};
TemplateLocation {
auto_path: EXAMPLES_REPO.into(),
subfolder: Some(path.to_string()),
}
}
}
#[derive(Args, Clone, Debug, Default)]
pub struct LogsArgs {
pub deployment_id: Option<String>,
#[arg(short, long)]
pub latest: bool,
#[arg(short, long, hide = true)]
pub follow: bool,
#[arg(long)]
pub raw: bool,
#[arg(long, group = "pagination", hide = true)]
pub head: Option<u32>,
#[arg(long, group = "pagination", hide = true)]
pub tail: Option<u32>,
#[arg(long, group = "pagination", hide = true)]
pub all: bool,
#[arg(long, hide = true)]
pub all_deployments: bool,
}
fn parse_path(path: OsString) -> Result<PathBuf, io::Error> {
dunce::canonicalize(&path).map_err(|e| {
io::Error::new(
ErrorKind::InvalidInput,
format!("could not turn {path:?} into a real path: {e}"),
)
})
}
pub(crate) fn create_and_parse_path(path: OsString) -> Result<PathBuf, io::Error> {
create_dir_all(&path).map_err(|e| {
io::Error::new(
ErrorKind::InvalidInput,
format!("Could not create directory: {e}"),
)
})?;
parse_path(path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::path_from_workspace_root;
use clap::CommandFactory;
#[test]
fn test_shuttle_args() {
ShuttleArgs::command().debug_assert();
}
#[test]
fn test_init_args_framework() {
let init_args = InitArgs {
template: Some(InitTemplateArg::Tower),
from: None,
subfolder: None,
..Default::default()
};
assert_eq!(
init_args.git_template().unwrap(),
Some(TemplateLocation {
auto_path: EXAMPLES_REPO.into(),
subfolder: Some("tower/hello-world".into())
})
);
let init_args = InitArgs {
template: Some(InitTemplateArg::Axum),
from: None,
subfolder: None,
..Default::default()
};
assert_eq!(
init_args.git_template().unwrap(),
Some(TemplateLocation {
auto_path: EXAMPLES_REPO.into(),
subfolder: Some("axum/hello-world".into())
})
);
let init_args = InitArgs {
template: Some(InitTemplateArg::None),
from: None,
subfolder: None,
..Default::default()
};
assert_eq!(
init_args.git_template().unwrap(),
Some(TemplateLocation {
auto_path: EXAMPLES_REPO.into(),
subfolder: Some("custom-service/none".into())
})
);
let init_args = InitArgs {
template: None,
from: Some("https://github.com/some/repo".into()),
subfolder: Some("some/path".into()),
..Default::default()
};
assert_eq!(
init_args.git_template().unwrap(),
Some(TemplateLocation {
auto_path: "https://github.com/some/repo".into(),
subfolder: Some("some/path".into())
})
);
let init_args = InitArgs {
template: None,
from: None,
subfolder: None,
..Default::default()
};
assert_eq!(init_args.git_template().unwrap(), None);
}
#[test]
fn workspace_path() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
name: None,
id: None,
};
assert_eq!(
project_args.workspace_path().unwrap(),
path_from_workspace_root("examples/axum/hello-world/")
);
}
#[test]
fn project_name() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
name: None,
id: None,
};
assert_eq!(
project_args.project_name().unwrap().to_string(),
"hello-world"
);
}
#[test]
fn project_name_in_workspace() {
let project_args = ProjectArgs {
working_directory: path_from_workspace_root(
"examples/rocket/workspace/hello-world/src",
),
name: None,
id: None,
};
assert_eq!(
project_args.project_name().unwrap().to_string(),
"workspace"
);
}
}