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