cargo_shuttle/
lib.rs

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
77/// Returns the args and whether the PATH arg of the init command was explicitly given
78pub 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    // don't use an override if production is targetted
89    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            // let user set RUST_LOG if they want to
105            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    /// Alter behaviour based on which CLI is used
136    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        // Set up the API client for all commands that call the API
163        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                // calculate env-specific url if no explicit url given but an env was given
179                .or_else(|| args.api_env.as_ref().map(|env| other_env_api_url(env)))
180                // add /admin prefix if in admin mode
181                .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        // Load project context for all commands that need to know which project is being targetted
211        if matches!(
212            args.cmd,
213            Command::Deploy(..)
214                | Command::Deployment(..)
215                | Command::Resource(..)
216                | Command::Certificate(..)
217                | Command::Project(
218                    // ProjectCommand::List does not need to know which project we are in
219                    ProjectCommand::Create
220                        | ProjectCommand::Update(..)
221                        | ProjectCommand::Status
222                        | ProjectCommand::Delete { .. }
223                        | ProjectCommand::Link
224                )
225                | Command::Logs { .. }
226        ) {
227            // Command::Run only uses `load_local_config` (below) instead of `load_project` since it does not target a project in the API
228            self.load_project(
229                &args.project_args,
230                matches!(args.cmd, Command::Project(ProjectCommand::Link)),
231                // Only the deploy command should create a project if the provided name is not found in the project list.
232                // (ProjectCommand::Create should always make the POST call since it's an upsert operation)
233                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(()), // logic is done in `load_project` in previous step
302            },
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    /// Log in, initialize a project and potentially create the Shuttle environment for it.
311    ///
312    /// If project name, template, and path are passed as arguments, it will run without any extra
313    /// interaction.
314    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        // Turns the template or git args (if present) to a repo+folder.
322        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        // 1. Log in (if not logged in yet)
335        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        // 2. Ask for project name or validate the given one
344        let mut prev_name: Option<String> = None;
345        loop {
346            // prompt if interactive
347            let name: String = if let Some(name) = project_args.name.clone() {
348                name
349            } else {
350                // not using `validate_with` due to being blocking.
351                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            // validate and take action based on result
362            if self
363                .check_project_name(&mut project_args, name.clone())
364                .await
365            {
366                // success
367                break;
368            } else if needs_name {
369                // try again
370                eprintln!(r#"Type the same name again to use "{}" anyways."#, name);
371                prev_name = Some(name);
372            } else {
373                // don't continue if non-interactive
374                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        // 3. Confirm the project directory
384        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        // 4. Ask for the template
420        let template = match git_template {
421            Some(git_template) => git_template,
422            None => {
423                // Try to present choices from our up-to-date examples.
424                // Fall back to the internal (potentially outdated) list.
425                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", // TODO(when templates page is live): Add link to it?
458                        ])
459                        .clear(false)
460                        .default(0)
461                        .interact()?;
462                    eprintln!();
463                    if i == 0 {
464                        // Use a Hello world starter
465                        let mut starters = schema.starters.into_values().collect::<Vec<_>>();
466                        starters.sort_by_key(|t| {
467                            // Make the "No templates" appear last in the list
468                            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                        // Browse all non-starter templates
497                        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        // 5. Initialize locally
550        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        // 6. Confirm that the user wants to create the project on Shuttle
562        let should_create_project = if should_link {
563            // user wants to link project that already exists
564            false
565        } else if !interactive {
566            // non-interactive mode: use value of arg
567            args.create_project
568        } else if args.create_project {
569            // interactive and arg is true
570            true
571        } else {
572            // interactive and arg was not set, so ask
573            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            // Set the project working directory path to the init path,
590            // so `load_project` is ran with the correct project path when linking
591            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    /// Return value: true -> success or unknown. false -> try again.
605    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                // should not be possible
619                panic!("Unexpected API response");
620            }
621            Err(e) => {
622                // If API error contains message regarding format of error name, print that error and prompt again
623                if let Ok(api_error) = e.downcast::<ApiError>() {
624                    // If the returned error string changes, this could break
625                    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                // Else, the API error was about something else.
632                // Ignore and keep going to not prevent the flow of the init command.
633                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    /// Ensures a project id is known, either by explicit --id/--name args or config file(s)
645    /// or by asking user to link the project folder.
646    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        // load project id from args if given or from file if exists
656        self.ctx.load_local_internal_config(project_args)?;
657
658        if let Some(id) = project_args.id.as_ref() {
659            // Validate format of explicitly given project id and change the ULID to uppercase if it is lowercase
660            if let Some(proj_id_uppercase) = id.strip_prefix("proj_").and_then(|suffix| {
661                // Soft (dumb) validation of ULID format (ULIDs are 26 chars)
662                (suffix.len() == 26).then_some(format!("proj_{}", suffix.to_ascii_uppercase()))
663            }) {
664                if *id != proj_id_uppercase {
665                    eprintln!("INFO: Converted project id to '{}'", proj_id_uppercase);
666                    self.ctx.set_project_id(proj_id_uppercase);
667                }
668            } else {
669                // TODO: eprintln a warning?
670                warn!("project id with bad format detected: '{id}'");
671            }
672
673            // if linking, save config
674            if do_linking {
675                eprintln!("Linking to project {}", self.ctx.project_id());
676                self.ctx.save_local_internal()?;
677            }
678        }
679        if self.ctx.project_id_found() {
680            // --id takes prio over --name, and at this point we know the id, so we're done
681            return Ok(());
682        }
683
684        // translate project name to project id if a name was given
685        if let Some(name) = project_args.name.as_ref() {
686            let client = self.client.as_ref().unwrap();
687            trace!(%name, "looking up project id from project name");
688            if let Some(proj) = client
689                .get_projects_list()
690                .await?
691                .into_inner()
692                .projects
693                .into_iter()
694                .find(|p| p.name == *name)
695            {
696                trace!("found project by name");
697                self.ctx.set_project_id(proj.id);
698            } else {
699                trace!("did not find project by name");
700                if create_missing_project {
701                    trace!("creating project since it was not found");
702                    // This is a side effect (non-primary output), so OutputMode::Json is not considered
703                    let proj = client.create_project(name).await?.into_inner();
704                    eprintln!("Created project '{}' with id {}", proj.name, proj.id);
705                    self.ctx.set_project_id(proj.id);
706                }
707            }
708        }
709
710        match (self.ctx.project_id_found(), do_linking) {
711            // if project id is known and we are linking, save config
712            (true, true) => {
713                eprintln!("Linking to project {}", self.ctx.project_id());
714                self.ctx.save_local_internal()?;
715            }
716            // if project id is known, we are done and nothing more to do
717            (true, false) => (),
718            // we still don't know the project id but want to link
719            (false, true) => {
720                self.project_link_interactive().await?;
721            }
722            // we still don't know the project id
723            (false, false) => {
724                // if a name was given but no project was found, (incorrectly) set the id to the name so that an api error will be encountered
725                // TODO: return an error here instead of letting an api error happen?
726                if let Some(name) = project_args.name.as_ref() {
727                    warn!("using project name as the id");
728                    self.ctx.set_project_id(name.clone());
729                } else {
730                    // otherwise, we have to do an explicit linking
731                    trace!("no project id found");
732                    self.project_link_interactive().await?;
733                }
734            }
735        }
736
737        Ok(())
738    }
739
740    async fn project_link_interactive(&mut self) -> Result<()> {
741        let client = self.client.as_ref().unwrap();
742        let projs = client.get_projects_list().await?.into_inner().projects;
743
744        let theme = ColorfulTheme::default();
745
746        let selected_project = if projs.is_empty() {
747            eprintln!("Create a new project to link to this directory:");
748
749            None
750        } else {
751            eprintln!("Which project do you want to link this directory to?");
752
753            let mut items = projs
754                .iter()
755                .map(|p| {
756                    if let Some(team_id) = p.team_id.as_ref() {
757                        format!("Team {}: {}", team_id, p.name)
758                    } else {
759                        p.name.clone()
760                    }
761                })
762                .collect::<Vec<_>>();
763            items.extend_from_slice(&["[CREATE NEW]".to_string()]);
764            let index = Select::with_theme(&theme)
765                .items(&items)
766                .default(0)
767                .interact()?;
768
769            if index == projs.len() {
770                // last item selected (create new)
771                None
772            } else {
773                Some(projs[index].clone())
774            }
775        };
776
777        let proj = match selected_project {
778            Some(proj) => proj,
779            None => {
780                let name: String = Input::with_theme(&theme)
781                    .with_prompt("Project name")
782                    .interact()?;
783
784                // This is a side effect (non-primary output), so OutputMode::Json is not considered
785                let proj = client.create_project(&name).await?.into_inner();
786                eprintln!("Created project '{}' with id {}", proj.name, proj.id);
787
788                proj
789            }
790        };
791
792        eprintln!("Linking to project '{}' with id {}", proj.name, proj.id);
793        self.ctx.set_project_id(proj.id);
794        self.ctx.save_local_internal()?;
795
796        Ok(())
797    }
798
799    async fn account(&self) -> Result<()> {
800        let client = self.client.as_ref().unwrap();
801        let r = client.get_current_user().await?;
802        match self.output_mode {
803            OutputMode::Normal => {
804                print!("{}", r.into_inner().to_string_colored());
805            }
806            OutputMode::Json => {
807                println!("{}", r.raw_json);
808            }
809        }
810
811        Ok(())
812    }
813
814    /// Log in with the given API key or after prompting the user for one.
815    async fn login(&mut self, login_args: LoginArgs, offline: bool, login_cmd: bool) -> Result<()> {
816        let api_key = match login_args.api_key {
817            Some(api_key) => api_key,
818            None => {
819                if login_args.prompt {
820                    Password::with_theme(&ColorfulTheme::default())
821                        .with_prompt("API key")
822                        .validate_with(|input: &String| {
823                            if input.is_empty() {
824                                return Err("Empty API key was provided");
825                            }
826                            Ok(())
827                        })
828                        .interact()?
829                } else {
830                    // device auth flow via Shuttle Console
831                    self.device_auth(login_args.console_url).await?
832                }
833            }
834        };
835
836        self.ctx.set_api_key(api_key.clone())?;
837
838        if let Some(client) = self.client.as_mut() {
839            client.api_key = Some(api_key);
840
841            if offline {
842                eprintln!("INFO: Skipping API key verification");
843            } else {
844                let (user, raw_json) = client
845                    .get_current_user()
846                    .await
847                    .context("failed to check API key validity")?
848                    .into_parts();
849                if login_cmd {
850                    match self.output_mode {
851                        OutputMode::Normal => {
852                            println!("Logged in as {}", user.id.bold());
853                        }
854                        OutputMode::Json => {
855                            println!("{}", raw_json);
856                        }
857                    }
858                } else {
859                    eprintln!("Logged in as {}", user.id.bold());
860                }
861            }
862        }
863
864        Ok(())
865    }
866
867    async fn device_auth(&self, console_url: Option<String>) -> Result<String> {
868        let client = self.client.as_ref().unwrap();
869
870        // should not have trailing slash
871        if let Some(u) = console_url.as_ref() {
872            if u.ends_with('/') {
873                eprintln!("WARNING: Console URL is probably incorrect. Ends with '/': {u}");
874            }
875        }
876
877        let (mut tx, mut rx) = client.get_device_auth_ws().await?.split();
878
879        // keep the socket alive with ping/pong
880        let pinger = tokio::spawn(async move {
881            loop {
882                if let Err(e) = tx.send(Message::Ping(Default::default())).await {
883                    error!(error = %e, "Error when pinging websocket");
884                    break;
885                };
886                sleep(Duration::from_secs(20)).await;
887            }
888        });
889
890        let token_message = read_ws_until_text(&mut rx).await?;
891        let Some(token_message) = token_message else {
892            bail!("Did not receive device auth token over websocket");
893        };
894        let token_message = serde_json::from_str::<TokenMessage>(&token_message)?;
895        let token = token_message.token;
896
897        // use provided url if it exists, otherwise fall back to old behavior of constructing it here
898        let url = token_message.url.unwrap_or_else(|| {
899            format!(
900                "{}/device-auth?token={}",
901                console_url.as_deref().unwrap_or(SHUTTLE_CONSOLE_URL),
902                token
903            )
904        });
905        let _ = webbrowser::open(&url);
906        eprintln!("Complete login in Shuttle Console to authenticate the Shuttle CLI.");
907        eprintln!("If your browser did not automatically open, go to {url}");
908        eprintln!();
909        eprintln!("{}", format!("Token: {token}").bold());
910        eprintln!();
911
912        let key = read_ws_until_text(&mut rx).await?;
913        let Some(key) = key else {
914            bail!("Failed to receive API key over websocket");
915        };
916        let key = serde_json::from_str::<KeyMessage>(&key)?.api_key;
917
918        pinger.abort();
919
920        Ok(key)
921    }
922
923    async fn logout(&mut self, logout_args: LogoutArgs) -> Result<()> {
924        if logout_args.reset_api_key {
925            let client = self.client.as_ref().unwrap();
926            client.reset_api_key().await.context("Resetting API key")?;
927            eprintln!("Successfully reset the API key.");
928        }
929        self.ctx.clear_api_key()?;
930        eprintln!("Successfully logged out.");
931        eprintln!(" -> Use `shuttle login` to log in again.");
932
933        Ok(())
934    }
935
936    async fn stop(&self, tracking_args: DeploymentTrackingArgs) -> Result<()> {
937        let client = self.client.as_ref().unwrap();
938        let pid = self.ctx.project_id();
939        let res = client.stop_service(pid).await?.into_inner();
940        println!("{res}");
941
942        if tracking_args.no_follow {
943            return Ok(());
944        }
945
946        wait_with_spinner(2000, |_, pb| async move {
947            let (deployment, raw_json) = client.get_current_deployment(pid).await?.into_parts();
948
949            let get_cleanup = |d: Option<DeploymentResponse>| {
950                move || {
951                    if let Some(d) = d {
952                        match self.output_mode {
953                            OutputMode::Normal => {
954                                eprintln!("{}", d.to_string_colored());
955                            }
956                            OutputMode::Json => {
957                                // last deployment response already printed
958                            }
959                        }
960                    }
961                }
962            };
963            let Some(deployment) = deployment else {
964                return Ok(Some(get_cleanup(None)));
965            };
966
967            let state = deployment.state.clone();
968            match self.output_mode {
969                OutputMode::Normal => {
970                    pb.set_message(deployment.to_string_summary_colored());
971                }
972                OutputMode::Json => {
973                    println!("{}", raw_json);
974                }
975            }
976            let cleanup = get_cleanup(Some(deployment));
977            match state {
978                DeploymentState::Pending
979                | DeploymentState::Stopping
980                | DeploymentState::InProgress
981                | DeploymentState::Running => Ok(None),
982                DeploymentState::Building // a building deployment should take it back to InProgress then Running, so don't follow that sequence
983                | DeploymentState::Failed
984                | DeploymentState::Stopped
985                | DeploymentState::Unknown(_) => Ok(Some(cleanup)),
986            }
987        })
988        .await?;
989
990        Ok(())
991    }
992
993    async fn logs(&self, args: LogsArgs) -> Result<()> {
994        if args.follow {
995            eprintln!("Streamed logs are not yet supported on the shuttle.dev platform.");
996            return Ok(());
997        }
998        if args.tail.is_some() | args.head.is_some() {
999            eprintln!("Fetching log ranges are not yet supported on the shuttle.dev platform.");
1000            return Ok(());
1001        }
1002        let client = self.client.as_ref().unwrap();
1003        let pid = self.ctx.project_id();
1004        let r = if args.all_deployments {
1005            client.get_project_logs(pid).await?
1006        } else {
1007            let id = if args.latest {
1008                // Find latest deployment (not always an active one)
1009                let deployments = client
1010                    .get_deployments(pid, 1, 1)
1011                    .await?
1012                    .into_inner()
1013                    .deployments;
1014                let Some(most_recent) = deployments.into_iter().next() else {
1015                    println!("No deployments found");
1016                    return Ok(());
1017                };
1018                eprintln!("Getting logs from: {}", most_recent.id);
1019                most_recent.id
1020            } else if let Some(id) = args.id {
1021                id
1022            } else {
1023                let Some(current) = client.get_current_deployment(pid).await?.into_inner() else {
1024                    println!("No deployments found");
1025                    return Ok(());
1026                };
1027                eprintln!("Getting logs from: {}", current.id);
1028                current.id
1029            };
1030            client.get_deployment_logs(pid, &id).await?
1031        };
1032        match self.output_mode {
1033            OutputMode::Normal => {
1034                let logs = r.into_inner().logs;
1035                for log in logs {
1036                    if args.raw {
1037                        println!("{}", log.line);
1038                    } else {
1039                        println!("{log}");
1040                    }
1041                }
1042            }
1043            OutputMode::Json => {
1044                println!("{}", r.raw_json);
1045            }
1046        }
1047
1048        Ok(())
1049    }
1050
1051    async fn deployments_list(&self, page: u32, limit: u32, table_args: TableArgs) -> Result<()> {
1052        let client = self.client.as_ref().unwrap();
1053        if limit == 0 {
1054            return Ok(());
1055        }
1056        let proj_name = self.ctx.project_name();
1057
1058        // fetch one additional to know if there is another page available
1059        let limit = limit + 1;
1060        let (deployments, raw_json) = client
1061            .get_deployments(self.ctx.project_id(), page as i32, limit as i32)
1062            .await?
1063            .into_parts();
1064        let mut deployments = deployments.deployments;
1065        let page_hint = if deployments.len() == limit as usize {
1066            // hide the extra one and show hint instead
1067            deployments.pop();
1068            true
1069        } else {
1070            false
1071        };
1072        match self.output_mode {
1073            OutputMode::Normal => {
1074                let table = deployments_table(&deployments, table_args.raw);
1075                println!(
1076                    "{}",
1077                    format!("Deployments in project '{}'", proj_name).bold()
1078                );
1079                println!("{table}");
1080                if page_hint {
1081                    println!("View the next page using `--page {}`", page + 1);
1082                }
1083            }
1084            OutputMode::Json => {
1085                println!("{}", raw_json);
1086            }
1087        }
1088
1089        Ok(())
1090    }
1091
1092    async fn deployment_get(&self, deployment_id: Option<String>) -> Result<()> {
1093        let client = self.client.as_ref().unwrap();
1094        let pid = self.ctx.project_id();
1095
1096        let deployment = match deployment_id {
1097            Some(id) => {
1098                let r = client.get_deployment(pid, &id).await?;
1099                if self.output_mode == OutputMode::Json {
1100                    println!("{}", r.raw_json);
1101                    return Ok(());
1102                }
1103                r.into_inner()
1104            }
1105            None => {
1106                let r = client.get_current_deployment(pid).await?;
1107                if self.output_mode == OutputMode::Json {
1108                    println!("{}", r.raw_json);
1109                    return Ok(());
1110                }
1111
1112                let Some(d) = r.into_inner() else {
1113                    println!("No deployment found");
1114                    return Ok(());
1115                };
1116                d
1117            }
1118        };
1119
1120        println!("{}", deployment.to_string_colored());
1121
1122        Ok(())
1123    }
1124
1125    async fn deployment_redeploy(
1126        &self,
1127        deployment_id: Option<String>,
1128        tracking_args: DeploymentTrackingArgs,
1129    ) -> Result<()> {
1130        let client = self.client.as_ref().unwrap();
1131
1132        let pid = self.ctx.project_id();
1133        let deployment_id = match deployment_id {
1134            Some(id) => id,
1135            None => {
1136                let d = client.get_current_deployment(pid).await?.into_inner();
1137                let Some(d) = d else {
1138                    println!("No deployment found");
1139                    return Ok(());
1140                };
1141                d.id
1142            }
1143        };
1144        let (deployment, raw_json) = client.redeploy(pid, &deployment_id).await?.into_parts();
1145
1146        if tracking_args.no_follow {
1147            match self.output_mode {
1148                OutputMode::Normal => {
1149                    println!("{}", deployment.to_string_colored());
1150                }
1151                OutputMode::Json => {
1152                    println!("{}", raw_json);
1153                }
1154            }
1155            return Ok(());
1156        }
1157
1158        self.track_deployment_status_and_print_logs_on_fail(pid, &deployment.id, tracking_args.raw)
1159            .await
1160    }
1161
1162    async fn resources_list(&self, table_args: TableArgs, show_secrets: bool) -> Result<()> {
1163        let client = self.client.as_ref().unwrap();
1164        let pid = self.ctx.project_id();
1165        let r = client.get_service_resources(pid).await?;
1166
1167        match self.output_mode {
1168            OutputMode::Normal => {
1169                let table = get_resource_tables(
1170                    r.into_inner().resources.as_slice(),
1171                    pid,
1172                    table_args.raw,
1173                    show_secrets,
1174                );
1175                println!("{table}");
1176            }
1177            OutputMode::Json => {
1178                println!("{}", r.raw_json);
1179            }
1180        }
1181
1182        Ok(())
1183    }
1184
1185    async fn resource_delete(&self, resource_type: &ResourceType, no_confirm: bool) -> Result<()> {
1186        let client = self.client.as_ref().unwrap();
1187
1188        if !no_confirm {
1189            eprintln!(
1190                "{}",
1191                formatdoc!(
1192                    "
1193                WARNING:
1194                    Are you sure you want to delete this project's {}?
1195                    This action is permanent.",
1196                    resource_type
1197                )
1198                .bold()
1199                .red()
1200            );
1201            if !Confirm::with_theme(&ColorfulTheme::default())
1202                .with_prompt("Are you sure?")
1203                .default(false)
1204                .interact()
1205                .unwrap()
1206            {
1207                return Ok(());
1208            }
1209        }
1210
1211        let msg = client
1212            .delete_service_resource(self.ctx.project_id(), resource_type)
1213            .await?
1214            .into_inner();
1215        println!("{msg}");
1216
1217        eprintln!(
1218            "{}",
1219            formatdoc! {"
1220                Note:
1221                    Remember to remove the resource annotation from your #[shuttle_runtime::main] function.
1222                    Otherwise, it will be provisioned again during the next deployment."
1223            }
1224            .yellow(),
1225        );
1226
1227        Ok(())
1228    }
1229
1230    async fn resource_dump(&self, _resource_type: &ResourceType) -> Result<()> {
1231        unimplemented!();
1232        // let client = self.client.as_ref().unwrap();
1233        // let bytes = client...;
1234        // std::io::stdout().write_all(&bytes).unwrap();
1235        // Ok(())
1236    }
1237
1238    async fn list_certificates(&self, table_args: TableArgs) -> Result<()> {
1239        let client = self.client.as_ref().unwrap();
1240        let r = client.list_certificates(self.ctx.project_id()).await?;
1241
1242        match self.output_mode {
1243            OutputMode::Normal => {
1244                let table =
1245                    get_certificates_table(r.into_inner().certificates.as_ref(), table_args.raw);
1246                println!("{table}");
1247            }
1248            OutputMode::Json => {
1249                println!("{}", r.raw_json);
1250            }
1251        }
1252
1253        Ok(())
1254    }
1255    async fn add_certificate(&self, domain: String) -> Result<()> {
1256        let client = self.client.as_ref().unwrap();
1257        let r = client
1258            .add_certificate(self.ctx.project_id(), domain.clone())
1259            .await?;
1260
1261        match self.output_mode {
1262            OutputMode::Normal => {
1263                println!("Added certificate for {}", r.into_inner().subject);
1264            }
1265            OutputMode::Json => {
1266                println!("{}", r.raw_json);
1267            }
1268        }
1269
1270        Ok(())
1271    }
1272    async fn delete_certificate(&self, domain: String, no_confirm: bool) -> Result<()> {
1273        let client = self.client.as_ref().unwrap();
1274
1275        if !no_confirm {
1276            eprintln!(
1277                "{}",
1278                formatdoc!(
1279                    "
1280                WARNING:
1281                    Delete the certificate for {}?",
1282                    domain
1283                )
1284                .bold()
1285                .red()
1286            );
1287            if !Confirm::with_theme(&ColorfulTheme::default())
1288                .with_prompt("Are you sure?")
1289                .default(false)
1290                .interact()
1291                .unwrap()
1292            {
1293                return Ok(());
1294            }
1295        }
1296
1297        let msg = client
1298            .delete_certificate(self.ctx.project_id(), domain.clone())
1299            .await?
1300            .into_inner();
1301        println!("{msg}");
1302
1303        Ok(())
1304    }
1305
1306    fn get_secrets(
1307        args: &SecretsArgs,
1308        workspace_root: &Path,
1309        dev: bool,
1310    ) -> Result<Option<HashMap<String, String>>> {
1311        // Look for a secrets file, first in the command args, then in the root of the workspace.
1312        let files: &[PathBuf] = if dev {
1313            &[
1314                workspace_root.join("Secrets.dev.toml"),
1315                workspace_root.join("Secrets.toml"),
1316            ]
1317        } else {
1318            &[workspace_root.join("Secrets.toml")]
1319        };
1320        let secrets_file = args.secrets.as_ref().or_else(|| {
1321            files
1322                .iter()
1323                .find(|&secrets_file| secrets_file.exists() && secrets_file.is_file())
1324        });
1325
1326        let Some(secrets_file) = secrets_file else {
1327            trace!("No secrets file was found");
1328            return Ok(None);
1329        };
1330
1331        trace!("Loading secrets from {}", secrets_file.display());
1332        let Ok(secrets_str) = fs::read_to_string(secrets_file) else {
1333            tracing::warn!("Failed to read secrets file, no secrets were loaded");
1334            return Ok(None);
1335        };
1336
1337        let secrets = toml::from_str::<HashMap<String, String>>(&secrets_str)
1338            .context("parsing secrets file")?;
1339        trace!(keys = ?secrets.keys(), "Loaded secrets");
1340
1341        Ok(Some(secrets))
1342    }
1343
1344    async fn pre_local_run(&self, run_args: &RunArgs) -> Result<BuiltService> {
1345        trace!("starting a local run with args: {run_args:?}");
1346
1347        let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(256);
1348        tokio::task::spawn(async move {
1349            while let Some(line) = rx.recv().await {
1350                println!("{line}");
1351            }
1352        });
1353
1354        let project_directory = self.ctx.project_directory();
1355
1356        println!(
1357            "{} {}",
1358            "    Building".bold().green(),
1359            project_directory.display()
1360        );
1361
1362        build_workspace(project_directory, run_args.release, tx).await
1363    }
1364
1365    fn find_available_port(run_args: &mut RunArgs) {
1366        let original_port = run_args.port;
1367        for port in (run_args.port..=u16::MAX).step_by(10) {
1368            if !portpicker::is_free_tcp(port) {
1369                continue;
1370            }
1371            run_args.port = port;
1372            break;
1373        }
1374
1375        if run_args.port != original_port {
1376            eprintln!(
1377                "Port {} is already in use. Using port {}.",
1378                original_port, run_args.port,
1379            )
1380        };
1381    }
1382
1383    async fn local_run(&self, mut run_args: RunArgs, debug: bool) -> Result<()> {
1384        let project_name = self.ctx.project_name().to_owned();
1385        let project_directory = self.ctx.project_directory();
1386
1387        // Handle bacon mode
1388        if run_args.bacon {
1389            println!(
1390                "\n    {} {} in watch mode using bacon\n",
1391                "Starting".bold().green(),
1392                project_name
1393            );
1394            return bacon::run_bacon(project_directory).await;
1395        }
1396
1397        let service = self.pre_local_run(&run_args).await?;
1398        trace!(path = ?service.executable_path, "runtime executable");
1399
1400        let secrets = Shuttle::get_secrets(&run_args.secret_args, project_directory, true)?
1401            .unwrap_or_default();
1402        Shuttle::find_available_port(&mut run_args);
1403        if let Some(warning) = check_and_warn_runtime_version(&service.executable_path).await? {
1404            eprint!("{}", warning);
1405        }
1406
1407        let runtime_executable = service.executable_path.clone();
1408        let api_port = portpicker::pick_unused_port()
1409            .expect("failed to find available port for local provisioner server");
1410        let api_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), api_port);
1411        let healthz_port = portpicker::pick_unused_port()
1412            .expect("failed to find available port for runtime health check");
1413        let ip = if run_args.external {
1414            Ipv4Addr::UNSPECIFIED
1415        } else {
1416            Ipv4Addr::LOCALHOST
1417        };
1418
1419        let state = Arc::new(ProvApiState {
1420            project_name: project_name.clone(),
1421            secrets,
1422        });
1423        tokio::spawn(async move { ProvisionerServer::run(state, &api_addr).await });
1424
1425        println!(
1426            "\n    {} {} on http://{}:{}\n",
1427            "Starting".bold().green(),
1428            service.target_name,
1429            ip,
1430            run_args.port,
1431        );
1432
1433        let mut envs = vec![
1434            ("SHUTTLE_BETA", "true".to_owned()),
1435            ("SHUTTLE_PROJECT_ID", "proj_LOCAL".to_owned()),
1436            ("SHUTTLE_PROJECT_NAME", project_name),
1437            ("SHUTTLE_ENV", Environment::Local.to_string()),
1438            ("SHUTTLE_RUNTIME_IP", ip.to_string()),
1439            ("SHUTTLE_RUNTIME_PORT", run_args.port.to_string()),
1440            ("SHUTTLE_HEALTHZ_PORT", healthz_port.to_string()),
1441            ("SHUTTLE_API", format!("http://127.0.0.1:{}", api_port)),
1442        ];
1443        // Use a nice debugging tracing level if user does not provide their own
1444        if debug && std::env::var("RUST_LOG").is_err() {
1445            envs.push(("RUST_LOG", "info,shuttle=trace,reqwest=debug".to_owned()));
1446        }
1447
1448        info!(
1449            path = %runtime_executable.display(),
1450            "Spawning runtime process",
1451        );
1452        let mut runtime = tokio::process::Command::new(
1453            dunce::canonicalize(runtime_executable).context("canonicalize path of executable")?,
1454        )
1455        .current_dir(&service.workspace_path)
1456        .envs(envs)
1457        .stdout(std::process::Stdio::piped())
1458        .stderr(std::process::Stdio::piped())
1459        .kill_on_drop(true)
1460        .spawn()
1461        .context("spawning runtime process")?;
1462
1463        // Start background tasks for reading runtime's stdout and stderr
1464        let raw = run_args.raw;
1465        let mut stdout_reader = BufReader::new(
1466            runtime
1467                .stdout
1468                .take()
1469                .context("child process did not have a handle to stdout")?,
1470        )
1471        .lines();
1472        tokio::spawn(async move {
1473            while let Some(line) = stdout_reader.next_line().await.unwrap() {
1474                if raw {
1475                    println!("{}", line);
1476                } else {
1477                    let log_item = LogItem::new(Utc::now(), "app".to_owned(), line);
1478                    println!("{log_item}");
1479                }
1480            }
1481        });
1482        let mut stderr_reader = BufReader::new(
1483            runtime
1484                .stderr
1485                .take()
1486                .context("child process did not have a handle to stderr")?,
1487        )
1488        .lines();
1489        tokio::spawn(async move {
1490            while let Some(line) = stderr_reader.next_line().await.unwrap() {
1491                if raw {
1492                    println!("{}", line);
1493                } else {
1494                    let log_item = LogItem::new(Utc::now(), "app".to_owned(), line);
1495                    println!("{log_item}");
1496                }
1497            }
1498        });
1499
1500        // Start background task for simulated health check
1501        tokio::spawn(async move {
1502            loop {
1503                // ECS health check runs ever 5s
1504                tokio::time::sleep(tokio::time::Duration::from_millis(5000)).await;
1505
1506                tracing::trace!("Health check against runtime");
1507                if let Err(e) = reqwest::get(format!("http://127.0.0.1:{}/", healthz_port)).await {
1508                    tracing::trace!("Health check against runtime failed: {e}");
1509                }
1510            }
1511        });
1512
1513        #[cfg(target_family = "unix")]
1514        let exit_result = {
1515            let mut sigterm_notif =
1516                tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1517                    .expect("Can not get the SIGTERM signal receptor");
1518            let mut sigint_notif =
1519                tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
1520                    .expect("Can not get the SIGINT signal receptor");
1521            tokio::select! {
1522                exit_result = runtime.wait() => {
1523                    Some(exit_result)
1524                }
1525                _ = sigterm_notif.recv() => {
1526                    eprintln!("Received SIGTERM. Killing the runtime...");
1527                    None
1528                },
1529                _ = sigint_notif.recv() => {
1530                    eprintln!("Received SIGINT. Killing the runtime...");
1531                    None
1532                }
1533            }
1534        };
1535        #[cfg(target_family = "windows")]
1536        let exit_result = {
1537            let mut ctrl_break_notif = tokio::signal::windows::ctrl_break()
1538                .expect("Can not get the CtrlBreak signal receptor");
1539            let mut ctrl_c_notif =
1540                tokio::signal::windows::ctrl_c().expect("Can not get the CtrlC signal receptor");
1541            let mut ctrl_close_notif = tokio::signal::windows::ctrl_close()
1542                .expect("Can not get the CtrlClose signal receptor");
1543            let mut ctrl_logoff_notif = tokio::signal::windows::ctrl_logoff()
1544                .expect("Can not get the CtrlLogoff signal receptor");
1545            let mut ctrl_shutdown_notif = tokio::signal::windows::ctrl_shutdown()
1546                .expect("Can not get the CtrlShutdown signal receptor");
1547            tokio::select! {
1548                exit_result = runtime.wait() => {
1549                    Some(exit_result)
1550                }
1551                _ = ctrl_break_notif.recv() => {
1552                    eprintln!("Received ctrl-break.");
1553                    None
1554                },
1555                _ = ctrl_c_notif.recv() => {
1556                    eprintln!("Received ctrl-c.");
1557                    None
1558                },
1559                _ = ctrl_close_notif.recv() => {
1560                    eprintln!("Received ctrl-close.");
1561                    None
1562                },
1563                _ = ctrl_logoff_notif.recv() => {
1564                    eprintln!("Received ctrl-logoff.");
1565                    None
1566                },
1567                _ = ctrl_shutdown_notif.recv() => {
1568                    eprintln!("Received ctrl-shutdown.");
1569                    None
1570                }
1571            }
1572        };
1573        match exit_result {
1574            Some(Ok(exit_status)) => {
1575                bail!(
1576                    "Runtime process exited with code {}",
1577                    exit_status.code().unwrap_or_default()
1578                );
1579            }
1580            Some(Err(e)) => {
1581                bail!("Failed to wait for runtime process to exit: {e}");
1582            }
1583            None => {
1584                runtime.kill().await?;
1585            }
1586        }
1587
1588        Ok(())
1589    }
1590
1591    async fn deploy(&mut self, args: DeployArgs) -> Result<()> {
1592        let client = self.client.as_ref().unwrap();
1593        let project_directory = self.ctx.project_directory();
1594        let manifest_path = project_directory.join("Cargo.toml");
1595
1596        let secrets = Shuttle::get_secrets(&args.secret_args, project_directory, false)?;
1597
1598        // Image deployment mode
1599        if let Some(image) = args.image {
1600            let pid = self.ctx.project_id();
1601            let deployment_req_image = DeploymentRequestImage { image, secrets };
1602
1603            let (deployment, raw_json) = client
1604                .deploy(pid, DeploymentRequest::Image(deployment_req_image))
1605                .await?
1606                .into_parts();
1607
1608            if args.tracking_args.no_follow {
1609                match self.output_mode {
1610                    OutputMode::Normal => {
1611                        println!("{}", deployment.to_string_colored());
1612                    }
1613                    OutputMode::Json => {
1614                        println!("{}", raw_json);
1615                    }
1616                }
1617                return Ok(());
1618            }
1619
1620            return self
1621                .track_deployment_status_and_print_logs_on_fail(
1622                    pid,
1623                    &deployment.id,
1624                    args.tracking_args.raw,
1625                )
1626                .await;
1627        }
1628
1629        // Build archive deployment mode
1630        let mut deployment_req = DeploymentRequestBuildArchive {
1631            secrets,
1632            ..Default::default()
1633        };
1634        let mut build_meta = BuildMeta::default();
1635        let mut rust_build_args = BuildArgsRust::default();
1636
1637        let metadata = async_cargo_metadata(manifest_path.as_path()).await?;
1638        // TODO: support overriding this
1639        let (package, target, runtime_version) = find_first_shuttle_package(&metadata)?;
1640        rust_build_args.package_name = Some(package.name.clone());
1641        rust_build_args.binary_name = Some(target.name.clone());
1642        rust_build_args.shuttle_runtime_version = runtime_version;
1643
1644        // activate shuttle feature if present
1645        let (no_default_features, features) = if package.features.contains_key("shuttle") {
1646            (true, Some(vec!["shuttle".to_owned()]))
1647        } else {
1648            (false, None)
1649        };
1650        rust_build_args.no_default_features = no_default_features;
1651        rust_build_args.features = features.map(|v| v.join(","));
1652
1653        // TODO: determine which (one) binary to build
1654
1655        deployment_req.build_args = Some(BuildArgs::Rust(rust_build_args));
1656
1657        // TODO: have all of the above be configurable in CLI and Shuttle.toml
1658
1659        deployment_req.infra = parse_infra_from_code(
1660            &fs::read_to_string(target.src_path.as_path())
1661                .context("reading target file when extracting infra annotations")?,
1662        )
1663        .context("parsing infra annotations")?;
1664
1665        if let Ok(repo) = Repository::discover(project_directory) {
1666            let repo_path = repo
1667                .workdir()
1668                .context("getting working directory of repository")?;
1669            let repo_path = dunce::canonicalize(repo_path)?;
1670            trace!(?repo_path, "found git repository");
1671
1672            let dirty = is_dirty(&repo);
1673            build_meta.git_dirty = Some(dirty.is_err());
1674
1675            let check_dirty = self.ctx.deny_dirty().is_some_and(|d| d);
1676            if check_dirty && !args.allow_dirty && dirty.is_err() {
1677                bail!(dirty.unwrap_err());
1678            }
1679
1680            if let Ok(head) = repo.head() {
1681                // This is typically the name of the current branch
1682                // It is "HEAD" when head detached, for example when a tag is checked out
1683                build_meta.git_branch = head
1684                    .shorthand()
1685                    .map(|s| s.chars().take(GIT_STRINGS_MAX_LENGTH).collect());
1686                if let Ok(commit) = head.peel_to_commit() {
1687                    build_meta.git_commit_id = Some(commit.id().to_string());
1688                    // Summary is None if error or invalid utf-8
1689                    build_meta.git_commit_msg = commit
1690                        .summary()
1691                        .map(|s| s.chars().take(GIT_STRINGS_MAX_LENGTH).collect());
1692                }
1693            }
1694        }
1695
1696        eprintln!("Packing files...");
1697        let archive = self.make_archive()?;
1698
1699        if let Some(path) = args.output_archive {
1700            eprintln!("Writing archive to {}", path.display());
1701            std::fs::write(path, archive).context("writing archive")?;
1702
1703            return Ok(());
1704        }
1705
1706        // TODO: upload secrets separately
1707
1708        let pid = self.ctx.project_id();
1709
1710        eprintln!("Uploading code...");
1711        let arch = client.upload_archive(pid, archive).await?.into_inner();
1712        deployment_req.archive_version_id = arch.archive_version_id;
1713        deployment_req.build_meta = Some(build_meta);
1714
1715        eprintln!("Creating deployment...");
1716        let (deployment, raw_json) = client
1717            .deploy(
1718                pid,
1719                DeploymentRequest::BuildArchive(Box::new(deployment_req)),
1720            )
1721            .await?
1722            .into_parts();
1723
1724        if args.tracking_args.no_follow {
1725            match self.output_mode {
1726                OutputMode::Normal => {
1727                    println!("{}", deployment.to_string_colored());
1728                }
1729                OutputMode::Json => {
1730                    println!("{}", raw_json);
1731                }
1732            }
1733            return Ok(());
1734        }
1735
1736        self.track_deployment_status_and_print_logs_on_fail(
1737            pid,
1738            &deployment.id,
1739            args.tracking_args.raw,
1740        )
1741        .await
1742    }
1743
1744    /// Returns true if the deployment failed
1745    async fn track_deployment_status(&self, pid: &str, id: &str) -> Result<bool> {
1746        let client = self.client.as_ref().unwrap();
1747        let failed = wait_with_spinner(2000, |_, pb| async move {
1748            let (deployment, raw_json) = client.get_deployment(pid, id).await?.into_parts();
1749
1750            let state = deployment.state.clone();
1751            match self.output_mode {
1752                OutputMode::Normal => {
1753                    pb.set_message(deployment.to_string_summary_colored());
1754                }
1755                OutputMode::Json => {
1756                    println!("{}", raw_json);
1757                }
1758            }
1759            let failed = state == DeploymentState::Failed;
1760            let cleanup = move || {
1761                match self.output_mode {
1762                    OutputMode::Normal => {
1763                        eprintln!("{}", deployment.to_string_colored());
1764                    }
1765                    OutputMode::Json => {
1766                        // last deployment response already printed
1767                    }
1768                }
1769                failed
1770            };
1771            match state {
1772                // non-end states
1773                DeploymentState::Pending
1774                | DeploymentState::Building
1775                | DeploymentState::InProgress => Ok(None),
1776                // end states
1777                DeploymentState::Running
1778                | DeploymentState::Stopped
1779                | DeploymentState::Stopping
1780                | DeploymentState::Unknown(_)
1781                | DeploymentState::Failed => Ok(Some(cleanup)),
1782            }
1783        })
1784        .await?;
1785
1786        Ok(failed)
1787    }
1788
1789    async fn track_deployment_status_and_print_logs_on_fail(
1790        &self,
1791        proj_id: &str,
1792        depl_id: &str,
1793        raw: bool,
1794    ) -> Result<()> {
1795        let client = self.client.as_ref().unwrap();
1796        let failed = self.track_deployment_status(proj_id, depl_id).await?;
1797        if failed {
1798            let r = client.get_deployment_logs(proj_id, depl_id).await?;
1799            match self.output_mode {
1800                OutputMode::Normal => {
1801                    let logs = r.into_inner().logs;
1802                    for log in logs {
1803                        if raw {
1804                            println!("{}", log.line);
1805                        } else {
1806                            println!("{log}");
1807                        }
1808                    }
1809                }
1810                OutputMode::Json => {
1811                    println!("{}", r.raw_json);
1812                }
1813            }
1814            return Err(anyhow!("Deployment failed"));
1815        }
1816
1817        Ok(())
1818    }
1819
1820    async fn project_create(&self) -> Result<()> {
1821        let client = self.client.as_ref().unwrap();
1822        let name = self.ctx.project_name();
1823        let r = client.create_project(name).await?;
1824
1825        match self.output_mode {
1826            OutputMode::Normal => {
1827                let project = r.into_inner();
1828                println!("Created project '{}' with id {}", project.name, project.id);
1829            }
1830            OutputMode::Json => {
1831                println!("{}", r.raw_json);
1832            }
1833        }
1834
1835        Ok(())
1836    }
1837    async fn project_rename(&self, name: String) -> Result<()> {
1838        let client = self.client.as_ref().unwrap();
1839
1840        let r = client
1841            .update_project(
1842                self.ctx.project_id(),
1843                ProjectUpdateRequest {
1844                    name: Some(name),
1845                    ..Default::default()
1846                },
1847            )
1848            .await?;
1849
1850        match self.output_mode {
1851            OutputMode::Normal => {
1852                let project = r.into_inner();
1853                println!("Renamed project {} to '{}'", project.id, project.name);
1854            }
1855            OutputMode::Json => {
1856                println!("{}", r.raw_json);
1857            }
1858        }
1859
1860        Ok(())
1861    }
1862
1863    async fn projects_list(&self, table_args: TableArgs) -> Result<()> {
1864        let client = self.client.as_ref().unwrap();
1865        let r = client.get_projects_list().await?;
1866
1867        match self.output_mode {
1868            OutputMode::Normal => {
1869                let all_projects = r.into_inner().projects;
1870                // partition by team id and print separate tables
1871                let mut all_projects_map = BTreeMap::new();
1872                for proj in all_projects {
1873                    all_projects_map
1874                        .entry(proj.team_id.clone())
1875                        .or_insert_with(Vec::new)
1876                        .push(proj);
1877                }
1878                for (team_id, projects) in all_projects_map {
1879                    println!(
1880                        "{}",
1881                        if let Some(team_id) = team_id {
1882                            format!("Team {} projects", team_id)
1883                        } else {
1884                            "Personal Projects".to_owned()
1885                        }
1886                        .bold()
1887                    );
1888                    println!("{}\n", get_projects_table(&projects, table_args.raw));
1889                }
1890            }
1891            OutputMode::Json => {
1892                println!("{}", r.raw_json);
1893            }
1894        }
1895
1896        Ok(())
1897    }
1898
1899    async fn project_status(&self) -> Result<()> {
1900        let client = self.client.as_ref().unwrap();
1901        let r = client.get_project(self.ctx.project_id()).await?;
1902
1903        match self.output_mode {
1904            OutputMode::Normal => {
1905                print!("{}", r.into_inner().to_string_colored());
1906            }
1907            OutputMode::Json => {
1908                println!("{}", r.raw_json);
1909            }
1910        }
1911
1912        Ok(())
1913    }
1914
1915    async fn project_delete(&self, no_confirm: bool) -> Result<()> {
1916        let client = self.client.as_ref().unwrap();
1917        let pid = self.ctx.project_id();
1918
1919        if !no_confirm {
1920            // check that the project exists, and look up the name
1921            let proj = client.get_project(pid).await?.into_inner();
1922            eprintln!(
1923                "{}",
1924                formatdoc!(
1925                    r#"
1926                    WARNING:
1927                        Are you sure you want to delete '{}' ({})?
1928                        This will...
1929                        - Shut down your service
1930                        - Delete any databases and secrets in this project
1931                        - Delete any custom domains linked to this project
1932                        This action is permanent."#,
1933                    proj.name,
1934                    pid,
1935                )
1936                .bold()
1937                .red()
1938            );
1939            if !Confirm::with_theme(&ColorfulTheme::default())
1940                .with_prompt("Are you sure?")
1941                .default(false)
1942                .interact()
1943                .unwrap()
1944            {
1945                return Ok(());
1946            }
1947        }
1948
1949        let res = client.delete_project(pid).await?.into_inner();
1950
1951        println!("{res}");
1952
1953        Ok(())
1954    }
1955
1956    /// Find list of all files to include in a build, ready for placing in a zip archive
1957    fn gather_build_files(&self) -> Result<BTreeMap<PathBuf, PathBuf>> {
1958        let include_patterns = self.ctx.include();
1959
1960        let project_directory = self.ctx.project_directory();
1961
1962        //
1963        // Mixing include and exclude overrides messes up the .ignore and .gitignore etc,
1964        // therefore these "ignore" walk and the "include" walk are separate.
1965        //
1966        let mut entries = Vec::new();
1967
1968        // Default excludes
1969        let ignore_overrides = OverrideBuilder::new(project_directory)
1970            .add("!.git/")
1971            .context("adding override `!.git/`")?
1972            .add("!target/")
1973            .context("adding override `!target/`")?
1974            .build()
1975            .context("building archive override rules")?;
1976        for r in WalkBuilder::new(project_directory)
1977            .hidden(false)
1978            .overrides(ignore_overrides)
1979            .build()
1980        {
1981            entries.push(r.context("list dir entry")?.into_path())
1982        }
1983
1984        // User provided includes
1985        let mut globs = GlobSetBuilder::new();
1986        if let Some(rules) = include_patterns {
1987            for r in rules {
1988                globs.add(Glob::new(r.as_str()).context(format!("parsing glob pattern {:?}", r))?);
1989            }
1990        }
1991
1992        // Find the files
1993        let globs = globs.build().context("glob glob")?;
1994        for entry in walkdir::WalkDir::new(project_directory) {
1995            let path = entry.context("list dir")?.into_path();
1996            if globs.is_match(
1997                path.strip_prefix(project_directory)
1998                    .context("strip prefix of path")?,
1999            ) {
2000                entries.push(path);
2001            }
2002        }
2003
2004        let mut archive_files = BTreeMap::new();
2005        for path in entries {
2006            // It's not possible to add a directory to an archive
2007            if path.is_dir() {
2008                trace!("Skipping {:?}: is a directory", path);
2009                continue;
2010            }
2011            // symlinks == chaos
2012            if path.is_symlink() {
2013                trace!("Skipping {:?}: is a symlink", path);
2014                continue;
2015            }
2016
2017            // zip file puts all files in root
2018            let name = path
2019                .strip_prefix(project_directory)
2020                .context("strip prefix of path")?
2021                .to_owned();
2022
2023            archive_files.insert(path, name);
2024        }
2025
2026        Ok(archive_files)
2027    }
2028
2029    fn make_archive(&self) -> Result<Vec<u8>> {
2030        let archive_files = self.gather_build_files()?;
2031        if archive_files.is_empty() {
2032            error!("No files included in upload. Aborting...");
2033            bail!("No files included in upload.");
2034        }
2035
2036        let bytes = {
2037            debug!("making zip archive");
2038            let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
2039            for (path, name) in archive_files {
2040                debug!("Packing {path:?}");
2041
2042                // windows things
2043                let name = name.to_str().expect("valid filename").replace('\\', "/");
2044                zip.start_file(name, FileOptions::<()>::default())?;
2045
2046                let mut b = Vec::new();
2047                fs::File::open(path)?.read_to_end(&mut b)?;
2048                zip.write_all(&b)?;
2049            }
2050            let r = zip.finish().context("finish encoding zip archive")?;
2051
2052            r.into_inner()
2053        };
2054        debug!("Archive size: {} bytes", bytes.len());
2055
2056        Ok(bytes)
2057    }
2058}
2059
2060/// Calls async function `f` in a loop with `millis` sleep between iterations,
2061/// providing iteration count and reference to update the progress bar.
2062/// `f` returns Some with a cleanup function if done.
2063/// The cleanup function is called after teardown of progress bar,
2064/// and its return value is returned from here.
2065async fn wait_with_spinner<Fut, C, O>(
2066    millis: u64,
2067    f: impl Fn(usize, ProgressBar) -> Fut,
2068) -> Result<O, anyhow::Error>
2069where
2070    Fut: std::future::Future<Output = Result<Option<C>>>,
2071    C: FnOnce() -> O,
2072{
2073    let progress_bar = create_spinner();
2074    let mut count = 0usize;
2075    let cleanup = loop {
2076        if let Some(cleanup) = f(count, progress_bar.clone()).await? {
2077            break cleanup;
2078        }
2079        count += 1;
2080        sleep(Duration::from_millis(millis)).await;
2081    };
2082    progress_bar.finish_and_clear();
2083
2084    Ok(cleanup())
2085}
2086
2087fn create_spinner() -> ProgressBar {
2088    let pb = indicatif::ProgressBar::new_spinner();
2089    pb.enable_steady_tick(std::time::Duration::from_millis(250));
2090    pb.set_style(
2091        indicatif::ProgressStyle::with_template("{spinner:.orange} {msg}")
2092            .unwrap()
2093            .tick_strings(&[
2094                "( ●    )",
2095                "(  ●   )",
2096                "(   ●  )",
2097                "(    ● )",
2098                "(     ●)",
2099                "(    ● )",
2100                "(   ●  )",
2101                "(  ●   )",
2102                "( ●    )",
2103                "(●     )",
2104                "(●●●●●●)",
2105            ]),
2106    );
2107
2108    pb
2109}
2110
2111#[cfg(test)]
2112mod tests {
2113    use zip::ZipArchive;
2114
2115    use crate::args::ProjectArgs;
2116    use crate::Shuttle;
2117    use std::fs::{self, canonicalize};
2118    use std::io::Cursor;
2119    use std::path::PathBuf;
2120
2121    pub fn path_from_workspace_root(path: &str) -> PathBuf {
2122        let path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap())
2123            .join("..")
2124            .join(path);
2125
2126        dunce::canonicalize(path).unwrap()
2127    }
2128
2129    async fn get_archive_entries(project_args: ProjectArgs) -> Vec<String> {
2130        let mut shuttle = Shuttle::new(crate::Binary::Shuttle, None).unwrap();
2131        shuttle
2132            .load_project(&project_args, false, false)
2133            .await
2134            .unwrap();
2135
2136        let archive = shuttle.make_archive().unwrap();
2137
2138        let mut zip = ZipArchive::new(Cursor::new(archive)).unwrap();
2139        (0..zip.len())
2140            .map(|i| zip.by_index(i).unwrap().name().to_owned())
2141            .collect()
2142    }
2143
2144    #[tokio::test]
2145    async fn make_archive_respect_rules() {
2146        let working_directory = canonicalize(path_from_workspace_root(
2147            "cargo-shuttle/tests/resources/archiving",
2148        ))
2149        .unwrap();
2150
2151        fs::write(working_directory.join("Secrets.toml"), "KEY = 'value'").unwrap();
2152        fs::write(working_directory.join("Secrets.dev.toml"), "KEY = 'dev'").unwrap();
2153        fs::write(working_directory.join("asset2"), "").unwrap();
2154        fs::write(working_directory.join("asset4"), "").unwrap();
2155        fs::create_dir_all(working_directory.join("dist")).unwrap();
2156        fs::write(working_directory.join("dist").join("dist1"), "").unwrap();
2157
2158        fs::create_dir_all(working_directory.join("target")).unwrap();
2159        fs::write(working_directory.join("target").join("binary"), b"12345").unwrap();
2160
2161        let project_args = ProjectArgs {
2162            working_directory: working_directory.clone(),
2163            name: None,
2164            id: Some("proj_archiving-test".to_owned()),
2165        };
2166        let mut entries = get_archive_entries(project_args.clone()).await;
2167        entries.sort();
2168
2169        let expected = vec![
2170            ".gitignore",
2171            ".ignore",
2172            "Cargo.toml",
2173            "Secrets.toml.example",
2174            "Shuttle.toml",
2175            "asset1", // normal file
2176            "asset2", // .gitignore'd, but included in Shuttle.toml
2177            // asset3 is .ignore'd
2178            "asset4",                // .gitignore'd, but un-ignored in .ignore
2179            "asset5",                // .ignore'd, but included in Shuttle.toml
2180            "dist/dist1",            // .gitignore'd, but included in Shuttle.toml
2181            "nested/static/nested1", // normal file
2182            // nested/static/nestedignore is .gitignore'd
2183            "src/main.rs",
2184        ];
2185        assert_eq!(entries, expected);
2186    }
2187
2188    #[tokio::test]
2189    async fn finds_workspace_root() {
2190        let project_args = ProjectArgs {
2191            working_directory: path_from_workspace_root("examples/axum/hello-world/src"),
2192            name: None,
2193            id: None,
2194        };
2195
2196        assert_eq!(
2197            project_args.workspace_path().unwrap(),
2198            path_from_workspace_root("examples/axum/hello-world")
2199        );
2200    }
2201}