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