1mod args;
2pub mod builder;
3pub mod config;
4mod init;
5mod provisioner_server;
6mod util;
7
8use std::collections::{BTreeMap, HashMap};
9use std::ffi::OsString;
10use std::fs;
11use std::io::{Read, Write};
12use std::net::{Ipv4Addr, SocketAddr};
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use anyhow::{anyhow, bail, Context, Result};
17use args::DeploymentTrackingArgs;
18use chrono::Utc;
19use clap::{parser::ValueSource, CommandFactory, FromArgMatches};
20use crossterm::style::Stylize;
21use dialoguer::{theme::ColorfulTheme, Confirm, Input, Password, Select};
22use futures::{SinkExt, StreamExt};
23use git2::Repository;
24use globset::{Glob, GlobSetBuilder};
25use ignore::overrides::OverrideBuilder;
26use ignore::WalkBuilder;
27use indicatif::ProgressBar;
28use indoc::formatdoc;
29use reqwest::header::HeaderMap;
30use shuttle_api_client::ShuttleApiClient;
31use shuttle_common::{
32 constants::{
33 headers::X_CARGO_SHUTTLE_VERSION, other_env_api_url, EXAMPLES_REPO, SHUTTLE_API_URL,
34 SHUTTLE_CONSOLE_URL, TEMPLATES_SCHEMA_VERSION,
35 },
36 models::{
37 auth::{KeyMessage, TokenMessage},
38 deployment::{
39 BuildArgs, BuildArgsRust, BuildMeta, DeploymentRequest, DeploymentRequestBuildArchive,
40 DeploymentRequestImage, DeploymentResponse, DeploymentState, Environment,
41 GIT_STRINGS_MAX_LENGTH,
42 },
43 error::ApiError,
44 log::LogItem,
45 project::ProjectUpdateRequest,
46 resource::ResourceType,
47 },
48 tables::{deployments_table, get_certificates_table, get_projects_table, get_resource_tables},
49};
50use shuttle_ifc::parse_infra_from_code;
51use strum::{EnumMessage, VariantArray};
52use tokio::io::{AsyncBufReadExt, BufReader};
53use tokio::time::{sleep, Duration};
54use tokio_tungstenite::tungstenite::Message;
55use tracing::{debug, error, info, trace, warn};
56use tracing_subscriber::{fmt, prelude::*, registry, EnvFilter};
57use zip::write::FileOptions;
58
59use crate::args::{
60 CertificateCommand, ConfirmationArgs, DeployArgs, DeploymentCommand, GenerateCommand, InitArgs,
61 LoginArgs, LogoutArgs, LogsArgs, McpCommand, OutputMode, ProjectCommand, ProjectUpdateCommand,
62 ResourceCommand, SecretsArgs, TableArgs, TemplateLocation,
63};
64pub use crate::args::{Command, ProjectArgs, RunArgs, ShuttleArgs};
65use crate::builder::{
66 async_cargo_metadata, build_workspace, find_first_shuttle_package, BuiltService,
67};
68use crate::config::RequestContext;
69use crate::provisioner_server::{ProvApiState, ProvisionerServer};
70use crate::util::{
71 bacon, check_and_warn_runtime_version, generate_completions, generate_manpage,
72 get_templates_schema, is_dirty, open_gh_issue, read_ws_until_text, update_cargo_shuttle,
73};
74
75const VERSION: &str = env!("CARGO_PKG_VERSION");
76
77pub fn parse_args() -> (ShuttleArgs, bool) {
79 let matches = ShuttleArgs::command().get_matches();
80 let mut args =
81 ShuttleArgs::from_arg_matches(&matches).expect("args to already be parsed successfully");
82 let provided_path_to_init = matches
83 .subcommand_matches("init")
84 .is_some_and(|init_matches| {
85 init_matches.value_source("path") == Some(ValueSource::CommandLine)
86 });
87
88 if args
90 .api_env
91 .as_ref()
92 .is_some_and(|e| e == "prod" || e == "production")
93 {
94 args.api_env = None;
95 }
96
97 (args, provided_path_to_init)
98}
99
100pub fn setup_tracing(debug: bool) {
101 registry()
102 .with(fmt::layer())
103 .with(
104 EnvFilter::try_from_default_env().unwrap_or_else(|_| {
106 if debug {
107 EnvFilter::new("info,cargo_shuttle=trace,shuttle=trace")
108 } else {
109 EnvFilter::default()
110 }
111 }),
112 )
113 .init();
114}
115
116#[derive(PartialEq)]
117pub enum Binary {
118 CargoShuttle,
119 Shuttle,
120}
121
122impl Binary {
123 pub fn name(&self) -> String {
124 match self {
125 Self::CargoShuttle => "cargo-shuttle".to_owned(),
126 Self::Shuttle => "shuttle".to_owned(),
127 }
128 }
129}
130
131pub struct Shuttle {
132 ctx: RequestContext,
133 client: Option<ShuttleApiClient>,
134 output_mode: OutputMode,
135 bin: Binary,
137}
138
139impl Shuttle {
140 pub fn new(bin: Binary, env_override: Option<String>) -> Result<Self> {
141 let ctx = RequestContext::load_global(env_override.inspect(|e| {
142 eprintln!(
143 "{}",
144 format!("INFO: Using non-default global config file: {e}").yellow(),
145 )
146 }))?;
147 Ok(Self {
148 ctx,
149 client: None,
150 output_mode: OutputMode::Normal,
151 bin,
152 })
153 }
154
155 pub async fn run(mut self, args: ShuttleArgs, provided_path_to_init: bool) -> Result<()> {
156 if matches!(args.cmd, Command::Resource(ResourceCommand::Dump { .. })) {
157 bail!("This command is not yet supported on the NEW platform (shuttle.dev).");
158 }
159
160 self.output_mode = args.output_mode;
161
162 if matches!(
164 args.cmd,
165 Command::Init(..)
166 | Command::Deploy(..)
167 | Command::Logs { .. }
168 | Command::Account
169 | Command::Login(..)
170 | Command::Logout(..)
171 | Command::Deployment(..)
172 | Command::Resource(..)
173 | Command::Certificate(..)
174 | Command::Project(..)
175 ) {
176 let api_url = args
177 .api_url
178 .or_else(|| args.api_env.as_ref().map(|env| other_env_api_url(env)))
180 .map(|u| if args.admin { format!("{u}/admin") } else { u });
182 if let Some(ref url) = api_url {
183 if url != SHUTTLE_API_URL {
184 eprintln!(
185 "{}",
186 format!("INFO: Targeting non-default API: {url}").yellow(),
187 );
188 }
189 if url.ends_with('/') {
190 eprintln!("WARNING: API URL is probably incorrect. Ends with '/': {url}");
191 }
192 }
193 self.ctx.set_api_url(api_url);
194
195 let client = ShuttleApiClient::new(
196 self.ctx.api_url(),
197 self.ctx.api_key().ok(),
198 Some(
199 HeaderMap::try_from(&HashMap::from([(
200 X_CARGO_SHUTTLE_VERSION.clone(),
201 crate::VERSION.to_owned(),
202 )]))
203 .unwrap(),
204 ),
205 None,
206 );
207 self.client = Some(client);
208 }
209
210 if matches!(
212 args.cmd,
213 Command::Deploy(..)
214 | Command::Deployment(..)
215 | Command::Resource(..)
216 | Command::Certificate(..)
217 | Command::Project(
218 ProjectCommand::Create
220 | ProjectCommand::Update(..)
221 | ProjectCommand::Status
222 | ProjectCommand::Delete { .. }
223 | ProjectCommand::Link
224 )
225 | Command::Logs { .. }
226 ) {
227 self.load_project(
229 &args.project_args,
230 matches!(args.cmd, Command::Project(ProjectCommand::Link)),
231 matches!(args.cmd, Command::Deploy(..)),
234 )
235 .await?;
236 }
237
238 match args.cmd {
239 Command::Init(init_args) => {
240 self.init(
241 init_args,
242 args.project_args,
243 provided_path_to_init,
244 args.offline,
245 )
246 .await
247 }
248 Command::Generate(cmd) => match cmd {
249 GenerateCommand::Manpage => generate_manpage(),
250 GenerateCommand::Shell { shell, output_file } => {
251 generate_completions(self.bin, shell, output_file)
252 }
253 },
254 Command::Account => self.account().await,
255 Command::Login(login_args) => self.login(login_args, args.offline, true).await,
256 Command::Logout(logout_args) => self.logout(logout_args).await,
257 Command::Feedback => open_gh_issue(),
258 Command::Run(run_args) => {
259 self.ctx.load_local_config(&args.project_args)?;
260 self.local_run(run_args, args.debug).await
261 }
262 Command::Deploy(deploy_args) => self.deploy(deploy_args).await,
263 Command::Logs(logs_args) => self.logs(logs_args).await,
264 Command::Deployment(cmd) => match cmd {
265 DeploymentCommand::List { page, limit, table } => {
266 self.deployments_list(page, limit, table).await
267 }
268 DeploymentCommand::Status { id } => self.deployment_get(id).await,
269 DeploymentCommand::Redeploy { id, tracking_args } => {
270 self.deployment_redeploy(id, tracking_args).await
271 }
272 DeploymentCommand::Stop { tracking_args } => self.stop(tracking_args).await,
273 },
274 Command::Resource(cmd) => match cmd {
275 ResourceCommand::List {
276 table,
277 show_secrets,
278 } => self.resources_list(table, show_secrets).await,
279 ResourceCommand::Delete {
280 resource_type,
281 confirmation: ConfirmationArgs { yes },
282 } => self.resource_delete(&resource_type, yes).await,
283 ResourceCommand::Dump { resource_type } => self.resource_dump(&resource_type).await,
284 },
285 Command::Certificate(cmd) => match cmd {
286 CertificateCommand::Add { domain } => self.add_certificate(domain).await,
287 CertificateCommand::List { table } => self.list_certificates(table).await,
288 CertificateCommand::Delete {
289 domain,
290 confirmation: ConfirmationArgs { yes },
291 } => self.delete_certificate(domain, yes).await,
292 },
293 Command::Project(cmd) => match cmd {
294 ProjectCommand::Create => self.project_create().await,
295 ProjectCommand::Update(cmd) => match cmd {
296 ProjectUpdateCommand::Name { name } => self.project_rename(name).await,
297 },
298 ProjectCommand::Status => self.project_status().await,
299 ProjectCommand::List { table, .. } => self.projects_list(table).await,
300 ProjectCommand::Delete(ConfirmationArgs { yes }) => self.project_delete(yes).await,
301 ProjectCommand::Link => Ok(()), },
303 Command::Upgrade { preview } => update_cargo_shuttle(preview).await,
304 Command::Mcp(cmd) => match cmd {
305 McpCommand::Start => shuttle_mcp::run_mcp_server().await,
306 },
307 }
308 }
309
310 async fn init(
315 &mut self,
316 args: InitArgs,
317 mut project_args: ProjectArgs,
318 provided_path_to_init: bool,
319 offline: bool,
320 ) -> Result<()> {
321 let git_template = args.git_template()?;
323 let no_git = args.no_git;
324
325 let needs_name = project_args.name.is_none();
326 let needs_template = git_template.is_none();
327 let needs_path = !provided_path_to_init;
328 let needs_login = self.ctx.api_key().is_err() && args.login_args.api_key.is_none();
329 let should_link = project_args.id.is_some();
330 let interactive = needs_name || needs_template || needs_path || needs_login;
331
332 let theme = ColorfulTheme::default();
333
334 if needs_login {
336 eprintln!("First, let's log in to your Shuttle account.");
337 self.login(args.login_args.clone(), offline, false).await?;
338 eprintln!();
339 } else if args.login_args.api_key.is_some() {
340 self.login(args.login_args.clone(), offline, false).await?;
341 }
342
343 let mut prev_name: Option<String> = None;
345 loop {
346 let name: String = if let Some(name) = project_args.name.clone() {
348 name
349 } else {
350 Input::with_theme(&theme)
352 .with_prompt("Project name")
353 .interact()?
354 };
355 let force_name = args.force_name
356 || (needs_name && prev_name.as_ref().is_some_and(|prev| prev == &name));
357 if force_name {
358 project_args.name = Some(name);
359 break;
360 }
361 if self
363 .check_project_name(&mut project_args, name.clone())
364 .await
365 {
366 break;
368 } else if needs_name {
369 eprintln!(r#"Type the same name again to use "{}" anyways."#, name);
371 prev_name = Some(name);
372 } else {
373 bail!(
375 "Invalid or unavailable project name. Use `--force-name` to use this project name anyways."
376 );
377 }
378 }
379 if needs_name {
380 eprintln!();
381 }
382
383 let path = if needs_path {
385 let path = args
386 .path
387 .join(project_args.name.as_ref().expect("name should be set"));
388
389 loop {
390 eprintln!("Where should we create this project?");
391
392 let directory_str: String = Input::with_theme(&theme)
393 .with_prompt("Directory")
394 .default(format!("{}", path.display()))
395 .interact()?;
396 eprintln!();
397
398 let path = args::create_and_parse_path(OsString::from(directory_str))?;
399
400 if std::fs::read_dir(&path)
401 .expect("init dir to exist and list entries")
402 .count()
403 > 0
404 && !Confirm::with_theme(&theme)
405 .with_prompt("Target directory is not empty. Are you sure?")
406 .default(true)
407 .interact()?
408 {
409 eprintln!();
410 continue;
411 }
412
413 break path;
414 }
415 } else {
416 args.path.clone()
417 };
418
419 let template = match git_template {
421 Some(git_template) => git_template,
422 None => {
423 let schema = if offline {
426 None
427 } else {
428 get_templates_schema()
429 .await
430 .map_err(|e| {
431 error!(err = %e, "Failed to get templates");
432 eprintln!(
433 "{}",
434 "Failed to look up template list. Falling back to internal list."
435 .yellow()
436 )
437 })
438 .ok()
439 .and_then(|s| {
440 if s.version == TEMPLATES_SCHEMA_VERSION {
441 return Some(s);
442 }
443 eprintln!(
444 "{}",
445 "Template list with incompatible version found. Consider upgrading Shuttle CLI. Falling back to internal list."
446 .yellow()
447 );
448
449 None
450 })
451 };
452 if let Some(schema) = schema {
453 eprintln!("What type of project template would you like to start from?");
454 let i = Select::with_theme(&theme)
455 .items(&[
456 "A Hello World app in a supported framework",
457 "Browse our full library of templates", ])
459 .clear(false)
460 .default(0)
461 .interact()?;
462 eprintln!();
463 if i == 0 {
464 let mut starters = schema.starters.into_values().collect::<Vec<_>>();
466 starters.sort_by_key(|t| {
467 if t.title.starts_with("No") {
469 "zzz".to_owned()
470 } else {
471 t.title.clone()
472 }
473 });
474 let starter_strings = starters
475 .iter()
476 .map(|t| {
477 format!("{} - {}", t.title.clone().bold(), t.description.clone())
478 })
479 .collect::<Vec<_>>();
480 let index = Select::with_theme(&theme)
481 .with_prompt("Select template")
482 .items(&starter_strings)
483 .default(0)
484 .interact()?;
485 eprintln!();
486 let path = starters[index]
487 .path
488 .clone()
489 .expect("starter to have a path");
490
491 TemplateLocation {
492 auto_path: EXAMPLES_REPO.into(),
493 subfolder: Some(path),
494 }
495 } else {
496 let mut templates = schema.templates.into_values().collect::<Vec<_>>();
498 templates.sort_by_key(|t| t.title.clone());
499 let template_strings = templates
500 .iter()
501 .map(|t| {
502 format!(
503 "{} - {}{}",
504 t.title.clone().bold(),
505 t.description.clone(),
506 t.tags
507 .first()
508 .map(|tag| format!(" ({tag})").dim().to_string())
509 .unwrap_or_default(),
510 )
511 })
512 .collect::<Vec<_>>();
513 let index = Select::with_theme(&theme)
514 .with_prompt("Select template")
515 .items(&template_strings)
516 .default(0)
517 .interact()?;
518 eprintln!();
519 let path = templates[index]
520 .path
521 .clone()
522 .expect("template to have a path");
523
524 TemplateLocation {
525 auto_path: EXAMPLES_REPO.into(),
526 subfolder: Some(path),
527 }
528 }
529 } else {
530 eprintln!("Shuttle works with many frameworks. Which one do you want to use?");
531 let frameworks = args::InitTemplateArg::VARIANTS;
532 let framework_strings = frameworks
533 .iter()
534 .map(|t| {
535 t.get_documentation()
536 .expect("all template variants to have docs")
537 })
538 .collect::<Vec<_>>();
539 let index = Select::with_theme(&theme)
540 .items(&framework_strings)
541 .default(0)
542 .interact()?;
543 eprintln!();
544 frameworks[index].template()
545 }
546 }
547 };
548
549 crate::init::generate_project(
551 path.clone(),
552 project_args
553 .name
554 .as_ref()
555 .expect("to have a project name provided"),
556 &template,
557 no_git,
558 )?;
559 eprintln!();
560
561 let should_create_project = if should_link {
563 false
565 } else if !interactive {
566 args.create_project
568 } else if args.create_project {
569 true
571 } else {
572 let name = project_args
574 .name
575 .as_ref()
576 .expect("to have a project name provided");
577
578 let should_create = Confirm::with_theme(&theme)
579 .with_prompt(format!(
580 r#"Create a project on Shuttle with the name "{name}"?"#
581 ))
582 .default(true)
583 .interact()?;
584 eprintln!();
585 should_create
586 };
587
588 if should_link || should_create_project {
589 project_args.working_directory.clone_from(&path);
592
593 self.load_project(&project_args, true, true).await?;
594 }
595
596 if std::env::current_dir().is_ok_and(|d| d != path) {
597 eprintln!("You can `cd` to the directory, then:");
598 }
599 eprintln!("Run `shuttle deploy` to deploy it to Shuttle.");
600
601 Ok(())
602 }
603
604 async fn check_project_name(&self, project_args: &mut ProjectArgs, name: String) -> bool {
606 let client = self.client.as_ref().unwrap();
607 match client
608 .check_project_name(&name)
609 .await
610 .map(|r| r.into_inner())
611 {
612 Ok(true) => {
613 project_args.name = Some(name);
614
615 true
616 }
617 Ok(false) => {
618 panic!("Unexpected API response");
620 }
621 Err(e) => {
622 if let Ok(api_error) = e.downcast::<ApiError>() {
624 if api_error.message().contains("Invalid project name") {
626 eprintln!("{}", api_error.message().yellow());
627 eprintln!("{}", "Try a different name.".yellow());
628 return false;
629 }
630 }
631 project_args.name = Some(name);
634 eprintln!(
635 "{}",
636 "Failed to check if project name is available.".yellow()
637 );
638
639 true
640 }
641 }
642 }
643
644 pub async fn load_project(
647 &mut self,
648 project_args: &ProjectArgs,
649 do_linking: bool,
650 create_missing_project: bool,
651 ) -> Result<()> {
652 trace!("project arguments: {project_args:?}");
653
654 self.ctx.load_local_config(project_args)?;
655 self.ctx.load_local_internal_config(project_args)?;
657
658 if project_args.id.is_none() {
661 if let Some(name) = project_args.name.as_ref() {
662 let client = self.client.as_ref().unwrap();
663 trace!(%name, "looking up project id from project name");
664 if let Some(proj) = client
665 .get_projects_list()
666 .await?
667 .into_inner()
668 .projects
669 .into_iter()
670 .find(|p| p.name == *name)
671 {
672 trace!("found project by name");
673 self.ctx.set_project_id(proj.id);
674 } else {
675 trace!("did not find project by name");
676 if create_missing_project {
677 trace!("creating project since it was not found");
678 let proj = client.create_project(name).await?.into_inner();
680 eprintln!("Created project '{}' with id {}", proj.name, proj.id);
681 self.ctx.set_project_id(proj.id);
682 } else if do_linking {
683 self.project_link_interactive().await?;
684 return Ok(());
685 } else {
686 bail!(
687 "Project with name '{}' not found in your project list. \
688 Use 'shuttle project link' to create it or link an existing project.",
689 name
690 );
691 }
692 }
693 }
694 }
695
696 match (self.ctx.project_id_found(), do_linking) {
697 (true, true) => {
698 let arg_given = project_args.id.is_some() || project_args.name.is_some();
699 if arg_given {
700 eprintln!("Linking to project {}", self.ctx.project_id());
702 self.ctx.save_local_internal()?;
703 } else {
704 self.project_link_interactive().await?;
706 }
707 }
708 (true, false) => (),
710 (false, _) => {
712 trace!("no project id found");
713 self.project_link_interactive().await?;
714 }
715 }
716
717 Ok(())
718 }
719
720 async fn project_link_interactive(&mut self) -> Result<()> {
721 let client = self.client.as_ref().unwrap();
722 let projs = client.get_projects_list().await?.into_inner().projects;
723
724 let theme = ColorfulTheme::default();
725
726 let selected_project = if projs.is_empty() {
727 eprintln!("Create a new project to link to this directory:");
728
729 None
730 } else {
731 eprintln!("Which project do you want to link this directory to?");
732
733 let mut items = projs
734 .iter()
735 .map(|p| {
736 if let Some(team_id) = p.team_id.as_ref() {
737 format!("Team {}: {}", team_id, p.name)
738 } else {
739 p.name.clone()
740 }
741 })
742 .collect::<Vec<_>>();
743 items.extend_from_slice(&["[CREATE NEW]".to_string()]);
744 let index = Select::with_theme(&theme)
745 .items(&items)
746 .default(0)
747 .interact()?;
748
749 if index == projs.len() {
750 None
752 } else {
753 Some(projs[index].clone())
754 }
755 };
756
757 let proj = match selected_project {
758 Some(proj) => proj,
759 None => {
760 let name: String = Input::with_theme(&theme)
761 .with_prompt("Project name")
762 .interact()?;
763
764 let proj = client.create_project(&name).await?.into_inner();
766 eprintln!("Created project '{}' with id {}", proj.name, proj.id);
767
768 proj
769 }
770 };
771
772 eprintln!("Linking to project '{}' with id {}", proj.name, proj.id);
773 self.ctx.set_project_id(proj.id);
774 self.ctx.save_local_internal()?;
775
776 Ok(())
777 }
778
779 async fn account(&self) -> Result<()> {
780 let client = self.client.as_ref().unwrap();
781 let r = client.get_current_user().await?;
782 match self.output_mode {
783 OutputMode::Normal => {
784 print!("{}", r.into_inner().to_string_colored());
785 }
786 OutputMode::Json => {
787 println!("{}", r.raw_json);
788 }
789 }
790
791 Ok(())
792 }
793
794 async fn login(&mut self, login_args: LoginArgs, offline: bool, login_cmd: bool) -> Result<()> {
796 let api_key = match login_args.api_key {
797 Some(api_key) => api_key,
798 None => {
799 if login_args.prompt {
800 Password::with_theme(&ColorfulTheme::default())
801 .with_prompt("API key")
802 .validate_with(|input: &String| {
803 if input.is_empty() {
804 return Err("Empty API key was provided");
805 }
806 Ok(())
807 })
808 .interact()?
809 } else {
810 self.device_auth(login_args.console_url).await?
812 }
813 }
814 };
815
816 self.ctx.set_api_key(api_key.clone())?;
817
818 if let Some(client) = self.client.as_mut() {
819 client.api_key = Some(api_key);
820
821 if offline {
822 eprintln!("INFO: Skipping API key verification");
823 } else {
824 let (user, raw_json) = client
825 .get_current_user()
826 .await
827 .context("failed to check API key validity")?
828 .into_parts();
829 if login_cmd {
830 match self.output_mode {
831 OutputMode::Normal => {
832 println!("Logged in as {}", user.id.bold());
833 }
834 OutputMode::Json => {
835 println!("{}", raw_json);
836 }
837 }
838 } else {
839 eprintln!("Logged in as {}", user.id.bold());
840 }
841 }
842 }
843
844 Ok(())
845 }
846
847 async fn device_auth(&self, console_url: Option<String>) -> Result<String> {
848 let client = self.client.as_ref().unwrap();
849
850 if let Some(u) = console_url.as_ref() {
852 if u.ends_with('/') {
853 eprintln!("WARNING: Console URL is probably incorrect. Ends with '/': {u}");
854 }
855 }
856
857 let (mut tx, mut rx) = client.get_device_auth_ws().await?.split();
858
859 let pinger = tokio::spawn(async move {
861 loop {
862 if let Err(e) = tx.send(Message::Ping(Default::default())).await {
863 error!(error = %e, "Error when pinging websocket");
864 break;
865 };
866 sleep(Duration::from_secs(20)).await;
867 }
868 });
869
870 let token_message = read_ws_until_text(&mut rx).await?;
871 let Some(token_message) = token_message else {
872 bail!("Did not receive device auth token over websocket");
873 };
874 let token_message = serde_json::from_str::<TokenMessage>(&token_message)?;
875 let token = token_message.token;
876
877 let url = token_message.url.unwrap_or_else(|| {
879 format!(
880 "{}/device-auth?token={}",
881 console_url.as_deref().unwrap_or(SHUTTLE_CONSOLE_URL),
882 token
883 )
884 });
885 let _ = webbrowser::open(&url);
886 eprintln!("Complete login in Shuttle Console to authenticate the Shuttle CLI.");
887 eprintln!("If your browser did not automatically open, go to {url}");
888 eprintln!();
889 eprintln!("{}", format!("Token: {token}").bold());
890 eprintln!();
891
892 let key = read_ws_until_text(&mut rx).await?;
893 let Some(key) = key else {
894 bail!("Failed to receive API key over websocket");
895 };
896 let key = serde_json::from_str::<KeyMessage>(&key)?.api_key;
897
898 pinger.abort();
899
900 Ok(key)
901 }
902
903 async fn logout(&mut self, logout_args: LogoutArgs) -> Result<()> {
904 if logout_args.reset_api_key {
905 let client = self.client.as_ref().unwrap();
906 client.reset_api_key().await.context("Resetting API key")?;
907 eprintln!("Successfully reset the API key.");
908 }
909 self.ctx.clear_api_key()?;
910 eprintln!("Successfully logged out.");
911 eprintln!(" -> Use `shuttle login` to log in again.");
912
913 Ok(())
914 }
915
916 async fn stop(&self, tracking_args: DeploymentTrackingArgs) -> Result<()> {
917 let client = self.client.as_ref().unwrap();
918 let pid = self.ctx.project_id();
919 let res = client.stop_service(pid).await?.into_inner();
920 println!("{res}");
921
922 if tracking_args.no_follow {
923 return Ok(());
924 }
925
926 wait_with_spinner(2000, |_, pb| async move {
927 let (deployment, raw_json) = client.get_current_deployment(pid).await?.into_parts();
928
929 let get_cleanup = |d: Option<DeploymentResponse>| {
930 move || {
931 if let Some(d) = d {
932 match self.output_mode {
933 OutputMode::Normal => {
934 eprintln!("{}", d.to_string_colored());
935 }
936 OutputMode::Json => {
937 }
939 }
940 }
941 }
942 };
943 let Some(deployment) = deployment else {
944 return Ok(Some(get_cleanup(None)));
945 };
946
947 let state = deployment.state.clone();
948 match self.output_mode {
949 OutputMode::Normal => {
950 pb.set_message(deployment.to_string_summary_colored());
951 }
952 OutputMode::Json => {
953 println!("{}", raw_json);
954 }
955 }
956 let cleanup = get_cleanup(Some(deployment));
957 match state {
958 DeploymentState::Pending
959 | DeploymentState::Stopping
960 | DeploymentState::InProgress
961 | DeploymentState::Running => Ok(None),
962 DeploymentState::Building | DeploymentState::Failed
964 | DeploymentState::Stopped
965 | DeploymentState::Unknown(_) => Ok(Some(cleanup)),
966 }
967 })
968 .await?;
969
970 Ok(())
971 }
972
973 async fn logs(&self, args: LogsArgs) -> Result<()> {
974 if args.follow {
975 eprintln!("Streamed logs are not yet supported on the shuttle.dev platform.");
976 return Ok(());
977 }
978 if args.tail.is_some() | args.head.is_some() {
979 eprintln!("Fetching log ranges are not yet supported on the shuttle.dev platform.");
980 return Ok(());
981 }
982 let client = self.client.as_ref().unwrap();
983 let pid = self.ctx.project_id();
984 let r = if args.all_deployments {
985 client.get_project_logs(pid).await?
986 } else {
987 let id = if args.latest {
988 let deployments = client
990 .get_deployments(pid, 1, 1)
991 .await?
992 .into_inner()
993 .deployments;
994 let Some(most_recent) = deployments.into_iter().next() else {
995 println!("No deployments found");
996 return Ok(());
997 };
998 eprintln!("Getting logs from: {}", most_recent.id);
999 most_recent.id
1000 } else if let Some(id) = args.id {
1001 id
1002 } else {
1003 let Some(current) = client.get_current_deployment(pid).await?.into_inner() else {
1004 println!("No deployments found");
1005 return Ok(());
1006 };
1007 eprintln!("Getting logs from: {}", current.id);
1008 current.id
1009 };
1010 client.get_deployment_logs(pid, &id).await?
1011 };
1012 match self.output_mode {
1013 OutputMode::Normal => {
1014 let logs = r.into_inner().logs;
1015 for log in logs {
1016 if args.raw {
1017 println!("{}", log.line);
1018 } else {
1019 println!("{log}");
1020 }
1021 }
1022 }
1023 OutputMode::Json => {
1024 println!("{}", r.raw_json);
1025 }
1026 }
1027
1028 Ok(())
1029 }
1030
1031 async fn deployments_list(&self, page: u32, limit: u32, table_args: TableArgs) -> Result<()> {
1032 if limit == 0 {
1033 warn!("Limit is set to 0, no deployments will be listed.");
1034 return Ok(());
1035 }
1036 let client = self.client.as_ref().unwrap();
1037 let pid = self.ctx.project_id();
1038
1039 let limit = limit + 1;
1041 let (deployments, raw_json) = client
1042 .get_deployments(pid, page as i32, limit as i32)
1043 .await?
1044 .into_parts();
1045 let mut deployments = deployments.deployments;
1046 let page_hint = if deployments.len() == limit as usize {
1047 deployments.pop();
1049 true
1050 } else {
1051 false
1052 };
1053 match self.output_mode {
1054 OutputMode::Normal => {
1055 let table = deployments_table(&deployments, table_args.raw);
1056 println!("{}", format!("Deployments in project '{}'", pid).bold());
1057 println!("{table}");
1058 if page_hint {
1059 println!("View the next page using `--page {}`", page + 1);
1060 }
1061 }
1062 OutputMode::Json => {
1063 println!("{}", raw_json);
1064 }
1065 }
1066
1067 Ok(())
1068 }
1069
1070 async fn deployment_get(&self, deployment_id: Option<String>) -> Result<()> {
1071 let client = self.client.as_ref().unwrap();
1072 let pid = self.ctx.project_id();
1073
1074 let deployment = match deployment_id {
1075 Some(id) => {
1076 let r = client.get_deployment(pid, &id).await?;
1077 if self.output_mode == OutputMode::Json {
1078 println!("{}", r.raw_json);
1079 return Ok(());
1080 }
1081 r.into_inner()
1082 }
1083 None => {
1084 let r = client.get_current_deployment(pid).await?;
1085 if self.output_mode == OutputMode::Json {
1086 println!("{}", r.raw_json);
1087 return Ok(());
1088 }
1089
1090 let Some(d) = r.into_inner() else {
1091 println!("No deployment found");
1092 return Ok(());
1093 };
1094 d
1095 }
1096 };
1097
1098 println!("{}", deployment.to_string_colored());
1099
1100 Ok(())
1101 }
1102
1103 async fn deployment_redeploy(
1104 &self,
1105 deployment_id: Option<String>,
1106 tracking_args: DeploymentTrackingArgs,
1107 ) -> Result<()> {
1108 let client = self.client.as_ref().unwrap();
1109
1110 let pid = self.ctx.project_id();
1111 let deployment_id = match deployment_id {
1112 Some(id) => id,
1113 None => {
1114 let d = client.get_current_deployment(pid).await?.into_inner();
1115 let Some(d) = d else {
1116 println!("No deployment found");
1117 return Ok(());
1118 };
1119 d.id
1120 }
1121 };
1122 let (deployment, raw_json) = client.redeploy(pid, &deployment_id).await?.into_parts();
1123
1124 if tracking_args.no_follow {
1125 match self.output_mode {
1126 OutputMode::Normal => {
1127 println!("{}", deployment.to_string_colored());
1128 }
1129 OutputMode::Json => {
1130 println!("{}", raw_json);
1131 }
1132 }
1133 return Ok(());
1134 }
1135
1136 self.track_deployment_status_and_print_logs_on_fail(pid, &deployment.id, tracking_args.raw)
1137 .await
1138 }
1139
1140 async fn resources_list(&self, table_args: TableArgs, show_secrets: bool) -> Result<()> {
1141 let client = self.client.as_ref().unwrap();
1142 let pid = self.ctx.project_id();
1143 let r = client.get_service_resources(pid).await?;
1144
1145 match self.output_mode {
1146 OutputMode::Normal => {
1147 let table = get_resource_tables(
1148 r.into_inner().resources.as_slice(),
1149 pid,
1150 table_args.raw,
1151 show_secrets,
1152 );
1153 println!("{table}");
1154 }
1155 OutputMode::Json => {
1156 println!("{}", r.raw_json);
1157 }
1158 }
1159
1160 Ok(())
1161 }
1162
1163 async fn resource_delete(&self, resource_type: &ResourceType, no_confirm: bool) -> Result<()> {
1164 let client = self.client.as_ref().unwrap();
1165
1166 if !no_confirm {
1167 eprintln!(
1168 "{}",
1169 formatdoc!(
1170 "
1171 WARNING:
1172 Are you sure you want to delete this project's {}?
1173 This action is permanent.",
1174 resource_type
1175 )
1176 .bold()
1177 .red()
1178 );
1179 if !Confirm::with_theme(&ColorfulTheme::default())
1180 .with_prompt("Are you sure?")
1181 .default(false)
1182 .interact()
1183 .unwrap()
1184 {
1185 return Ok(());
1186 }
1187 }
1188
1189 let msg = client
1190 .delete_service_resource(self.ctx.project_id(), resource_type)
1191 .await?
1192 .into_inner();
1193 println!("{msg}");
1194
1195 eprintln!(
1196 "{}",
1197 formatdoc! {"
1198 Note:
1199 Remember to remove the resource annotation from your #[shuttle_runtime::main] function.
1200 Otherwise, it will be provisioned again during the next deployment."
1201 }
1202 .yellow(),
1203 );
1204
1205 Ok(())
1206 }
1207
1208 async fn resource_dump(&self, _resource_type: &ResourceType) -> Result<()> {
1209 unimplemented!();
1210 }
1215
1216 async fn list_certificates(&self, table_args: TableArgs) -> Result<()> {
1217 let client = self.client.as_ref().unwrap();
1218 let r = client.list_certificates(self.ctx.project_id()).await?;
1219
1220 match self.output_mode {
1221 OutputMode::Normal => {
1222 let table =
1223 get_certificates_table(r.into_inner().certificates.as_ref(), table_args.raw);
1224 println!("{table}");
1225 }
1226 OutputMode::Json => {
1227 println!("{}", r.raw_json);
1228 }
1229 }
1230
1231 Ok(())
1232 }
1233 async fn add_certificate(&self, domain: String) -> Result<()> {
1234 let client = self.client.as_ref().unwrap();
1235 let r = client
1236 .add_certificate(self.ctx.project_id(), domain.clone())
1237 .await?;
1238
1239 match self.output_mode {
1240 OutputMode::Normal => {
1241 println!("Added certificate for {}", r.into_inner().subject);
1242 }
1243 OutputMode::Json => {
1244 println!("{}", r.raw_json);
1245 }
1246 }
1247
1248 Ok(())
1249 }
1250 async fn delete_certificate(&self, domain: String, no_confirm: bool) -> Result<()> {
1251 let client = self.client.as_ref().unwrap();
1252
1253 if !no_confirm {
1254 eprintln!(
1255 "{}",
1256 formatdoc!(
1257 "
1258 WARNING:
1259 Delete the certificate for {}?",
1260 domain
1261 )
1262 .bold()
1263 .red()
1264 );
1265 if !Confirm::with_theme(&ColorfulTheme::default())
1266 .with_prompt("Are you sure?")
1267 .default(false)
1268 .interact()
1269 .unwrap()
1270 {
1271 return Ok(());
1272 }
1273 }
1274
1275 let msg = client
1276 .delete_certificate(self.ctx.project_id(), domain.clone())
1277 .await?
1278 .into_inner();
1279 println!("{msg}");
1280
1281 Ok(())
1282 }
1283
1284 fn get_secrets(
1285 args: &SecretsArgs,
1286 workspace_root: &Path,
1287 dev: bool,
1288 ) -> Result<Option<HashMap<String, String>>> {
1289 let files: &[PathBuf] = if dev {
1291 &[
1292 workspace_root.join("Secrets.dev.toml"),
1293 workspace_root.join("Secrets.toml"),
1294 ]
1295 } else {
1296 &[workspace_root.join("Secrets.toml")]
1297 };
1298 let secrets_file = args.secrets.as_ref().or_else(|| {
1299 files
1300 .iter()
1301 .find(|&secrets_file| secrets_file.exists() && secrets_file.is_file())
1302 });
1303
1304 let Some(secrets_file) = secrets_file else {
1305 trace!("No secrets file was found");
1306 return Ok(None);
1307 };
1308
1309 trace!("Loading secrets from {}", secrets_file.display());
1310 let Ok(secrets_str) = fs::read_to_string(secrets_file) else {
1311 tracing::warn!("Failed to read secrets file, no secrets were loaded");
1312 return Ok(None);
1313 };
1314
1315 let secrets = toml::from_str::<HashMap<String, String>>(&secrets_str)
1316 .context("parsing secrets file")?;
1317 trace!(keys = ?secrets.keys(), "Loaded secrets");
1318
1319 Ok(Some(secrets))
1320 }
1321
1322 async fn pre_local_run(&self, run_args: &RunArgs) -> Result<BuiltService> {
1323 trace!("starting a local run with args: {run_args:?}");
1324
1325 let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(256);
1326 tokio::task::spawn(async move {
1327 while let Some(line) = rx.recv().await {
1328 println!("{line}");
1329 }
1330 });
1331
1332 let project_directory = self.ctx.project_directory();
1333
1334 println!(
1335 "{} {}",
1336 " Building".bold().green(),
1337 project_directory.display()
1338 );
1339
1340 build_workspace(project_directory, run_args.release, tx).await
1341 }
1342
1343 fn find_available_port(run_args: &mut RunArgs) {
1344 let original_port = run_args.port;
1345 for port in (run_args.port..=u16::MAX).step_by(10) {
1346 if !portpicker::is_free_tcp(port) {
1347 continue;
1348 }
1349 run_args.port = port;
1350 break;
1351 }
1352
1353 if run_args.port != original_port {
1354 eprintln!(
1355 "Port {} is already in use. Using port {}.",
1356 original_port, run_args.port,
1357 )
1358 };
1359 }
1360
1361 async fn local_run(&self, mut run_args: RunArgs, debug: bool) -> Result<()> {
1362 let project_name = self.ctx.project_name().to_owned();
1363 let project_directory = self.ctx.project_directory();
1364
1365 if run_args.bacon {
1367 println!(
1368 "\n {} {} in watch mode using bacon\n",
1369 "Starting".bold().green(),
1370 project_name
1371 );
1372 return bacon::run_bacon(project_directory).await;
1373 }
1374
1375 let service = self.pre_local_run(&run_args).await?;
1376 trace!(path = ?service.executable_path, "runtime executable");
1377
1378 let secrets = Shuttle::get_secrets(&run_args.secret_args, project_directory, true)?
1379 .unwrap_or_default();
1380 Shuttle::find_available_port(&mut run_args);
1381 if let Some(warning) = check_and_warn_runtime_version(&service.executable_path).await? {
1382 eprint!("{}", warning);
1383 }
1384
1385 let runtime_executable = service.executable_path.clone();
1386 let api_port = portpicker::pick_unused_port()
1387 .expect("failed to find available port for local provisioner server");
1388 let api_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), api_port);
1389 let healthz_port = portpicker::pick_unused_port()
1390 .expect("failed to find available port for runtime health check");
1391 let ip = if run_args.external {
1392 Ipv4Addr::UNSPECIFIED
1393 } else {
1394 Ipv4Addr::LOCALHOST
1395 };
1396
1397 let state = Arc::new(ProvApiState {
1398 project_name: project_name.clone(),
1399 secrets,
1400 });
1401 tokio::spawn(async move { ProvisionerServer::run(state, &api_addr).await });
1402
1403 println!(
1404 "\n {} {} on http://{}:{}\n",
1405 "Starting".bold().green(),
1406 service.target_name,
1407 ip,
1408 run_args.port,
1409 );
1410
1411 let mut envs = vec![
1412 ("SHUTTLE_BETA", "true".to_owned()),
1413 ("SHUTTLE_PROJECT_ID", "proj_LOCAL".to_owned()),
1414 ("SHUTTLE_PROJECT_NAME", project_name),
1415 ("SHUTTLE_ENV", Environment::Local.to_string()),
1416 ("SHUTTLE_RUNTIME_IP", ip.to_string()),
1417 ("SHUTTLE_RUNTIME_PORT", run_args.port.to_string()),
1418 ("SHUTTLE_HEALTHZ_PORT", healthz_port.to_string()),
1419 ("SHUTTLE_API", format!("http://127.0.0.1:{}", api_port)),
1420 ];
1421 if debug && std::env::var("RUST_LOG").is_err() {
1423 envs.push(("RUST_LOG", "info,shuttle=trace,reqwest=debug".to_owned()));
1424 }
1425
1426 info!(
1427 path = %runtime_executable.display(),
1428 "Spawning runtime process",
1429 );
1430 let mut runtime = tokio::process::Command::new(
1431 dunce::canonicalize(runtime_executable).context("canonicalize path of executable")?,
1432 )
1433 .current_dir(&service.workspace_path)
1434 .envs(envs)
1435 .stdout(std::process::Stdio::piped())
1436 .stderr(std::process::Stdio::piped())
1437 .kill_on_drop(true)
1438 .spawn()
1439 .context("spawning runtime process")?;
1440
1441 let raw = run_args.raw;
1443 let mut stdout_reader = BufReader::new(
1444 runtime
1445 .stdout
1446 .take()
1447 .context("child process did not have a handle to stdout")?,
1448 )
1449 .lines();
1450 tokio::spawn(async move {
1451 while let Some(line) = stdout_reader.next_line().await.unwrap() {
1452 if raw {
1453 println!("{}", line);
1454 } else {
1455 let log_item = LogItem::new(Utc::now(), "app".to_owned(), line);
1456 println!("{log_item}");
1457 }
1458 }
1459 });
1460 let mut stderr_reader = BufReader::new(
1461 runtime
1462 .stderr
1463 .take()
1464 .context("child process did not have a handle to stderr")?,
1465 )
1466 .lines();
1467 tokio::spawn(async move {
1468 while let Some(line) = stderr_reader.next_line().await.unwrap() {
1469 if raw {
1470 println!("{}", line);
1471 } else {
1472 let log_item = LogItem::new(Utc::now(), "app".to_owned(), line);
1473 println!("{log_item}");
1474 }
1475 }
1476 });
1477
1478 tokio::spawn(async move {
1480 loop {
1481 tokio::time::sleep(tokio::time::Duration::from_millis(5000)).await;
1483
1484 tracing::trace!("Health check against runtime");
1485 if let Err(e) = reqwest::get(format!("http://127.0.0.1:{}/", healthz_port)).await {
1486 tracing::trace!("Health check against runtime failed: {e}");
1487 }
1488 }
1489 });
1490
1491 #[cfg(target_family = "unix")]
1492 let exit_result = {
1493 let mut sigterm_notif =
1494 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1495 .expect("Can not get the SIGTERM signal receptor");
1496 let mut sigint_notif =
1497 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
1498 .expect("Can not get the SIGINT signal receptor");
1499 tokio::select! {
1500 exit_result = runtime.wait() => {
1501 Some(exit_result)
1502 }
1503 _ = sigterm_notif.recv() => {
1504 eprintln!("Received SIGTERM. Killing the runtime...");
1505 None
1506 },
1507 _ = sigint_notif.recv() => {
1508 eprintln!("Received SIGINT. Killing the runtime...");
1509 None
1510 }
1511 }
1512 };
1513 #[cfg(target_family = "windows")]
1514 let exit_result = {
1515 let mut ctrl_break_notif = tokio::signal::windows::ctrl_break()
1516 .expect("Can not get the CtrlBreak signal receptor");
1517 let mut ctrl_c_notif =
1518 tokio::signal::windows::ctrl_c().expect("Can not get the CtrlC signal receptor");
1519 let mut ctrl_close_notif = tokio::signal::windows::ctrl_close()
1520 .expect("Can not get the CtrlClose signal receptor");
1521 let mut ctrl_logoff_notif = tokio::signal::windows::ctrl_logoff()
1522 .expect("Can not get the CtrlLogoff signal receptor");
1523 let mut ctrl_shutdown_notif = tokio::signal::windows::ctrl_shutdown()
1524 .expect("Can not get the CtrlShutdown signal receptor");
1525 tokio::select! {
1526 exit_result = runtime.wait() => {
1527 Some(exit_result)
1528 }
1529 _ = ctrl_break_notif.recv() => {
1530 eprintln!("Received ctrl-break.");
1531 None
1532 },
1533 _ = ctrl_c_notif.recv() => {
1534 eprintln!("Received ctrl-c.");
1535 None
1536 },
1537 _ = ctrl_close_notif.recv() => {
1538 eprintln!("Received ctrl-close.");
1539 None
1540 },
1541 _ = ctrl_logoff_notif.recv() => {
1542 eprintln!("Received ctrl-logoff.");
1543 None
1544 },
1545 _ = ctrl_shutdown_notif.recv() => {
1546 eprintln!("Received ctrl-shutdown.");
1547 None
1548 }
1549 }
1550 };
1551 match exit_result {
1552 Some(Ok(exit_status)) => {
1553 bail!(
1554 "Runtime process exited with code {}",
1555 exit_status.code().unwrap_or_default()
1556 );
1557 }
1558 Some(Err(e)) => {
1559 bail!("Failed to wait for runtime process to exit: {e}");
1560 }
1561 None => {
1562 runtime.kill().await?;
1563 }
1564 }
1565
1566 Ok(())
1567 }
1568
1569 async fn deploy(&mut self, args: DeployArgs) -> Result<()> {
1570 let client = self.client.as_ref().unwrap();
1571 let project_directory = self.ctx.project_directory();
1572 let manifest_path = project_directory.join("Cargo.toml");
1573
1574 let secrets = Shuttle::get_secrets(&args.secret_args, project_directory, false)?;
1575
1576 if let Some(image) = args.image {
1578 let pid = self.ctx.project_id();
1579 let deployment_req_image = DeploymentRequestImage { image, secrets };
1580
1581 let (deployment, raw_json) = client
1582 .deploy(pid, DeploymentRequest::Image(deployment_req_image))
1583 .await?
1584 .into_parts();
1585
1586 if args.tracking_args.no_follow {
1587 match self.output_mode {
1588 OutputMode::Normal => {
1589 println!("{}", deployment.to_string_colored());
1590 }
1591 OutputMode::Json => {
1592 println!("{}", raw_json);
1593 }
1594 }
1595 return Ok(());
1596 }
1597
1598 return self
1599 .track_deployment_status_and_print_logs_on_fail(
1600 pid,
1601 &deployment.id,
1602 args.tracking_args.raw,
1603 )
1604 .await;
1605 }
1606
1607 let mut deployment_req = DeploymentRequestBuildArchive {
1609 secrets,
1610 ..Default::default()
1611 };
1612 let mut build_meta = BuildMeta::default();
1613 let mut rust_build_args = BuildArgsRust::default();
1614
1615 let metadata = async_cargo_metadata(manifest_path.as_path()).await?;
1616 let (package, target, runtime_version) = find_first_shuttle_package(&metadata)?;
1618 rust_build_args.package_name = Some(package.name.clone());
1619 rust_build_args.binary_name = Some(target.name.clone());
1620 rust_build_args.shuttle_runtime_version = runtime_version;
1621
1622 let (no_default_features, features) = if package.features.contains_key("shuttle") {
1624 (true, Some(vec!["shuttle".to_owned()]))
1625 } else {
1626 (false, None)
1627 };
1628 rust_build_args.no_default_features = no_default_features;
1629 rust_build_args.features = features.map(|v| v.join(","));
1630
1631 deployment_req.build_args = Some(BuildArgs::Rust(rust_build_args));
1634
1635 deployment_req.infra = parse_infra_from_code(
1638 &fs::read_to_string(target.src_path.as_path())
1639 .context("reading target file when extracting infra annotations")?,
1640 )
1641 .context("parsing infra annotations")?;
1642
1643 if let Ok(repo) = Repository::discover(project_directory) {
1644 let repo_path = repo
1645 .workdir()
1646 .context("getting working directory of repository")?;
1647 let repo_path = dunce::canonicalize(repo_path)?;
1648 trace!(?repo_path, "found git repository");
1649
1650 let dirty = is_dirty(&repo);
1651 build_meta.git_dirty = Some(dirty.is_err());
1652
1653 let check_dirty = self.ctx.deny_dirty().is_some_and(|d| d);
1654 if check_dirty && !args.allow_dirty && dirty.is_err() {
1655 bail!(dirty.unwrap_err());
1656 }
1657
1658 if let Ok(head) = repo.head() {
1659 build_meta.git_branch = head
1662 .shorthand()
1663 .map(|s| s.chars().take(GIT_STRINGS_MAX_LENGTH).collect());
1664 if let Ok(commit) = head.peel_to_commit() {
1665 build_meta.git_commit_id = Some(commit.id().to_string());
1666 build_meta.git_commit_msg = commit
1668 .summary()
1669 .map(|s| s.chars().take(GIT_STRINGS_MAX_LENGTH).collect());
1670 }
1671 }
1672 }
1673
1674 eprintln!("Packing files...");
1675 let archive = self.make_archive()?;
1676
1677 if let Some(path) = args.output_archive {
1678 eprintln!("Writing archive to {}", path.display());
1679 std::fs::write(path, archive).context("writing archive")?;
1680
1681 return Ok(());
1682 }
1683
1684 let pid = self.ctx.project_id();
1687
1688 eprintln!("Uploading code...");
1689 let arch = client.upload_archive(pid, archive).await?.into_inner();
1690 deployment_req.archive_version_id = arch.archive_version_id;
1691 deployment_req.build_meta = Some(build_meta);
1692
1693 eprintln!("Creating deployment...");
1694 let (deployment, raw_json) = client
1695 .deploy(
1696 pid,
1697 DeploymentRequest::BuildArchive(Box::new(deployment_req)),
1698 )
1699 .await?
1700 .into_parts();
1701
1702 if args.tracking_args.no_follow {
1703 match self.output_mode {
1704 OutputMode::Normal => {
1705 println!("{}", deployment.to_string_colored());
1706 }
1707 OutputMode::Json => {
1708 println!("{}", raw_json);
1709 }
1710 }
1711 return Ok(());
1712 }
1713
1714 self.track_deployment_status_and_print_logs_on_fail(
1715 pid,
1716 &deployment.id,
1717 args.tracking_args.raw,
1718 )
1719 .await
1720 }
1721
1722 async fn track_deployment_status(&self, pid: &str, id: &str) -> Result<bool> {
1724 let client = self.client.as_ref().unwrap();
1725 let failed = wait_with_spinner(2000, |_, pb| async move {
1726 let (deployment, raw_json) = client.get_deployment(pid, id).await?.into_parts();
1727
1728 let state = deployment.state.clone();
1729 match self.output_mode {
1730 OutputMode::Normal => {
1731 pb.set_message(deployment.to_string_summary_colored());
1732 }
1733 OutputMode::Json => {
1734 println!("{}", raw_json);
1735 }
1736 }
1737 let failed = state == DeploymentState::Failed;
1738 let cleanup = move || {
1739 match self.output_mode {
1740 OutputMode::Normal => {
1741 eprintln!("{}", deployment.to_string_colored());
1742 }
1743 OutputMode::Json => {
1744 }
1746 }
1747 failed
1748 };
1749 match state {
1750 DeploymentState::Pending
1752 | DeploymentState::Building
1753 | DeploymentState::InProgress => Ok(None),
1754 DeploymentState::Running
1756 | DeploymentState::Stopped
1757 | DeploymentState::Stopping
1758 | DeploymentState::Unknown(_)
1759 | DeploymentState::Failed => Ok(Some(cleanup)),
1760 }
1761 })
1762 .await?;
1763
1764 Ok(failed)
1765 }
1766
1767 async fn track_deployment_status_and_print_logs_on_fail(
1768 &self,
1769 proj_id: &str,
1770 depl_id: &str,
1771 raw: bool,
1772 ) -> Result<()> {
1773 let client = self.client.as_ref().unwrap();
1774 let failed = self.track_deployment_status(proj_id, depl_id).await?;
1775 if failed {
1776 let r = client.get_deployment_logs(proj_id, depl_id).await?;
1777 match self.output_mode {
1778 OutputMode::Normal => {
1779 let logs = r.into_inner().logs;
1780 for log in logs {
1781 if raw {
1782 println!("{}", log.line);
1783 } else {
1784 println!("{log}");
1785 }
1786 }
1787 }
1788 OutputMode::Json => {
1789 println!("{}", r.raw_json);
1790 }
1791 }
1792 return Err(anyhow!("Deployment failed"));
1793 }
1794
1795 Ok(())
1796 }
1797
1798 async fn project_create(&self) -> Result<()> {
1799 let client = self.client.as_ref().unwrap();
1800 let name = self.ctx.project_name();
1801 let r = client.create_project(name).await?;
1802
1803 match self.output_mode {
1804 OutputMode::Normal => {
1805 let project = r.into_inner();
1806 println!("Created project '{}' with id {}", project.name, project.id);
1807 }
1808 OutputMode::Json => {
1809 println!("{}", r.raw_json);
1810 }
1811 }
1812
1813 Ok(())
1814 }
1815 async fn project_rename(&self, name: String) -> Result<()> {
1816 let client = self.client.as_ref().unwrap();
1817
1818 let r = client
1819 .update_project(
1820 self.ctx.project_id(),
1821 ProjectUpdateRequest {
1822 name: Some(name),
1823 ..Default::default()
1824 },
1825 )
1826 .await?;
1827
1828 match self.output_mode {
1829 OutputMode::Normal => {
1830 let project = r.into_inner();
1831 println!("Renamed project {} to '{}'", project.id, project.name);
1832 }
1833 OutputMode::Json => {
1834 println!("{}", r.raw_json);
1835 }
1836 }
1837
1838 Ok(())
1839 }
1840
1841 async fn projects_list(&self, table_args: TableArgs) -> Result<()> {
1842 let client = self.client.as_ref().unwrap();
1843 let r = client.get_projects_list().await?;
1844
1845 match self.output_mode {
1846 OutputMode::Normal => {
1847 let all_projects = r.into_inner().projects;
1848 let mut all_projects_map = BTreeMap::new();
1850 for proj in all_projects {
1851 all_projects_map
1852 .entry(proj.team_id.clone())
1853 .or_insert_with(Vec::new)
1854 .push(proj);
1855 }
1856 for (team_id, projects) in all_projects_map {
1857 println!(
1858 "{}",
1859 if let Some(team_id) = team_id {
1860 format!("Team {} projects", team_id)
1861 } else {
1862 "Personal Projects".to_owned()
1863 }
1864 .bold()
1865 );
1866 println!("{}\n", get_projects_table(&projects, table_args.raw));
1867 }
1868 }
1869 OutputMode::Json => {
1870 println!("{}", r.raw_json);
1871 }
1872 }
1873
1874 Ok(())
1875 }
1876
1877 async fn project_status(&self) -> Result<()> {
1878 let client = self.client.as_ref().unwrap();
1879 let r = client.get_project(self.ctx.project_id()).await?;
1880
1881 match self.output_mode {
1882 OutputMode::Normal => {
1883 print!("{}", r.into_inner().to_string_colored());
1884 }
1885 OutputMode::Json => {
1886 println!("{}", r.raw_json);
1887 }
1888 }
1889
1890 Ok(())
1891 }
1892
1893 async fn project_delete(&self, no_confirm: bool) -> Result<()> {
1894 let client = self.client.as_ref().unwrap();
1895 let pid = self.ctx.project_id();
1896
1897 if !no_confirm {
1898 let proj = client.get_project(pid).await?.into_inner();
1900 eprintln!(
1901 "{}",
1902 formatdoc!(
1903 r#"
1904 WARNING:
1905 Are you sure you want to delete '{}' ({})?
1906 This will...
1907 - Shut down your service
1908 - Delete any databases and secrets in this project
1909 - Delete any custom domains linked to this project
1910 This action is permanent."#,
1911 proj.name,
1912 pid,
1913 )
1914 .bold()
1915 .red()
1916 );
1917 if !Confirm::with_theme(&ColorfulTheme::default())
1918 .with_prompt("Are you sure?")
1919 .default(false)
1920 .interact()
1921 .unwrap()
1922 {
1923 return Ok(());
1924 }
1925 }
1926
1927 let res = client.delete_project(pid).await?.into_inner();
1928
1929 println!("{res}");
1930
1931 Ok(())
1932 }
1933
1934 fn gather_build_files(&self) -> Result<BTreeMap<PathBuf, PathBuf>> {
1936 let include_patterns = self.ctx.include();
1937
1938 let project_directory = self.ctx.project_directory();
1939
1940 let mut entries = Vec::new();
1945
1946 let ignore_overrides = OverrideBuilder::new(project_directory)
1948 .add("!.git/")
1949 .context("adding override `!.git/`")?
1950 .add("!target/")
1951 .context("adding override `!target/`")?
1952 .build()
1953 .context("building archive override rules")?;
1954 for r in WalkBuilder::new(project_directory)
1955 .hidden(false)
1956 .overrides(ignore_overrides)
1957 .build()
1958 {
1959 entries.push(r.context("list dir entry")?.into_path())
1960 }
1961
1962 let mut globs = GlobSetBuilder::new();
1964 if let Some(rules) = include_patterns {
1965 for r in rules {
1966 globs.add(Glob::new(r.as_str()).context(format!("parsing glob pattern {:?}", r))?);
1967 }
1968 }
1969
1970 let globs = globs.build().context("glob glob")?;
1972 for entry in walkdir::WalkDir::new(project_directory) {
1973 let path = entry.context("list dir")?.into_path();
1974 if globs.is_match(
1975 path.strip_prefix(project_directory)
1976 .context("strip prefix of path")?,
1977 ) {
1978 entries.push(path);
1979 }
1980 }
1981
1982 let mut archive_files = BTreeMap::new();
1983 for path in entries {
1984 if path.is_dir() {
1986 trace!("Skipping {:?}: is a directory", path);
1987 continue;
1988 }
1989 if path.is_symlink() {
1991 trace!("Skipping {:?}: is a symlink", path);
1992 continue;
1993 }
1994
1995 let name = path
1997 .strip_prefix(project_directory)
1998 .context("strip prefix of path")?
1999 .to_owned();
2000
2001 archive_files.insert(path, name);
2002 }
2003
2004 Ok(archive_files)
2005 }
2006
2007 fn make_archive(&self) -> Result<Vec<u8>> {
2008 let archive_files = self.gather_build_files()?;
2009 if archive_files.is_empty() {
2010 error!("No files included in upload. Aborting...");
2011 bail!("No files included in upload.");
2012 }
2013
2014 let bytes = {
2015 debug!("making zip archive");
2016 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
2017 for (path, name) in archive_files {
2018 debug!("Packing {path:?}");
2019
2020 let name = name.to_str().expect("valid filename").replace('\\', "/");
2022 zip.start_file(name, FileOptions::<()>::default())?;
2023
2024 let mut b = Vec::new();
2025 fs::File::open(path)?.read_to_end(&mut b)?;
2026 zip.write_all(&b)?;
2027 }
2028 let r = zip.finish().context("finish encoding zip archive")?;
2029
2030 r.into_inner()
2031 };
2032 debug!("Archive size: {} bytes", bytes.len());
2033
2034 Ok(bytes)
2035 }
2036}
2037
2038async fn wait_with_spinner<Fut, C, O>(
2044 millis: u64,
2045 f: impl Fn(usize, ProgressBar) -> Fut,
2046) -> Result<O, anyhow::Error>
2047where
2048 Fut: std::future::Future<Output = Result<Option<C>>>,
2049 C: FnOnce() -> O,
2050{
2051 let progress_bar = create_spinner();
2052 let mut count = 0usize;
2053 let cleanup = loop {
2054 if let Some(cleanup) = f(count, progress_bar.clone()).await? {
2055 break cleanup;
2056 }
2057 count += 1;
2058 sleep(Duration::from_millis(millis)).await;
2059 };
2060 progress_bar.finish_and_clear();
2061
2062 Ok(cleanup())
2063}
2064
2065fn create_spinner() -> ProgressBar {
2066 let pb = indicatif::ProgressBar::new_spinner();
2067 pb.enable_steady_tick(std::time::Duration::from_millis(250));
2068 pb.set_style(
2069 indicatif::ProgressStyle::with_template("{spinner:.orange} {msg}")
2070 .unwrap()
2071 .tick_strings(&[
2072 "( ● )",
2073 "( ● )",
2074 "( ● )",
2075 "( ● )",
2076 "( ●)",
2077 "( ● )",
2078 "( ● )",
2079 "( ● )",
2080 "( ● )",
2081 "(● )",
2082 "(●●●●●●)",
2083 ]),
2084 );
2085
2086 pb
2087}
2088
2089#[cfg(test)]
2090mod tests {
2091 use zip::ZipArchive;
2092
2093 use crate::args::ProjectArgs;
2094 use crate::Shuttle;
2095 use std::fs::{self, canonicalize};
2096 use std::io::Cursor;
2097 use std::path::PathBuf;
2098
2099 pub fn path_from_workspace_root(path: &str) -> PathBuf {
2100 let path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
2101 .join("..")
2102 .join(path);
2103
2104 dunce::canonicalize(path).unwrap()
2105 }
2106
2107 async fn get_archive_entries(project_args: ProjectArgs) -> Vec<String> {
2108 let mut shuttle = Shuttle::new(crate::Binary::Shuttle, None).unwrap();
2109 shuttle
2110 .load_project(&project_args, false, false)
2111 .await
2112 .unwrap();
2113
2114 let archive = shuttle.make_archive().unwrap();
2115
2116 let mut zip = ZipArchive::new(Cursor::new(archive)).unwrap();
2117 (0..zip.len())
2118 .map(|i| zip.by_index(i).unwrap().name().to_owned())
2119 .collect()
2120 }
2121
2122 #[tokio::test]
2123 async fn make_archive_respect_rules() {
2124 let working_directory = canonicalize(path_from_workspace_root(
2125 "cargo-shuttle/tests/resources/archiving",
2126 ))
2127 .unwrap();
2128
2129 fs::write(working_directory.join("Secrets.toml"), "KEY = 'value'").unwrap();
2130 fs::write(working_directory.join("Secrets.dev.toml"), "KEY = 'dev'").unwrap();
2131 fs::write(working_directory.join("asset2"), "").unwrap();
2132 fs::write(working_directory.join("asset4"), "").unwrap();
2133 fs::create_dir_all(working_directory.join("dist")).unwrap();
2134 fs::write(working_directory.join("dist").join("dist1"), "").unwrap();
2135
2136 fs::create_dir_all(working_directory.join("target")).unwrap();
2137 fs::write(working_directory.join("target").join("binary"), b"12345").unwrap();
2138
2139 let project_args = ProjectArgs {
2140 working_directory: working_directory.clone(),
2141 name: None,
2142 id: Some("proj_archiving-test".to_owned()),
2143 };
2144 let mut entries = get_archive_entries(project_args.clone()).await;
2145 entries.sort();
2146
2147 let expected = vec![
2148 ".gitignore",
2149 ".ignore",
2150 "Cargo.toml",
2151 "Secrets.toml.example",
2152 "Shuttle.toml",
2153 "asset1", "asset2", "asset4", "asset5", "dist/dist1", "nested/static/nested1", "src/main.rs",
2162 ];
2163 assert_eq!(entries, expected);
2164 }
2165
2166 #[tokio::test]
2167 async fn finds_workspace_root() {
2168 let project_args = ProjectArgs {
2169 working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
2170 name: None,
2171 id: None,
2172 };
2173
2174 assert_eq!(
2175 project_args.workspace_path().unwrap(),
2176 path_from_workspace_root("examples/axum/hello-world")
2177 );
2178 }
2179}